mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 10:13:32 -04:00 
			
		
		
		
	feat: ✨ (WIP) base-shoppinglist infra (#911)
* feat: ✨ base-shoppinglist infra (WIP) * add type checker * implement controllers * apply router fixes * add checked section hide/animation * add label support * formatting * fix overflow images * add experimental banner * fix #912 word break issue * remove any type errors * bump dependencies * remove templates * fix build errors * bump node version * fix template literal
This commit is contained in:
		
							
								
								
									
										2
									
								
								.github/workflows/frontend-lint.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/frontend-lint.yml
									
									
									
									
										vendored
									
									
								
							| @@ -15,7 +15,7 @@ jobs: | ||||
|     strategy: | ||||
|       matrix: | ||||
|         os: [ubuntu-latest] | ||||
|         node: [15] | ||||
|         node: [16] | ||||
|  | ||||
|     steps: | ||||
|       - name: Checkout 🛎 | ||||
|   | ||||
							
								
								
									
										1
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @@ -42,6 +42,7 @@ | ||||
|   "python.testing.pytestArgs": ["tests"], | ||||
|   "python.testing.pytestEnabled": true, | ||||
|   "python.testing.unittestEnabled": false, | ||||
|   "python.analysis.typeCheckingMode": "off", | ||||
|   "search.mode": "reuseEditor", | ||||
|   "vetur.validation.template": false, | ||||
|   "python.sortImports.path": "${workspaceFolder}/.venv/bin/isort" | ||||
|   | ||||
| @@ -135,7 +135,6 @@ WORKDIR / | ||||
|  | ||||
| # copy frontend | ||||
| # COPY --from=frontend-build /app/dist $MEALIE_HOME/dist | ||||
| COPY ./dev/data/templates $MEALIE_HOME/data/templates | ||||
| COPY ./Caddyfile $MEALIE_HOME | ||||
|  | ||||
| # Grab CRF++ Model Release | ||||
|   | ||||
							
								
								
									
										24
									
								
								dev/data/templates/recipes.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								dev/data/templates/recipes.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| # {{ recipe.name }} | ||||
| {{ recipe.description }} | ||||
|  | ||||
| ## Ingredients | ||||
| {% for ingredient in recipe.recipeIngredient %}  | ||||
| - [ ] {{ ingredient }} {% endfor %} | ||||
|  | ||||
| ## Instructions | ||||
| {% for step in recipe.recipeInstructions %}  | ||||
| - [ ] {{ step.text }} {% endfor %} | ||||
|  | ||||
| {% for note in recipe.notes %} | ||||
| **{{ note.title }}:** {{ note.text }} | ||||
| {% endfor %} | ||||
|  | ||||
| --- | ||||
|  | ||||
| Tags: {{ recipe.tags }} | ||||
| Categories: {{ recipe.categories }} | ||||
| Original URL: {{ recipe.orgURL }} | ||||
| @@ -1,4 +1,4 @@ | ||||
| FROM node:lts as builder | ||||
| FROM node:16 as builder | ||||
|  | ||||
| WORKDIR /app | ||||
|  | ||||
| @@ -21,7 +21,7 @@ RUN rm -rf node_modules && \ | ||||
|   --non-interactive \ | ||||
|   --production=true | ||||
|  | ||||
| FROM node:15-alpine | ||||
| FROM node:16-alpine | ||||
|  | ||||
| WORKDIR /app | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,22 @@ | ||||
| import { BaseCRUDAPI } from "../_base"; | ||||
|  | ||||
| const prefix = "/api"; | ||||
|  | ||||
| const routes = { | ||||
|   labels: `${prefix}/groups/labels`, | ||||
|   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> { | ||||
|   baseRoute = routes.labels; | ||||
|   itemRoute = routes.labelsId; | ||||
| } | ||||
							
								
								
									
										60
									
								
								frontend/api/class-interfaces/group-shopping-lists.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								frontend/api/class-interfaces/group-shopping-lists.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| import { BaseCRUDAPI } from "../_base"; | ||||
| import { ApiRequestInstance } from "~/types/api"; | ||||
| import { IngredientFood, IngredientUnit } from "~/types/api-types/recipe"; | ||||
|  | ||||
| const prefix = "/api"; | ||||
|  | ||||
| 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}`, | ||||
| }; | ||||
|  | ||||
| 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> { | ||||
|   baseRoute = routes.shoppingLists; | ||||
|   itemRoute = routes.shoppingListsId; | ||||
|  | ||||
|   async addRecipe(itemId: string, recipeId: number) { | ||||
|     return await this.requests.post(routes.shoppingListIdAddRecipe(itemId, recipeId), {}); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export class ShoppingApi { | ||||
|   public lists: ShoppingListsApi; | ||||
|  | ||||
|   constructor(requests: ApiRequestInstance) { | ||||
|     this.lists = new ShoppingListsApi(requests); | ||||
|   } | ||||
| } | ||||
| @@ -21,6 +21,8 @@ import { AdminAPI } from "./admin-api"; | ||||
| import { ToolsApi } from "./class-interfaces/tools"; | ||||
| import { GroupMigrationApi } from "./class-interfaces/group-migrations"; | ||||
| import { GroupReportsApi } from "./class-interfaces/group-reports"; | ||||
| import { ShoppingApi } from "./class-interfaces/group-shopping-lists"; | ||||
| import { MultiPurposeLabelsApi } from "./class-interfaces/group-multiple-purpose-labels"; | ||||
| import { ApiRequestInstance } from "~/types/api"; | ||||
|  | ||||
| class Api { | ||||
| @@ -46,6 +48,8 @@ class Api { | ||||
|   public groupReports: GroupReportsApi; | ||||
|   public grouperServerTasks: GroupServerTaskAPI; | ||||
|   public tools: ToolsApi; | ||||
|   public shopping: ShoppingApi; | ||||
|   public multiPurposeLabels: MultiPurposeLabelsApi; | ||||
|   // Utils | ||||
|   public upload: UploadFile; | ||||
|  | ||||
| @@ -74,6 +78,8 @@ class Api { | ||||
|     // Group | ||||
|     this.groupMigration = new GroupMigrationApi(requests); | ||||
|     this.groupReports = new GroupReportsApi(requests); | ||||
|     this.shopping = new ShoppingApi(requests); | ||||
|     this.multiPurposeLabels = new MultiPurposeLabelsApi(requests); | ||||
|  | ||||
|     // Admin | ||||
|     this.events = new EventsAPI(requests); | ||||
|   | ||||
| @@ -22,3 +22,20 @@ | ||||
| .theme--dark.v-card { | ||||
|   background-color: #2b2b2b !important; | ||||
| } | ||||
|  | ||||
| .left-border { | ||||
|   border-left: 5px solid var(--v-primary-base) !important; | ||||
| } | ||||
|  | ||||
| .handle { | ||||
|   cursor: grab; | ||||
| } | ||||
|  | ||||
| .hidden { | ||||
|   visibility: hidden !important; | ||||
| } | ||||
|  | ||||
| .v-card__text, | ||||
| .v-card__title { | ||||
|   word-break: normal !important; | ||||
| } | ||||
|   | ||||
| @@ -58,7 +58,6 @@ | ||||
|         show-print | ||||
|         :menu-top="false" | ||||
|         :slug="slug" | ||||
|         :name="name" | ||||
|         :menu-icon="$globals.icons.mdiDotsHorizontal" | ||||
|         fab | ||||
|         color="info" | ||||
| @@ -69,6 +68,7 @@ | ||||
|           edit: false, | ||||
|           download: true, | ||||
|           mealplanner: true, | ||||
|           shoppingList: true, | ||||
|           print: true, | ||||
|           share: true, | ||||
|         }" | ||||
|   | ||||
| @@ -38,6 +38,7 @@ | ||||
|                 edit: true, | ||||
|                 download: true, | ||||
|                 mealplanner: true, | ||||
|                 shoppingList: true, | ||||
|                 print: false, | ||||
|                 share: true, | ||||
|               }" | ||||
|   | ||||
| @@ -39,6 +39,7 @@ | ||||
|                   edit: true, | ||||
|                   download: true, | ||||
|                   mealplanner: true, | ||||
|                   shoppingList: true, | ||||
|                   print: false, | ||||
|                   share: true, | ||||
|                 }" | ||||
|   | ||||
| @@ -11,7 +11,7 @@ | ||||
|       <BaseCardSectionTitle :title="key"> </BaseCardSectionTitle> | ||||
|       <v-row> | ||||
|         <v-col v-for="(item, index) in itms" :key="'cat' + index" cols="12" :sm="12" :md="6" :lg="4" :xl="3"> | ||||
|           <v-card hover :to="`/recipes/${itemType}/${item.slug}`"> | ||||
|           <v-card class="left-border" hover :to="`/recipes/${itemType}/${item.slug}`"> | ||||
|             <v-card-actions> | ||||
|               <v-icon> | ||||
|                 {{ icon }} | ||||
|   | ||||
| @@ -46,6 +46,21 @@ | ||||
|         <v-select v-model="newMealType" :return-object="false" :items="planTypeOptions" label="Entry Type"></v-select> | ||||
|       </v-card-text> | ||||
|     </BaseDialog> | ||||
|     <BaseDialog v-model="shoppingListDialog" title="Add to List" :icon="$globals.icons.cartCheck"> | ||||
|       <v-card-text> | ||||
|         <v-card | ||||
|           v-for="list in shoppingLists" | ||||
|           :key="list.id" | ||||
|           hover | ||||
|           class="my-2 left-border" | ||||
|           @click="addRecipeToList(list.id)" | ||||
|         > | ||||
|           <v-card-title class="py-2"> | ||||
|             {{ list.name }} | ||||
|           </v-card-title> | ||||
|         </v-card> | ||||
|       </v-card-text> | ||||
|     </BaseDialog> | ||||
|     <v-menu | ||||
|       offset-y | ||||
|       left | ||||
| @@ -76,17 +91,19 @@ | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import { defineComponent, reactive, toRefs, useContext, useRouter } from "@nuxtjs/composition-api"; | ||||
| import { defineComponent, reactive, toRefs, useContext, useRouter, ref } from "@nuxtjs/composition-api"; | ||||
| import RecipeDialogShare from "./RecipeDialogShare.vue"; | ||||
| import { useUserApi } from "~/composables/api"; | ||||
| import { alert } from "~/composables/use-toast"; | ||||
| import { MealType, planTypeOptions } from "~/composables/use-group-mealplan"; | ||||
| import { ShoppingListSummary } from "~/api/class-interfaces/group-shopping-lists"; | ||||
|  | ||||
| export interface ContextMenuIncludes { | ||||
|   delete: boolean; | ||||
|   edit: boolean; | ||||
|   download: boolean; | ||||
|   mealplanner: boolean; | ||||
|   shoppingList: boolean; | ||||
|   print: boolean; | ||||
|   share: boolean; | ||||
| } | ||||
| @@ -110,6 +127,7 @@ export default defineComponent({ | ||||
|         edit: true, | ||||
|         download: true, | ||||
|         mealplanner: true, | ||||
|         shoppingList: true, | ||||
|         print: true, | ||||
|         share: true, | ||||
|       }), | ||||
| @@ -160,6 +178,7 @@ export default defineComponent({ | ||||
|       shareDialog: false, | ||||
|       recipeDeleteDialog: false, | ||||
|       mealplannerDialog: false, | ||||
|       shoppingListDialog: false, | ||||
|       loading: false, | ||||
|       menuItems: [] as ContextMenuItem[], | ||||
|       newMealdate: "", | ||||
| @@ -197,6 +216,12 @@ export default defineComponent({ | ||||
|         color: undefined, | ||||
|         event: "mealplanner", | ||||
|       }, | ||||
|       shoppingList: { | ||||
|         title: "Add to List", | ||||
|         icon: $globals.icons.cartCheck, | ||||
|         color: undefined, | ||||
|         event: "shoppingList", | ||||
|       }, | ||||
|       print: { | ||||
|         title: i18n.t("general.print") as string, | ||||
|         icon: $globals.icons.printer, | ||||
| @@ -229,6 +254,23 @@ export default defineComponent({ | ||||
|     // =========================================================================== | ||||
|     // Context Menu Event Handler | ||||
|  | ||||
|     const shoppingLists = ref<ShoppingListSummary[]>(); | ||||
|  | ||||
|     async function getShoppingLists() { | ||||
|       const { data } = await api.shopping.lists.getAll(); | ||||
|       if (data) { | ||||
|         shoppingLists.value = data; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     async function addRecipeToList(listId: string) { | ||||
|       const { data } = await api.shopping.lists.addRecipe(listId, props.recipeId); | ||||
|       if (data) { | ||||
|         alert.success("Recipe added to list"); | ||||
|         state.shoppingListDialog = false; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const router = useRouter(); | ||||
|  | ||||
|     async function deleteRecipe() { | ||||
| @@ -270,6 +312,10 @@ export default defineComponent({ | ||||
|       mealplanner: () => { | ||||
|         state.mealplannerDialog = true; | ||||
|       }, | ||||
|       shoppingList: () => { | ||||
|         getShoppingLists(); | ||||
|         state.shoppingListDialog = true; | ||||
|       }, | ||||
|       share: () => { | ||||
|         state.shareDialog = true; | ||||
|       }, | ||||
| @@ -289,6 +335,8 @@ export default defineComponent({ | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|       shoppingLists, | ||||
|       addRecipeToList, | ||||
|       ...toRefs(state), | ||||
|       contextMenuEventHandler, | ||||
|       deleteRecipe, | ||||
|   | ||||
							
								
								
									
										141
									
								
								frontend/components/Domain/ShoppingList/ShoppingListItem.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								frontend/components/Domain/ShoppingList/ShoppingListItem.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,141 @@ | ||||
| <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')"> | ||||
|       <template #label> | ||||
|         <div> | ||||
|           {{ listItem.quantity }} <v-icon size="16" class="mx-1"> {{ $globals.icons.close }} </v-icon> | ||||
|           {{ listItem.note }} | ||||
|         </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> | ||||
|   </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> | ||||
| </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"; | ||||
|  | ||||
| interface actions { | ||||
|   text: string; | ||||
|   event: string; | ||||
| } | ||||
|  | ||||
| const contextMenu: actions[] = [ | ||||
|   { | ||||
|     text: "Edit", | ||||
|     event: "edit", | ||||
|   }, | ||||
| //   { | ||||
| //     text: "Delete", | ||||
| //     event: "delete", | ||||
| //   }, | ||||
| //   { | ||||
| //     text: "Move", | ||||
| //     event: "move", | ||||
| //   }, | ||||
| ]; | ||||
|  | ||||
| export default defineComponent({ | ||||
|   props: { | ||||
|     value: { | ||||
|       type: Object as () => ShoppingListItemCreate, | ||||
|       required: true, | ||||
|     }, | ||||
|     labels: { | ||||
|       type: Array as () => Label[], | ||||
|       required: true, | ||||
|     }, | ||||
|   }, | ||||
|   setup(props, context) { | ||||
|     const listItem = computed({ | ||||
|       get: () => { | ||||
|         return props.value; | ||||
|       }, | ||||
|       set: (val) => { | ||||
|         context.emit("input", val); | ||||
|       }, | ||||
|     }); | ||||
|     const edit = ref(false); | ||||
|     function contextHandler(event: string) { | ||||
|       if (event === "edit") { | ||||
|         edit.value = true; | ||||
|       } else { | ||||
|         context.emit(event); | ||||
|       } | ||||
|     } | ||||
|     function save() { | ||||
|       context.emit("save"); | ||||
|       edit.value = false; | ||||
|     } | ||||
|  | ||||
|     function handle(event: string) { | ||||
|       console.log(event); | ||||
|     } | ||||
|  | ||||
|     const updatedLabels = computed(() => { | ||||
|       return props.labels.map((label) => { | ||||
|         return { | ||||
|           id: label.id, | ||||
|           text: label.name, | ||||
|         }; | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     return { | ||||
|       updatedLabels, | ||||
|       handle, | ||||
|       save, | ||||
|       contextHandler, | ||||
|       edit, | ||||
|       contextMenu, | ||||
|       listItem, | ||||
|     }; | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
| @@ -60,7 +60,9 @@ | ||||
|  | ||||
|     <!-- Secondary Links --> | ||||
|     <template v-if="secondaryLinks"> | ||||
|       <v-subheader v-if="secondaryHeader" class="pb-0">{{ secondaryHeader }}</v-subheader> | ||||
|       <v-subheader v-if="secondaryHeader" :to="secondaryHeaderLink" class="pb-0"> | ||||
|         {{ secondaryHeader }} | ||||
|       </v-subheader> | ||||
|       <v-divider></v-divider> | ||||
|       <v-list nav dense exact> | ||||
|         <template v-for="nav in secondaryLinks"> | ||||
| @@ -161,6 +163,10 @@ export default defineComponent({ | ||||
|       type: String, | ||||
|       default: null, | ||||
|     }, | ||||
|     secondaryHeaderLink: { | ||||
|       type: String, | ||||
|       default: null, | ||||
|     }, | ||||
|   }, | ||||
|   setup(props, context) { | ||||
|     // V-Model Support | ||||
|   | ||||
							
								
								
									
										6
									
								
								frontend/components/global/BannerExperimental.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								frontend/components/global/BannerExperimental.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| <template> | ||||
|   <v-alert border="left" colored-border type="warning" elevation="2" :icon="$globals.icons.alert"> | ||||
|     <b>Experimental Feature</b> | ||||
|     <div>This page contains experimental or still-baking features. Please excuse the mess.</div> | ||||
|   </v-alert> | ||||
| </template> | ||||
							
								
								
									
										56
									
								
								frontend/components/global/BaseButtonGroup.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								frontend/components/global/BaseButtonGroup.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | ||||
| <template> | ||||
|   <v-item-group> | ||||
|     <template v-for="btn in buttons"> | ||||
|       <v-menu v-if="btn.children" :key="'menu-' + btn.event" active-class="pa-0" offset-x left> | ||||
|         <template #activator="{ on, attrs }"> | ||||
|           <v-btn tile large icon v-bind="attrs" v-on="on"> | ||||
|             <v-icon> | ||||
|               {{ btn.icon }} | ||||
|             </v-icon> | ||||
|           </v-btn> | ||||
|         </template> | ||||
|         <v-list dense> | ||||
|           <v-list-item v-for="(child, idx) in btn.children" :key="idx" dense @click="$emit(child.event)"> | ||||
|             <v-list-item-title>{{ child.text }}</v-list-item-title> | ||||
|           </v-list-item> | ||||
|         </v-list> | ||||
|       </v-menu> | ||||
|       <v-tooltip | ||||
|         v-else | ||||
|         :key="'btn-' + btn.event" | ||||
|         open-delay="200" | ||||
|         transition="slide-y-reverse-transition" | ||||
|         dense | ||||
|         bottom | ||||
|         content-class="text-caption" | ||||
|       > | ||||
|         <template #activator="{ on, attrs }"> | ||||
|           <v-btn tile large icon v-bind="attrs" @click="$emit(btn.event)" v-on="on"> | ||||
|             <v-icon> {{ btn.icon }} </v-icon> | ||||
|           </v-btn> | ||||
|         </template> | ||||
|         <span>{{ btn.text }}</span> | ||||
|       </v-tooltip> | ||||
|     </template> | ||||
|   </v-item-group> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import { defineComponent } from "@nuxtjs/composition-api"; | ||||
|  | ||||
| export interface ButtonOption { | ||||
|   icon: string; | ||||
|   text: string; | ||||
|   event: string; | ||||
|   children: ButtonOption[]; | ||||
| } | ||||
|  | ||||
| export default defineComponent({ | ||||
|   props: { | ||||
|     buttons: { | ||||
|       type: Array as () => ButtonOption[], | ||||
|       required: true, | ||||
|     }, | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
| @@ -22,7 +22,7 @@ | ||||
|         </v-list-item> | ||||
|       </v-list-item-group> | ||||
|     </v-list> | ||||
|     <!--  Event --> | ||||
|     <!--  Links --> | ||||
|     <v-list v-else-if="mode === MODES.link" dense> | ||||
|       <v-list-item-group v-model="itemGroup"> | ||||
|         <v-list-item v-for="(item, index) in items" :key="index" :to="item.to"> | ||||
| @@ -58,6 +58,13 @@ const MODES = { | ||||
|   event: "event", | ||||
| }; | ||||
|  | ||||
| export interface MenuItem { | ||||
|   text: string; | ||||
|   icon: string; | ||||
|   to?: string; | ||||
|   event: string; | ||||
| } | ||||
|  | ||||
| export default defineComponent({ | ||||
|   props: { | ||||
|     mode: { | ||||
| @@ -65,7 +72,7 @@ export default defineComponent({ | ||||
|       default: "model", | ||||
|     }, | ||||
|     items: { | ||||
|       type: Array, | ||||
|       type: Array as () => MenuItem[], | ||||
|       required: true, | ||||
|     }, | ||||
|     disabled: { | ||||
| @@ -92,6 +99,8 @@ export default defineComponent({ | ||||
|     const activeObj = ref({ | ||||
|       text: "DEFAULT", | ||||
|       value: "", | ||||
|       icon: undefined, | ||||
|       event: undefined, | ||||
|     }); | ||||
|  | ||||
|     let startIndex = 0; | ||||
|   | ||||
| @@ -19,7 +19,7 @@ export function detectServerBaseUrl(req?: IncomingMessage | null) { | ||||
|   } else if (req.socket.remoteAddress) { | ||||
|     // @ts-ignore | ||||
|     const protocol = req.socket.encrypted ? "https:" : "http:"; | ||||
|     return `${protocol}//${req.socket.localAddress}:${req.socket.localPort}`; | ||||
|     return `${protocol}//${req.socket.localAddress || ""}:${req.socket.localPort || ""}`; | ||||
|   } | ||||
|  | ||||
|   return ""; | ||||
|   | ||||
| @@ -7,6 +7,7 @@ | ||||
|       absolute | ||||
|       :top-link="topLinks" | ||||
|       secondary-header="Cookbooks" | ||||
|       secondary-header-link="/user/group/cookbooks" | ||||
|       :secondary-links="cookbookLinks || []" | ||||
|       :bottom-links="isAdmin ? bottomLink : []" | ||||
|     > | ||||
| @@ -135,7 +136,7 @@ export default defineComponent({ | ||||
|           icon: this.$globals.icons.cartCheck, | ||||
|           title: "Shopping List", | ||||
|           subtitle: "Create a new shopping list", | ||||
|           to: "/user/group/shopping-list/create", | ||||
|           to: "/user/group/shopping-lists/create", | ||||
|           restricted: true, | ||||
|         }, | ||||
|       ], | ||||
| @@ -157,7 +158,7 @@ export default defineComponent({ | ||||
|         { | ||||
|           icon: this.$globals.icons.formatListCheck, | ||||
|           title: this.$t("shopping-list.shopping-lists"), | ||||
|           to: "/shopping-list", | ||||
|           to: "/shopping-lists", | ||||
|           restricted: true, | ||||
|         }, | ||||
|         { | ||||
|   | ||||
| @@ -229,8 +229,8 @@ export default defineComponent({ | ||||
|  | ||||
|     const weekRange = computed(() => { | ||||
|       return { | ||||
|         start: subDays(state.today, 1), | ||||
|         end: addDays(state.today, 6), | ||||
|         start: subDays(state.today as Date, 1), | ||||
|         end: addDays(state.today as Date, 6), | ||||
|       }; | ||||
|     }); | ||||
|  | ||||
| @@ -248,12 +248,12 @@ export default defineComponent({ | ||||
|  | ||||
|     function forwardOneWeek() { | ||||
|       if (!state.today) return; | ||||
|       state.today = addDays(state.today, +5); | ||||
|       state.today = addDays(state.today as Date, +5); | ||||
|     } | ||||
|  | ||||
|     function backOneWeek() { | ||||
|       if (!state.today) return; | ||||
|       state.today = addDays(state.today, -5); | ||||
|       state.today = addDays(state.today as Date, -5); | ||||
|     } | ||||
|  | ||||
|     function onMoveCallback(evt: SortableEvent) { | ||||
|   | ||||
| @@ -33,8 +33,9 @@ | ||||
|         <v-img | ||||
|           :key="imageKey" | ||||
|           :max-width="enableLandscape ? null : '50%'" | ||||
|           :min-height="hideImage ? '50' : imageHeight" | ||||
|           :src="recipeImage(recipe.slug, '', imageKey)" | ||||
|           min-height="50" | ||||
|           :height="hideImage ? undefined : imageHeight" | ||||
|           :src="recipeImage(recipe.slug, imageKey)" | ||||
|           class="d-print-none" | ||||
|           @error="hideImage = true" | ||||
|         > | ||||
| @@ -78,7 +79,12 @@ | ||||
|         > | ||||
|           <div v-if="form" class="d-flex justify-start align-center"> | ||||
|             <RecipeImageUploadBtn class="my-1" :slug="recipe.slug" @upload="uploadImage" @refresh="imageKey++" /> | ||||
|             <RecipeSettingsMenu class="my-1 mx-1" :value="recipe.settings" :is-owner="recipe.userId == $auth.user.id" @upload="uploadImage" /> | ||||
|             <RecipeSettingsMenu | ||||
|               class="my-1 mx-1" | ||||
|               :value="recipe.settings" | ||||
|               :is-owner="recipe.userId == $auth.user.id" | ||||
|               @upload="uploadImage" | ||||
|             /> | ||||
|           </div> | ||||
|           <!-- Recipe Title Section --> | ||||
|           <template v-if="!form && enableLandscape"> | ||||
| @@ -771,4 +777,3 @@ export default defineComponent({ | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
|  | ||||
|   | ||||
| @@ -1,21 +0,0 @@ | ||||
| <template> | ||||
|   <div></div> | ||||
| </template> | ||||
|    | ||||
|   <script lang="ts"> | ||||
| import { defineComponent } from "@nuxtjs/composition-api"; | ||||
|  | ||||
| export default defineComponent({ | ||||
|   setup() { | ||||
|     return {}; | ||||
|   }, | ||||
|   head() { | ||||
|     return { | ||||
|       title: this.$t("shopping-list.shopping-list") as string, | ||||
|     }; | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
|    | ||||
|   <style scoped> | ||||
| </style> | ||||
| @@ -1,21 +0,0 @@ | ||||
| <template> | ||||
|   <div></div> | ||||
| </template> | ||||
|    | ||||
| <script lang="ts"> | ||||
| import { defineComponent } from "@nuxtjs/composition-api"; | ||||
|  | ||||
| export default defineComponent({ | ||||
|   setup() { | ||||
|     return {}; | ||||
|   }, | ||||
|   head() { | ||||
|     return { | ||||
|       title: this.$t("shopping-list.shopping-list") as string, | ||||
|     }; | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
|    | ||||
| <style scoped> | ||||
| </style> | ||||
							
								
								
									
										506
									
								
								frontend/pages/shopping-lists/_id.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										506
									
								
								frontend/pages/shopping-lists/_id.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,506 @@ | ||||
| <template> | ||||
|   <v-container v-if="shoppingList" class="narrow-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 /> | ||||
|     <!-- 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" | ||||
|           /> | ||||
|         </draggable> | ||||
|       </div> | ||||
|       <div v-else> | ||||
|         <div v-for="(value, key) in itemsByLabel" :key="key" class="mb-6"> | ||||
|           <div @click="toggleShowChecked()"> | ||||
|             <span> | ||||
|               <v-icon> | ||||
|                 {{ $globals.icons.tags }} | ||||
|               </v-icon> | ||||
|             </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> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <div v-if="listItems.checked && listItems.checked.length > 0" class="mt-6"> | ||||
|         <button @click="toggleShowChecked()"> | ||||
|           <span> | ||||
|             <v-icon> | ||||
|               {{ showChecked ? $globals.icons.chevronDown : $globals.icons.chevronRight }} | ||||
|             </v-icon> | ||||
|           </span> | ||||
|           {{ listItems.checked ? listItems.checked.length : 0 }} items checked | ||||
|         </button> | ||||
|         <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> | ||||
|           </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> | ||||
|               </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-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-container> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| 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 { 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 ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue"; | ||||
| import BannerExperimental from "~/components/global/BannerExperimental.vue"; | ||||
| type CopyTypes = "plain" | "markdown"; | ||||
|  | ||||
| interface PresentLabel { | ||||
|   id: string; | ||||
|   name: string; | ||||
| } | ||||
|  | ||||
| export default defineComponent({ | ||||
|   components: { | ||||
|     draggable, | ||||
|     ShoppingListItem, | ||||
|     BannerExperimental, | ||||
|   }, | ||||
|   setup() { | ||||
|     const userApi = useUserApi(); | ||||
|  | ||||
|     const edit = ref(false); | ||||
|     const byLabel = ref(false); | ||||
|  | ||||
|     const route = useRoute(); | ||||
|     const id = route.value.params.id; | ||||
|  | ||||
|     const shoppingList = useAsync(async () => { | ||||
|       return await fetchShoppingList(); | ||||
|     }, useAsyncKey()); | ||||
|  | ||||
|     async function fetchShoppingList() { | ||||
|       const { data } = await userApi.shopping.lists.getOne(id); | ||||
|       return data; | ||||
|     } | ||||
|  | ||||
|     async function refresh() { | ||||
|       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 | ||||
|  | ||||
|     const listItems = computed(() => { | ||||
|       return { | ||||
|         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(); | ||||
|  | ||||
|     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); | ||||
|  | ||||
|       if (!items) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       let text = ""; | ||||
|  | ||||
|       switch (copyType) { | ||||
|         case "markdown": | ||||
|           text = getItemsAsMarkdown(items); | ||||
|           break; | ||||
|         default: | ||||
|           text = getItemsAsPlain(items); | ||||
|           break; | ||||
|       } | ||||
|  | ||||
|       await copy(text); | ||||
|  | ||||
|       if (copied) { | ||||
|         alert.success(`Copied ${items.length} items to clipboard`); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // ===================================== | ||||
|     // Check / Uncheck All | ||||
|  | ||||
|     function uncheckAll() { | ||||
|       let hasChanged = false; | ||||
|       shoppingList.value?.listItems.forEach((item) => { | ||||
|         if (item.checked) { | ||||
|           hasChanged = true; | ||||
|           item.checked = false; | ||||
|         } | ||||
|       }); | ||||
|       if (hasChanged) { | ||||
|         saveList(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     function deleteChecked() { | ||||
|       const unchecked = shoppingList.value?.listItems.filter((item) => !item.checked); | ||||
|  | ||||
|       if (unchecked?.length === shoppingList.value?.listItems.length) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if (shoppingList.value?.listItems) { | ||||
|         shoppingList.value.listItems = unchecked || []; | ||||
|       } | ||||
|  | ||||
|       saveList(); | ||||
|     } | ||||
|  | ||||
|     // ===================================== | ||||
|     // List Item Context Menu | ||||
|  | ||||
|     const contextActions = { | ||||
|       delete: "delete", | ||||
|       setIngredient: "setIngredient", | ||||
|     }; | ||||
|  | ||||
|     const contextMenu = [ | ||||
|       { title: "Delete", action: contextActions.delete }, | ||||
|       { title: "Ingredient", action: contextActions.setIngredient }, | ||||
|     ]; | ||||
|  | ||||
|     function contextMenuAction(action: string, item: ShoppingListItemCreate, idx: number) { | ||||
|       if (!shoppingList.value?.listItems) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       switch (action) { | ||||
|         case contextActions.delete: | ||||
|           shoppingList.value.listItems = shoppingList.value?.listItems.filter((itm) => itm.id !== item.id); | ||||
|           break; | ||||
|         case contextActions.setIngredient: | ||||
|           shoppingList.value.listItems[idx].isFood = !shoppingList.value.listItems[idx].isFood; | ||||
|           break; | ||||
|         default: | ||||
|           break; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // ===================================== | ||||
|     // Labels | ||||
|  | ||||
|     const allLabels = ref([] as Label[]); | ||||
|  | ||||
|     function sortByLabels() { | ||||
|       byLabel.value = !byLabel.value; | ||||
|     } | ||||
|  | ||||
|     const presentLabels = computed(() => { | ||||
|       const labels: PresentLabel[] = []; | ||||
|  | ||||
|       shoppingList.value?.listItems.forEach((item) => { | ||||
|         if (item.labelId) { | ||||
|           labels.push({ | ||||
|             // @ts-ignore | ||||
|             name: item.label.name, | ||||
|             id: item.labelId, | ||||
|           }); | ||||
|         } | ||||
|       }); | ||||
|  | ||||
|       return labels; | ||||
|     }); | ||||
|  | ||||
|     const itemsByLabel = computed(() => { | ||||
|       const items: { [prop: string]: ShoppingListItemCreate[] } = {}; | ||||
|  | ||||
|       const noLabel = { | ||||
|         "No Label": [], | ||||
|       }; | ||||
|  | ||||
|       shoppingList.value?.listItems.forEach((item) => { | ||||
|         if (item.labelId) { | ||||
|           if (item.label && item.label.name in items) { | ||||
|             items[item.label.name].push(item); | ||||
|           } else if (item.label) { | ||||
|             items[item.label.name] = [item]; | ||||
|           } | ||||
|         } else { | ||||
|           // @ts-ignore | ||||
|           noLabel["No Label"].push(item); | ||||
|         } | ||||
|       }); | ||||
|  | ||||
|       if (noLabel["No Label"].length > 0) { | ||||
|         items["No Label"] = noLabel["No Label"]; | ||||
|       } | ||||
|  | ||||
|       return items; | ||||
|     }); | ||||
|  | ||||
|     async function refreshLabels() { | ||||
|       const { data } = await userApi.multiPurposeLabels.getAll(); | ||||
|       allLabels.value = data ?? []; | ||||
|     } | ||||
|  | ||||
|     refreshLabels(); | ||||
|  | ||||
|     return { | ||||
|       itemsByLabel, | ||||
|       byLabel, | ||||
|       presentLabels, | ||||
|       allLabels, | ||||
|       copyListItems, | ||||
|       sortByLabels, | ||||
|       uncheckAll, | ||||
|       showChecked, | ||||
|       toggleShowChecked, | ||||
|       createIngredient, | ||||
|       contextMenuAction, | ||||
|       contextMenu, | ||||
|       deleteChecked, | ||||
|       listItems, | ||||
|       updateIndex, | ||||
|       saveList, | ||||
|       edit, | ||||
|       shoppingList, | ||||
|       ingredientCreate, | ||||
|     }; | ||||
|   }, | ||||
|   head() { | ||||
|     return { | ||||
|       title: this.$t("shopping-list.shopping-list") as string, | ||||
|     }; | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .number-input-container { | ||||
|   max-width: 50px; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										102
									
								
								frontend/pages/shopping-lists/index.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								frontend/pages/shopping-lists/index.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,102 @@ | ||||
| <template> | ||||
|   <v-container v-if="shoppingLists" class="narrow-container"> | ||||
|     <BaseDialog v-model="createDialog" :title="$t('shopping-list.create-shopping-list')" @submit="createOne"> | ||||
|       <v-card-text> | ||||
|         <v-text-field v-model="createName" autofocus :label="$t('shopping-list.new-list')"> </v-text-field> | ||||
|       </v-card-text> | ||||
|     </BaseDialog> | ||||
|  | ||||
|     <BaseDialog v-model="deleteDialog" :title="$t('general.confirm')" color="error" @confirm="deleteOne"> | ||||
|       <v-card-text> Are you sure you want to delete this item?</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 </template> | ||||
|     </BasePageTitle> | ||||
|     <BaseButton create @click="createDialog = true" /> | ||||
|  | ||||
|     <section> | ||||
|       <v-card v-for="list in shoppingLists" :key="list.id" class="my-2 left-border" :to="`/shopping-lists/${list.id}`"> | ||||
|         <v-card-title> | ||||
|           <v-icon left> | ||||
|             {{ $globals.icons.cartCheck }} | ||||
|           </v-icon> | ||||
|           {{ list.name }} | ||||
|           <v-btn class="ml-auto" icon @click.prevent="openDelete(list.id)"> | ||||
|             <v-icon> | ||||
|               {{ $globals.icons.delete }} | ||||
|             </v-icon> | ||||
|           </v-btn> | ||||
|         </v-card-title> | ||||
|       </v-card> | ||||
|     </section> | ||||
|   </v-container> | ||||
| </template> | ||||
|    | ||||
| <script lang="ts"> | ||||
| import { defineComponent, useAsync, reactive, toRefs } from "@nuxtjs/composition-api"; | ||||
| import { useUserApi } from "~/composables/api"; | ||||
| import { useAsyncKey } from "~/composables/use-utils"; | ||||
|  | ||||
| export default defineComponent({ | ||||
|   setup() { | ||||
|     const userApi = useUserApi(); | ||||
|  | ||||
|     const state = reactive({ | ||||
|       createName: "", | ||||
|       createDialog: false, | ||||
|       deleteDialog: false, | ||||
|       deleteTarget: "", | ||||
|     }); | ||||
|  | ||||
|     const shoppingLists = useAsync(async () => { | ||||
|       return await fetchShoppingLists(); | ||||
|     }, useAsyncKey()); | ||||
|  | ||||
|     async function fetchShoppingLists() { | ||||
|       const { data } = await userApi.shopping.lists.getAll(); | ||||
|       return data; | ||||
|     } | ||||
|  | ||||
|     async function refresh() { | ||||
|       shoppingLists.value = await fetchShoppingLists(); | ||||
|     } | ||||
|  | ||||
|     async function createOne() { | ||||
|       const { data } = await userApi.shopping.lists.createOne({ name: state.createName }); | ||||
|  | ||||
|       if (data) { | ||||
|         refresh(); | ||||
|         state.createName = ""; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     function openDelete(id: string) { | ||||
|       state.deleteDialog = true; | ||||
|       state.deleteTarget = id; | ||||
|     } | ||||
|  | ||||
|     async function deleteOne() { | ||||
|       const { data } = await userApi.shopping.lists.deleteOne(state.deleteTarget); | ||||
|       if (data) { | ||||
|         refresh(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|       ...toRefs(state), | ||||
|       shoppingLists, | ||||
|       createOne, | ||||
|       deleteOne, | ||||
|       openDelete, | ||||
|     }; | ||||
|   }, | ||||
|   head() { | ||||
|     return { | ||||
|       title: this.$t("shopping-list.shopping-list") as string, | ||||
|     }; | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
							
								
								
									
										1
									
								
								frontend/static/svgs/shopping-cart.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/static/svgs/shopping-cart.svg
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| After Width: | Height: | Size: 8.9 KiB | 
| @@ -103,6 +103,7 @@ import { | ||||
|   mdiTimerSand, | ||||
|   mdiRefresh, | ||||
|   mdiArrowRightBold, | ||||
|   mdiChevronRight, | ||||
| } from "@mdi/js"; | ||||
|  | ||||
| export const icons = { | ||||
| @@ -222,4 +223,5 @@ export const icons = { | ||||
|   back: mdiArrowLeftBoldOutline, | ||||
|   slotMachine: mdiSlotMachine, | ||||
|   chevronDown: mdiChevronDown, | ||||
|   chevronRight: mdiChevronRight, | ||||
| }; | ||||
|   | ||||
							
								
								
									
										2241
									
								
								frontend/yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										2241
									
								
								frontend/yarn.lock
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -4,16 +4,14 @@ from mealie.db.db_setup import create_session, engine | ||||
| from mealie.db.models._model_base import SqlAlchemyBase | ||||
| from mealie.repos.all_repositories import get_repositories | ||||
| from mealie.repos.repository_factory import AllRepositories | ||||
| from mealie.repos.seed.init_units_foods import default_recipe_unit_init | ||||
| from mealie.repos.seed.init_users import default_user_init | ||||
| from mealie.repos.seed.seeders import IngredientFoodsSeeder, IngredientUnitsSeeder, MultiPurposeLabelSeeder | ||||
| from mealie.schema.user.user import GroupBase | ||||
| from mealie.services.events import create_general_event | ||||
| from mealie.services.group_services.group_utils import create_new_group | ||||
|  | ||||
| logger = root_logger.get_logger("init_db") | ||||
|  | ||||
| settings = get_app_settings() | ||||
|  | ||||
|  | ||||
| def create_all_models(): | ||||
|     import mealie.db.models._all_models  # noqa: F401 | ||||
| @@ -22,12 +20,25 @@ def create_all_models(): | ||||
|  | ||||
|  | ||||
| def init_db(db: AllRepositories) -> None: | ||||
|     # TODO: Port other seed data to use abstract seeder class | ||||
|     default_group_init(db) | ||||
|     default_user_init(db) | ||||
|     default_recipe_unit_init(db) | ||||
|  | ||||
|     group_id = db.groups.get_all()[0].id | ||||
|  | ||||
|     seeders = [ | ||||
|         MultiPurposeLabelSeeder(db, group_id=group_id), | ||||
|         IngredientFoodsSeeder(db, group_id=group_id), | ||||
|         IngredientUnitsSeeder(db, group_id=group_id), | ||||
|     ] | ||||
|  | ||||
|     for seeder in seeders: | ||||
|         seeder.seed() | ||||
|  | ||||
|  | ||||
| def default_group_init(db: AllRepositories): | ||||
|     settings = get_app_settings() | ||||
|  | ||||
|     logger.info("Generating Default Group") | ||||
|     create_new_group(db, GroupBase(name=settings.DEFAULT_GROUP)) | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| from .event import * | ||||
| from .group import * | ||||
| from .labels import * | ||||
| from .recipe.recipe import * | ||||
| from .server import * | ||||
| from .sign_up import * | ||||
|   | ||||
| @@ -78,6 +78,8 @@ def handle_one_to_many_list(session: Session, get_attr, relation_cls, all_elemen | ||||
|     elems_to_create: list[dict] = [] | ||||
|     updated_elems: list[dict] = [] | ||||
|  | ||||
|     cfg = _get_config(relation_cls) | ||||
|  | ||||
|     for elem in all_elements: | ||||
|         elem_id = elem.get(get_attr, None) if isinstance(elem, dict) else elem | ||||
|         existing_elem = session.query(relation_cls).filter_by(**{get_attr: elem_id}).one_or_none() | ||||
| @@ -88,6 +90,7 @@ def handle_one_to_many_list(session: Session, get_attr, relation_cls, all_elemen | ||||
|  | ||||
|         elif isinstance(elem, dict): | ||||
|             for key, value in elem.items(): | ||||
|                 if key not in cfg.exclude: | ||||
|                     setattr(existing_elem, key, value) | ||||
|  | ||||
|         updated_elems.append(existing_elem) | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import sqlalchemy.orm as orm | ||||
| from sqlalchemy.orm.session import Session | ||||
|  | ||||
| from mealie.core.config import get_app_settings | ||||
| from mealie.db.models.labels import MultiPurposeLabel | ||||
|  | ||||
| from .._model_base import BaseMixins, SqlAlchemyBase | ||||
| from .._model_utils import GUID, auto_init | ||||
| @@ -47,6 +48,8 @@ class Group(SqlAlchemyBase, BaseMixins): | ||||
|         "single_parent": True, | ||||
|     } | ||||
|  | ||||
|     labels = orm.relationship(MultiPurposeLabel, **common_args) | ||||
|  | ||||
|     mealplans = orm.relationship(GroupMealPlan, order_by="GroupMealPlan.date", **common_args) | ||||
|     webhooks = orm.relationship(GroupWebhooksModel, **common_args) | ||||
|     cookbooks = orm.relationship(CookBook, **common_args) | ||||
|   | ||||
| @@ -1,51 +1,64 @@ | ||||
| import sqlalchemy.orm as orm | ||||
| from requests import Session | ||||
| from sqlalchemy import Boolean, Column, ForeignKey, Integer, String | ||||
| from sqlalchemy import Boolean, Column, Float, ForeignKey, Integer, String, orm | ||||
| from sqlalchemy.ext.orderinglist import ordering_list | ||||
|  | ||||
| from mealie.db.models.labels import MultiPurposeLabel | ||||
|  | ||||
| from .._model_base import BaseMixins, SqlAlchemyBase | ||||
| from .._model_utils.guid import GUID | ||||
| from .group import Group | ||||
| from .._model_utils import GUID, auto_init | ||||
| from ..recipe.ingredient import IngredientFoodModel, IngredientUnitModel | ||||
|  | ||||
|  | ||||
| class ShoppingListItem(SqlAlchemyBase, BaseMixins): | ||||
|     __tablename__ = "shopping_list_items" | ||||
|     id = Column(Integer, primary_key=True) | ||||
|     parent_id = Column(Integer, ForeignKey("shopping_lists.id")) | ||||
|     position = Column(Integer, nullable=False) | ||||
|  | ||||
|     title = Column(String) | ||||
|     text = Column(String) | ||||
|     quantity = Column(Integer) | ||||
|     checked = Column(Boolean) | ||||
|     # Id's | ||||
|     id = Column(GUID, primary_key=True, default=GUID.generate) | ||||
|     shopping_list_id = Column(GUID, ForeignKey("shopping_lists.id")) | ||||
|  | ||||
|     def __init__(self, title, text, quantity, checked, **_) -> None: | ||||
|         self.title = title | ||||
|         self.text = text | ||||
|         self.quantity = quantity | ||||
|         self.checked = checked | ||||
|     # 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) | ||||
|  | ||||
|     quantity = Column(Float, default=1) | ||||
|     note = Column(String) | ||||
|  | ||||
|     is_food = Column(Boolean, default=False) | ||||
|  | ||||
|     # Scaling Items | ||||
|     unit_id = Column(Integer, ForeignKey("ingredient_units.id")) | ||||
|     unit = orm.relationship(IngredientUnitModel, uselist=False) | ||||
|  | ||||
|     food_id = Column(Integer, ForeignKey("ingredient_foods.id")) | ||||
|     food = orm.relationship(IngredientFoodModel, uselist=False) | ||||
|  | ||||
|     label_id = Column(GUID, ForeignKey("multi_purpose_labels.id")) | ||||
|     label = orm.relationship(MultiPurposeLabel, uselist=False, back_populates="shopping_list_items") | ||||
|  | ||||
|     class Config: | ||||
|         exclude = {"id", "label"} | ||||
|  | ||||
|     @auto_init() | ||||
|     def __init__(self, **_) -> None: | ||||
|         pass | ||||
|  | ||||
|  | ||||
| class ShoppingList(SqlAlchemyBase, BaseMixins): | ||||
|     __tablename__ = "shopping_lists" | ||||
|     id = Column(Integer, primary_key=True) | ||||
|     id = Column(GUID, primary_key=True, default=GUID.generate) | ||||
|  | ||||
|     group_id = Column(GUID, ForeignKey("groups.id")) | ||||
|     group = orm.relationship("Group", back_populates="shopping_lists") | ||||
|  | ||||
|     name = Column(String) | ||||
|     items: list[ShoppingListItem] = orm.relationship( | ||||
|     list_items = orm.relationship( | ||||
|         ShoppingListItem, | ||||
|         cascade="all, delete, delete-orphan", | ||||
|         order_by="ShoppingListItem.position", | ||||
|         collection_class=ordering_list("position"), | ||||
|     ) | ||||
|  | ||||
|     def __init__(self, name, group, items, session=None, **_) -> None: | ||||
|         self.name = name | ||||
|         self.group = Group.get_ref(session, group) | ||||
|         self.items = [ShoppingListItem(**i) for i in items] | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_ref(session: Session, id: int): | ||||
|         return session.query(ShoppingList).filter(ShoppingList.id == id).one_or_none() | ||||
|     @auto_init() | ||||
|     def __init__(self, **_) -> None: | ||||
|         pass | ||||
|   | ||||
							
								
								
									
										22
									
								
								mealie/db/models/labels.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								mealie/db/models/labels.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| from sqlalchemy import Column, ForeignKey, String, orm | ||||
|  | ||||
| from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase | ||||
|  | ||||
| from ._model_utils import auto_init | ||||
| from ._model_utils.guid import GUID | ||||
|  | ||||
|  | ||||
| class MultiPurposeLabel(SqlAlchemyBase, BaseMixins): | ||||
|     __tablename__ = "multi_purpose_labels" | ||||
|     id = Column(GUID, default=GUID.generate, primary_key=True) | ||||
|     name = Column(String(255), nullable=False) | ||||
|  | ||||
|     group_id = Column(GUID, ForeignKey("groups.id")) | ||||
|     group = orm.relationship("Group", back_populates="labels") | ||||
|  | ||||
|     shopping_list_items = orm.relationship("ShoppingListItem", back_populates="label") | ||||
|     foods = orm.relationship("IngredientFoodModel", back_populates="label") | ||||
|  | ||||
|     @auto_init() | ||||
|     def __init__(self, **_) -> None: | ||||
|         pass | ||||
| @@ -1,6 +1,7 @@ | ||||
| from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm | ||||
|  | ||||
| from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase | ||||
| from mealie.db.models.labels import MultiPurposeLabel | ||||
|  | ||||
| from .._model_utils import auto_init | ||||
| from .._model_utils.guid import GUID | ||||
| @@ -27,6 +28,9 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins): | ||||
|     description = Column(String) | ||||
|     ingredients = orm.relationship("RecipeIngredient", back_populates="food") | ||||
|  | ||||
|     label_id = Column(GUID, ForeignKey("multi_purpose_labels.id")) | ||||
|     label = orm.relationship(MultiPurposeLabel, uselist=False, back_populates="foods") | ||||
|  | ||||
|     @auto_init() | ||||
|     def __init__(self, **_) -> None: | ||||
|         pass | ||||
| @@ -51,8 +55,6 @@ class RecipeIngredient(SqlAlchemyBase, BaseMixins): | ||||
|  | ||||
|     reference_id = Column(GUID)  # Reference Links | ||||
|  | ||||
|     # Extras | ||||
|  | ||||
|     @auto_init() | ||||
|     def __init__(self, **_) -> None: | ||||
|         pass | ||||
|   | ||||
| @@ -26,6 +26,12 @@ class RecipeInstruction(SqlAlchemyBase): | ||||
|  | ||||
|     ingredient_references = orm.relationship("RecipeIngredientRefLink", cascade="all, delete-orphan") | ||||
|  | ||||
|     class Config: | ||||
|         exclude = { | ||||
|             "id", | ||||
|             "ingredient_references", | ||||
|         } | ||||
|  | ||||
|     @auto_init() | ||||
|     def __init__(self, **_) -> None: | ||||
|         pass | ||||
|     def __init__(self, ingredient_references, **_) -> None: | ||||
|         self.ingredient_references = [RecipeIngredientRefLink(**ref) for ref in ingredient_references] | ||||
|   | ||||
| @@ -8,7 +8,9 @@ from mealie.db.models.group.cookbook import CookBook | ||||
| 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.webhooks import GroupWebhooksModel | ||||
| from mealie.db.models.labels import MultiPurposeLabel | ||||
| from mealie.db.models.recipe.category import Category | ||||
| from mealie.db.models.recipe.comment import RecipeComment | ||||
| from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel | ||||
| @@ -25,8 +27,10 @@ from mealie.schema.events import Event as EventSchema | ||||
| from mealie.schema.events import EventNotificationIn | ||||
| 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.invite_token import ReadInviteToken | ||||
| from mealie.schema.group.webhook import ReadWebhook | ||||
| from mealie.schema.labels import MultiPurposeLabelOut | ||||
| from mealie.schema.meal_plan.new_meal import ReadPlanEntry | ||||
| from mealie.schema.recipe import Recipe, RecipeCategoryResponse, RecipeCommentOut, RecipeTagResponse, RecipeTool | ||||
| from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit | ||||
| @@ -40,6 +44,7 @@ from .repository_generic import RepositoryGeneric | ||||
| from .repository_group import RepositoryGroup | ||||
| from .repository_meals import RepositoryMeals | ||||
| from .repository_recipes import RepositoryRecipes | ||||
| from .repository_shopping_list import RepositoryShoppingList | ||||
| from .repository_users import RepositoryUsers | ||||
|  | ||||
| pk_id = "id" | ||||
| @@ -176,3 +181,15 @@ class AllRepositories: | ||||
|     @cached_property | ||||
|     def group_report_entries(self) -> RepositoryGeneric[ReportEntryOut, ReportEntryModel]: | ||||
|         return RepositoryGeneric(self.session, pk_id, ReportEntryModel, ReportEntryOut) | ||||
|  | ||||
|     @cached_property | ||||
|     def group_shopping_lists(self) -> RepositoryShoppingList: | ||||
|         return RepositoryShoppingList(self.session, pk_id, ShoppingList, ShoppingListOut) | ||||
|  | ||||
|     @cached_property | ||||
|     def group_shopping_list_item(self) -> RepositoryGeneric[ShoppingListItemOut, ShoppingListItem]: | ||||
|         return RepositoryGeneric(self.session, pk_id, ShoppingListItem, ShoppingListItemOut) | ||||
|  | ||||
|     @cached_property | ||||
|     def group_multi_purpose_labels(self) -> RepositoryGeneric[MultiPurposeLabelOut, MultiPurposeLabel]: | ||||
|         return RepositoryGeneric(self.session, pk_id, MultiPurposeLabel, MultiPurposeLabelOut) | ||||
|   | ||||
| @@ -146,7 +146,7 @@ class RepositoryGeneric(Generic[T, D]): | ||||
|         filter = self._filter_builder(**{match_key: match_value}) | ||||
|         return self.session.query(self.sql_model).filter_by(**filter).one() | ||||
|  | ||||
|     def get_one(self, value: str | int, key: str = None, any_case=False, override_schema=None) -> T: | ||||
|     def get_one(self, value: str | int | UUID4, key: str = None, any_case=False, override_schema=None) -> T: | ||||
|         key = key or self.primary_key | ||||
|  | ||||
|         q = self.session.query(self.sql_model) | ||||
|   | ||||
							
								
								
									
										59
									
								
								mealie/repos/repository_shopping_list.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								mealie/repos/repository_shopping_list.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | ||||
| from pydantic import UUID4 | ||||
|  | ||||
| from mealie.db.models.group.shopping_list import ShoppingList, ShoppingListItem | ||||
| 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) | ||||
							
								
								
									
										29
									
								
								mealie/repos/seed/_abstract_seeder.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								mealie/repos/seed/_abstract_seeder.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| from abc import ABC, abstractmethod | ||||
| from logging import Logger | ||||
| from pathlib import Path | ||||
|  | ||||
| from pydantic import UUID4 | ||||
|  | ||||
| from mealie.core.root_logger import get_logger | ||||
| from mealie.repos.repository_factory import AllRepositories | ||||
|  | ||||
|  | ||||
| class AbstractSeeder(ABC): | ||||
|     """ | ||||
|     Abstract class for seeding data. | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, db: AllRepositories, logger: Logger = None, group_id: UUID4 = None): | ||||
|         """ | ||||
|         Initialize the abstract seeder. | ||||
|         :param db_conn: Database connection. | ||||
|         :param logger: Logger. | ||||
|         """ | ||||
|         self.repos = db | ||||
|         self.group_id = group_id | ||||
|         self.logger = logger or get_logger("Data Seeder") | ||||
|         self.resources = Path(__file__).parent / "resources" | ||||
|  | ||||
|     @abstractmethod | ||||
|     def seed(self): | ||||
|         ... | ||||
| @@ -1,40 +0,0 @@ | ||||
| import json | ||||
| from pathlib import Path | ||||
|  | ||||
| from mealie.core.root_logger import get_logger | ||||
| from mealie.repos.repository_factory import AllRepositories | ||||
| from mealie.schema.recipe import CreateIngredientFood, CreateIngredientUnit | ||||
|  | ||||
| CWD = Path(__file__).parent | ||||
| logger = get_logger(__name__) | ||||
|  | ||||
|  | ||||
| def get_default_foods(): | ||||
|     with open(CWD.joinpath("resources", "foods", "en-us.json"), "r") as f: | ||||
|         foods = json.loads(f.read()) | ||||
|     return foods | ||||
|  | ||||
|  | ||||
| def get_default_units() -> dict[str, str]: | ||||
|     with open(CWD.joinpath("resources", "units", "en-us.json"), "r") as f: | ||||
|         units = json.loads(f.read()) | ||||
|     return units | ||||
|  | ||||
|  | ||||
| def default_recipe_unit_init(db: AllRepositories) -> None: | ||||
|     for unit in get_default_units().values(): | ||||
|         try: | ||||
|             db.ingredient_units.create( | ||||
|                 CreateIngredientUnit( | ||||
|                     name=unit["name"], description=unit["description"], abbreviation=unit["abbreviation"] | ||||
|                 ) | ||||
|             ) | ||||
|         except Exception as e: | ||||
|             logger.error(e) | ||||
|  | ||||
|     for food in get_default_foods(): | ||||
|         try: | ||||
|  | ||||
|             db.ingredient_foods.create(CreateIngredientFood(name=food, description="")) | ||||
|         except Exception as e: | ||||
|             logger.error(e) | ||||
							
								
								
									
										65
									
								
								mealie/repos/seed/resources/labels/en-us.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								mealie/repos/seed/resources/labels/en-us.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | ||||
| [ | ||||
|     { | ||||
|         "name": "Produce" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Grains" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Fruits" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Vegetables" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Meat" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Seafood" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Beverages" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Baked Goods" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Canned Goods" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Condiments" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Confectionary" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Dairy Products" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Frozen Foods" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Health Foods" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Household" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Meat Products" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Snacks" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Spices" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Sweets" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Alcohol" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Other" | ||||
|     } | ||||
| ] | ||||
							
								
								
									
										61
									
								
								mealie/repos/seed/seeders.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								mealie/repos/seed/seeders.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | ||||
| from typing import Generator | ||||
|  | ||||
| from black import json | ||||
|  | ||||
| from mealie.schema.labels import MultiPurposeLabelSave | ||||
| from mealie.schema.recipe.recipe_ingredient import CreateIngredientFood, CreateIngredientUnit | ||||
|  | ||||
| from ._abstract_seeder import AbstractSeeder | ||||
|  | ||||
|  | ||||
| class MultiPurposeLabelSeeder(AbstractSeeder): | ||||
|     def load_data(self) -> Generator[MultiPurposeLabelSave, None, None]: | ||||
|         file = self.resources / "labels" / "en-us.json" | ||||
|  | ||||
|         for label in json.loads(file.read_text()): | ||||
|             yield MultiPurposeLabelSave( | ||||
|                 name=label["name"], | ||||
|                 group_id=self.group_id, | ||||
|             ) | ||||
|  | ||||
|     def seed(self) -> None: | ||||
|         self.logger.info("Seeding MultiPurposeLabel") | ||||
|         for label in self.load_data(): | ||||
|             try: | ||||
|                 self.repos.group_multi_purpose_labels.create(label) | ||||
|             except Exception as e: | ||||
|                 self.logger.error(e) | ||||
|  | ||||
|  | ||||
| class IngredientUnitsSeeder(AbstractSeeder): | ||||
|     def load_data(self) -> Generator[CreateIngredientUnit, None, None]: | ||||
|         file = self.resources / "units" / "en-us.json" | ||||
|         for unit in json.loads(file.read_text()).values(): | ||||
|             yield CreateIngredientUnit( | ||||
|                 name=unit["name"], | ||||
|                 description=unit["description"], | ||||
|                 abbreviation=unit["abbreviation"], | ||||
|             ) | ||||
|  | ||||
|     def seed(self) -> None: | ||||
|         self.logger.info("Seeding Ingredient Units") | ||||
|         for unit in self.load_data(): | ||||
|             try: | ||||
|                 self.repos.ingredient_units.create(unit) | ||||
|             except Exception as e: | ||||
|                 self.logger.error(e) | ||||
|  | ||||
|  | ||||
| class IngredientFoodsSeeder(AbstractSeeder): | ||||
|     def load_data(self) -> Generator[CreateIngredientFood, None, None]: | ||||
|         file = self.resources / "foods" / "en-us.json" | ||||
|         for food in json.loads(file.read_text()): | ||||
|             yield CreateIngredientFood(name=food, description="") | ||||
|  | ||||
|     def seed(self) -> None: | ||||
|         self.logger.info("Seeding Ingredient Foods") | ||||
|         for food in self.load_data(): | ||||
|             try: | ||||
|                 self.repos.ingredient_foods.create(food) | ||||
|             except Exception as e: | ||||
|                 self.logger.error(e) | ||||
| @@ -1,21 +1,6 @@ | ||||
| from fastapi import APIRouter | ||||
|  | ||||
| from . import ( | ||||
|     admin, | ||||
|     app, | ||||
|     auth, | ||||
|     categories, | ||||
|     comments, | ||||
|     groups, | ||||
|     parser, | ||||
|     recipe, | ||||
|     shared, | ||||
|     shopping_lists, | ||||
|     tags, | ||||
|     tools, | ||||
|     unit_and_foods, | ||||
|     users, | ||||
| ) | ||||
| from . import admin, app, auth, categories, comments, groups, parser, recipe, shared, tags, tools, unit_and_foods, users | ||||
|  | ||||
| router = APIRouter(prefix="/api") | ||||
|  | ||||
| @@ -31,5 +16,4 @@ router.include_router(unit_and_foods.router) | ||||
| router.include_router(tools.router) | ||||
| router.include_router(categories.router) | ||||
| router.include_router(tags.router) | ||||
| router.include_router(shopping_lists.router) | ||||
| router.include_router(admin.router) | ||||
|   | ||||
							
								
								
									
										182
									
								
								mealie/routes/_base/controller.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										182
									
								
								mealie/routes/_base/controller.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,182 @@ | ||||
| """ | ||||
| This file contains code taken from fastapi-utils project. The code is licensed under the MIT license. | ||||
|  | ||||
| See their repository for details -> https://github.com/dmontagu/fastapi-utils | ||||
| """ | ||||
| import inspect | ||||
| from typing import Any, Callable, List, Tuple, Type, TypeVar, Union, cast, get_type_hints | ||||
|  | ||||
| from fastapi import APIRouter, Depends | ||||
| from fastapi.routing import APIRoute | ||||
| from pydantic.typing import is_classvar | ||||
| from starlette.routing import Route, WebSocketRoute | ||||
|  | ||||
| T = TypeVar("T") | ||||
|  | ||||
| CBV_CLASS_KEY = "__cbv_class__" | ||||
| INCLUDE_INIT_PARAMS_KEY = "__include_init_params__" | ||||
| RETURN_TYPES_FUNC_KEY = "__return_types_func__" | ||||
|  | ||||
|  | ||||
| def controller(router: APIRouter, *urls: str) -> Callable[[Type[T]], Type[T]]: | ||||
|     """ | ||||
|     This function returns a decorator that converts the decorated into a class-based view for the provided router. | ||||
|     Any methods of the decorated class that are decorated as endpoints using the router provided to this function | ||||
|     will become endpoints in the router. The first positional argument to the methods (typically `self`) | ||||
|     will be populated with an instance created using FastAPI's dependency-injection. | ||||
|     For more detail, review the documentation at | ||||
|     https://fastapi-utils.davidmontague.xyz/user-guide/class-based-views/#the-cbv-decorator | ||||
|     """ | ||||
|  | ||||
|     def decorator(cls: Type[T]) -> Type[T]: | ||||
|         # Define cls as cbv class exclusively when using the decorator | ||||
|         return _cbv(router, cls, *urls) | ||||
|  | ||||
|     return decorator | ||||
|  | ||||
|  | ||||
| def _cbv(router: APIRouter, cls: Type[T], *urls: str, instance: Any = None) -> Type[T]: | ||||
|     """ | ||||
|     Replaces any methods of the provided class `cls` that are endpoints of routes in `router` with updated | ||||
|     function calls that will properly inject an instance of `cls`. | ||||
|     """ | ||||
|     _init_cbv(cls, instance) | ||||
|     _register_endpoints(router, cls, *urls) | ||||
|     return cls | ||||
|  | ||||
|  | ||||
| def _init_cbv(cls: Type[Any], instance: Any = None) -> None: | ||||
|     """ | ||||
|     Idempotently modifies the provided `cls`, performing the following modifications: | ||||
|     * The `__init__` function is updated to set any class-annotated dependencies as instance attributes | ||||
|     * The `__signature__` attribute is updated to indicate to FastAPI what arguments should be passed to the initializer | ||||
|     """ | ||||
|     if getattr(cls, CBV_CLASS_KEY, False):  # pragma: no cover | ||||
|         return  # Already initialized | ||||
|     old_init: Callable[..., Any] = cls.__init__ | ||||
|     old_signature = inspect.signature(old_init) | ||||
|     old_parameters = list(old_signature.parameters.values())[1:]  # drop `self` parameter | ||||
|     new_parameters = [ | ||||
|         x for x in old_parameters if x.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD) | ||||
|     ] | ||||
|  | ||||
|     dependency_names: List[str] = [] | ||||
|     for name, hint in get_type_hints(cls).items(): | ||||
|         if is_classvar(hint): | ||||
|             continue | ||||
|         parameter_kwargs = {"default": getattr(cls, name, Ellipsis)} | ||||
|         dependency_names.append(name) | ||||
|         new_parameters.append( | ||||
|             inspect.Parameter(name=name, kind=inspect.Parameter.KEYWORD_ONLY, annotation=hint, **parameter_kwargs) | ||||
|         ) | ||||
|     new_signature = inspect.Signature(()) | ||||
|     if not instance or hasattr(cls, INCLUDE_INIT_PARAMS_KEY): | ||||
|         new_signature = old_signature.replace(parameters=new_parameters) | ||||
|  | ||||
|     def new_init(self: Any, *args: Any, **kwargs: Any) -> None: | ||||
|         for dep_name in dependency_names: | ||||
|             dep_value = kwargs.pop(dep_name) | ||||
|             setattr(self, dep_name, dep_value) | ||||
|         if instance and not hasattr(cls, INCLUDE_INIT_PARAMS_KEY): | ||||
|             self.__class__ = instance.__class__ | ||||
|             self.__dict__ = instance.__dict__ | ||||
|         else: | ||||
|             old_init(self, *args, **kwargs) | ||||
|  | ||||
|     setattr(cls, "__signature__", new_signature) | ||||
|     setattr(cls, "__init__", new_init) | ||||
|     setattr(cls, CBV_CLASS_KEY, True) | ||||
|  | ||||
|  | ||||
| def _register_endpoints(router: APIRouter, cls: Type[Any], *urls: str) -> None: | ||||
|     cbv_router = APIRouter() | ||||
|     function_members = inspect.getmembers(cls, inspect.isfunction) | ||||
|     for url in urls: | ||||
|         _allocate_routes_by_method_name(router, url, function_members) | ||||
|     router_roles = [] | ||||
|     for route in router.routes: | ||||
|         assert isinstance(route, APIRoute) | ||||
|         route_methods: Any = route.methods | ||||
|         cast(Tuple[Any], route_methods) | ||||
|         router_roles.append((route.path, tuple(route_methods))) | ||||
|  | ||||
|     if len(set(router_roles)) != len(router_roles): | ||||
|         raise Exception("An identical route role has been implemented more then once") | ||||
|  | ||||
|     numbered_routes_by_endpoint = { | ||||
|         route.endpoint: (i, route) | ||||
|         for i, route in enumerate(router.routes) | ||||
|         if isinstance(route, (Route, WebSocketRoute)) | ||||
|     } | ||||
|  | ||||
|     prefix_length = len(router.prefix) | ||||
|     routes_to_append: List[Tuple[int, Union[Route, WebSocketRoute]]] = [] | ||||
|     for _, func in function_members: | ||||
|         index_route = numbered_routes_by_endpoint.get(func) | ||||
|  | ||||
|         if index_route is None: | ||||
|             continue | ||||
|  | ||||
|         _, route = index_route | ||||
|         route.path = route.path[prefix_length:] | ||||
|         routes_to_append.append(index_route) | ||||
|         router.routes.remove(route) | ||||
|  | ||||
|         _update_cbv_route_endpoint_signature(cls, route) | ||||
|     routes_to_append.sort(key=lambda x: x[0]) | ||||
|  | ||||
|     cbv_router.routes = [route for _, route in routes_to_append] | ||||
|  | ||||
|     # In order to use a "" as a router and utilize the prefix in the original router | ||||
|     # we need to create an intermediate prefix variable to hold the prefix and pass it | ||||
|     # into the original router when using "include_router" after we reeset the original | ||||
|     # prefix. This limits the original routers usability to only the controller. | ||||
|     # | ||||
|     # This is sort of a hack and causes unexpected behavior. I'm unsure of a better solution. | ||||
|     cbv_prefix = router.prefix | ||||
|     router.prefix = "" | ||||
|     router.include_router(cbv_router, prefix=cbv_prefix) | ||||
|  | ||||
|  | ||||
| def _allocate_routes_by_method_name(router: APIRouter, url: str, function_members: List[Tuple[str, Any]]) -> None: | ||||
|     # sourcery skip: merge-nested-ifs | ||||
|     existing_routes_endpoints: List[Tuple[Any, str]] = [ | ||||
|         (route.endpoint, route.path) for route in router.routes if isinstance(route, APIRoute) | ||||
|     ] | ||||
|     for name, func in function_members: | ||||
|         if hasattr(router, name) and not name.startswith("__") and not name.endswith("__"): | ||||
|             if (func, url) not in existing_routes_endpoints: | ||||
|                 response_model = None | ||||
|                 responses = None | ||||
|                 kwargs = {} | ||||
|                 status_code = 200 | ||||
|                 return_types_func = getattr(func, RETURN_TYPES_FUNC_KEY, None) | ||||
|                 if return_types_func: | ||||
|                     response_model, status_code, responses, kwargs = return_types_func() | ||||
|  | ||||
|                 api_resource = router.api_route( | ||||
|                     url, | ||||
|                     methods=[name.capitalize()], | ||||
|                     response_model=response_model, | ||||
|                     status_code=status_code, | ||||
|                     responses=responses, | ||||
|                     **kwargs, | ||||
|                 ) | ||||
|                 api_resource(func) | ||||
|  | ||||
|  | ||||
| def _update_cbv_route_endpoint_signature(cls: Type[Any], route: Union[Route, WebSocketRoute]) -> None: | ||||
|     """ | ||||
|     Fixes the endpoint signature for a cbv route to ensure FastAPI performs dependency injection properly. | ||||
|     """ | ||||
|     old_endpoint = route.endpoint | ||||
|     old_signature = inspect.signature(old_endpoint) | ||||
|     old_parameters: List[inspect.Parameter] = list(old_signature.parameters.values()) | ||||
|     old_first_parameter = old_parameters[0] | ||||
|     new_first_parameter = old_first_parameter.replace(default=Depends(cls)) | ||||
|     new_parameters = [new_first_parameter] + [ | ||||
|         parameter.replace(kind=inspect.Parameter.KEYWORD_ONLY) for parameter in old_parameters[1:] | ||||
|     ] | ||||
|  | ||||
|     new_signature = old_signature.replace(parameters=new_parameters) | ||||
|     setattr(route.endpoint, "__signature__", new_signature) | ||||
							
								
								
									
										58
									
								
								mealie/routes/_base/dependencies.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								mealie/routes/_base/dependencies.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from functools import cached_property | ||||
| from logging import Logger | ||||
|  | ||||
| from fastapi import Depends | ||||
| from sqlalchemy.orm import Session | ||||
|  | ||||
| from mealie.core.config import get_app_dirs, get_app_settings | ||||
| from mealie.core.dependencies.dependencies import get_admin_user, get_current_user | ||||
| from mealie.core.root_logger import get_logger | ||||
| from mealie.core.settings.directories import AppDirectories | ||||
| from mealie.core.settings.settings import AppSettings | ||||
| from mealie.db.db_setup import generate_session | ||||
| from mealie.lang import AbstractLocaleProvider, get_locale_provider | ||||
| from mealie.repos import AllRepositories | ||||
| from mealie.schema.user.user import PrivateUser | ||||
|  | ||||
|  | ||||
| def _get_logger() -> Logger: | ||||
|     return get_logger() | ||||
|  | ||||
|  | ||||
| class SharedDependencies: | ||||
|     session: Session | ||||
|     t: AbstractLocaleProvider | ||||
|     logger: Logger | ||||
|     acting_user: PrivateUser | None | ||||
|  | ||||
|     def __init__(self, session: Session, acting_user: PrivateUser | None) -> None: | ||||
|         self.t = get_locale_provider() | ||||
|         self.logger = _get_logger() | ||||
|         self.session = session | ||||
|         self.acting_user = acting_user | ||||
|  | ||||
|     @classmethod | ||||
|     def user( | ||||
|         cls, session: Session = Depends(generate_session), user: PrivateUser = Depends(get_current_user) | ||||
|     ) -> "SharedDependencies": | ||||
|         return cls(session, user) | ||||
|  | ||||
|     @classmethod | ||||
|     def admin( | ||||
|         cls, session: Session = Depends(generate_session), admin: PrivateUser = Depends(get_admin_user) | ||||
|     ) -> "SharedDependencies": | ||||
|         return cls(session, admin) | ||||
|  | ||||
|     @cached_property | ||||
|     def settings(self) -> AppSettings: | ||||
|         return get_app_settings() | ||||
|  | ||||
|     @cached_property | ||||
|     def folders(self) -> AppDirectories: | ||||
|         return get_app_dirs() | ||||
|  | ||||
|     @cached_property | ||||
|     def repos(self) -> AllRepositories: | ||||
|         return AllRepositories(self.session) | ||||
							
								
								
									
										109
									
								
								mealie/routes/_base/mixins.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								mealie/routes/_base/mixins.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,109 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from logging import Logger | ||||
| from typing import Callable, Type | ||||
|  | ||||
| from fastapi import HTTPException, status | ||||
|  | ||||
| from mealie.repos.repository_generic import RepositoryGeneric | ||||
| from mealie.schema.response import ErrorResponse | ||||
|  | ||||
|  | ||||
| class CrudMixins: | ||||
|     repo: RepositoryGeneric | ||||
|     exception_msgs: Callable[[Type[Exception]], str] | None | ||||
|     default_message: str = "An unexpected error occurred." | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         repo: RepositoryGeneric, | ||||
|         logger: Logger, | ||||
|         exception_msgs: Callable[[Type[Exception]], str] = None, | ||||
|         default_message: str = None, | ||||
|     ) -> None: | ||||
|         """ | ||||
|         The CrudMixins class is a mixin class that provides a common set of methods for CRUD operations. | ||||
|         This class is inteded to be used in a composition pattern where a class has a mixin property. For example: | ||||
|  | ||||
|         ``` | ||||
|         class MyClass: | ||||
|             def __init(self repo, logger): | ||||
|                 self.mixins = CrudMixins(repo, logger) | ||||
|         ``` | ||||
|  | ||||
|         """ | ||||
|         self.repo = repo | ||||
|         self.logger = logger | ||||
|         self.exception_msgs = exception_msgs | ||||
|  | ||||
|         if default_message: | ||||
|             self.default_message = default_message | ||||
|  | ||||
|     def set_default_message(self, default_msg: str) -> "CrudMixins": | ||||
|         """ | ||||
|         Use this method to set a lookup function for exception messages. When an exception is raised, and | ||||
|         no custom message is set, the default message will be used. | ||||
|  | ||||
|         IMPORTANT! The function returns the same instance of the CrudMixins class, so you can chain calls. | ||||
|         """ | ||||
|         self.default_msg = default_msg | ||||
|         return self | ||||
|  | ||||
|     def get_exception_message(self, ext: Exception) -> str: | ||||
|         if self.exception_msgs: | ||||
|             return self.exception_msgs(type(ext)) | ||||
|         return self.default_message | ||||
|  | ||||
|     def handle_exception(self, ex: Exception) -> None: | ||||
|         # Cleanup | ||||
|         self.logger.exception(ex) | ||||
|         self.repo.session.rollback() | ||||
|  | ||||
|         # Respond | ||||
|         msg = self.get_exception_message(ex) | ||||
|  | ||||
|         raise HTTPException( | ||||
|             status.HTTP_400_BAD_REQUEST, | ||||
|             detail=ErrorResponse.respond(message=msg, exception=str(ex)), | ||||
|         ) | ||||
|  | ||||
|     def create_one(self, data): | ||||
|         item = None | ||||
|         try: | ||||
|             item = self.repo.create(data) | ||||
|         except Exception as ex: | ||||
|             self.handle_exception(ex) | ||||
|  | ||||
|         return item | ||||
|  | ||||
|     def update_one(self, data, item_id): | ||||
|         item = self.repo.get(item_id) | ||||
|  | ||||
|         if not item: | ||||
|             return | ||||
|  | ||||
|         try: | ||||
|             item = self.repo.update(item.id, data)  # type: ignore | ||||
|         except Exception as ex: | ||||
|             self.handle_exception(ex) | ||||
|  | ||||
|         return item | ||||
|  | ||||
|     def patch_one(self, data, item_id) -> None: | ||||
|         self.repo.get(item_id) | ||||
|  | ||||
|         try: | ||||
|             self.repo.patch(item_id, data.dict(exclude_unset=True, exclude_defaults=True)) | ||||
|         except Exception as ex: | ||||
|             self.handle_exception(ex) | ||||
|  | ||||
|     def delete_one(self, item_id): | ||||
|         item = self.repo.get(item_id) | ||||
|         self.logger.info(f"Deleting item with id {item}") | ||||
|  | ||||
|         try: | ||||
|             item = self.repo.delete(item) | ||||
|         except Exception as ex: | ||||
|             self.handle_exception(ex) | ||||
|  | ||||
|         return item | ||||
| @@ -8,7 +8,7 @@ from mealie.services.group_services import CookbookService, WebhookService | ||||
| from mealie.services.group_services.meal_service import MealService | ||||
| from mealie.services.group_services.reports_service import GroupReportService | ||||
|  | ||||
| from . import categories, invitations, migrations, preferences, self_service | ||||
| from . import categories, invitations, labels, migrations, preferences, self_service, shopping_lists | ||||
|  | ||||
| router = APIRouter() | ||||
|  | ||||
| @@ -20,18 +20,18 @@ cookbook_router = RouterFactory(service=CookbookService, prefix="/groups/cookboo | ||||
|  | ||||
|  | ||||
| @router.get("/groups/mealplans/today", tags=["Groups: Mealplans"]) | ||||
| def get_todays_meals(m_service: MealService = Depends(MealService.private)): | ||||
|     return m_service.get_today() | ||||
| def get_todays_meals(ms: MealService = Depends(MealService.private)): | ||||
|     return ms.get_today() | ||||
|  | ||||
|  | ||||
| meal_plan_router = RouterFactory(service=MealService, prefix="/groups/mealplans", tags=["Groups: Mealplans"]) | ||||
|  | ||||
|  | ||||
| @meal_plan_router.get("") | ||||
| def get_all(start: date = None, limit: date = None, m_service: MealService = Depends(MealService.private)): | ||||
| def get_all(start: date = None, limit: date = None, ms: MealService = Depends(MealService.private)): | ||||
|     start = start or date.today() - timedelta(days=999) | ||||
|     limit = limit or date.today() + timedelta(days=999) | ||||
|     return m_service.get_slice(start, limit) | ||||
|     return ms.get_slice(start, limit) | ||||
|  | ||||
|  | ||||
| router.include_router(cookbook_router) | ||||
| @@ -47,9 +47,12 @@ report_router = RouterFactory(service=GroupReportService, prefix="/groups/report | ||||
|  | ||||
| @report_router.get("") | ||||
| def get_all_reports( | ||||
|     report_type: ReportCategory = None, gm_service: GroupReportService = Depends(GroupReportService.private) | ||||
|     report_type: ReportCategory = None, | ||||
|     gs: GroupReportService = Depends(GroupReportService.private), | ||||
| ): | ||||
|     return gm_service._get_all(report_type) | ||||
|     return gs._get_all(report_type) | ||||
|  | ||||
|  | ||||
| router.include_router(report_router) | ||||
| router.include_router(shopping_lists.router) | ||||
| router.include_router(labels.router) | ||||
|   | ||||
							
								
								
									
										71
									
								
								mealie/routes/groups/labels.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								mealie/routes/groups/labels.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| from functools import cached_property | ||||
| from sqlite3 import IntegrityError | ||||
| from typing import Type | ||||
|  | ||||
| from fastapi import APIRouter, Depends | ||||
| from pydantic import UUID4 | ||||
|  | ||||
| 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, | ||||
|     MultiPurposeLabelOut, | ||||
|     MultiPurposeLabelSave, | ||||
|     MultiPurposeLabelSummary, | ||||
|     MultiPurposeLabelUpdate, | ||||
| ) | ||||
| from mealie.schema.mapper import cast | ||||
| from mealie.schema.query import GetAll | ||||
| from mealie.services.group_services.shopping_lists import ShoppingListService | ||||
|  | ||||
| router = APIRouter(prefix="/groups/labels", tags=["Group: Multi Purpose Labels"]) | ||||
|  | ||||
|  | ||||
| @controller(router) | ||||
| class ShoppingListRoutes: | ||||
|     deps: SharedDependencies = Depends(SharedDependencies.user) | ||||
|     service: ShoppingListService = Depends(ShoppingListService.private) | ||||
|  | ||||
|     @cached_property | ||||
|     def repo(self): | ||||
|         if not self.deps.acting_user: | ||||
|             raise Exception("No user is logged in.") | ||||
|  | ||||
|         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 = { | ||||
|             Exception: "An unexpected error occurred.", | ||||
|             IntegrityError: "An unexpected error occurred.", | ||||
|         } | ||||
|  | ||||
|         return registered.get(ex, "An unexpected error occurred.") | ||||
|  | ||||
|     # ======================================================================= | ||||
|     # CRUD Operations | ||||
|  | ||||
|     @property | ||||
|     def mixins(self) -> CrudMixins: | ||||
|         return CrudMixins(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.") | ||||
|  | ||||
|     @router.get("", response_model=list[MultiPurposeLabelSummary]) | ||||
|     def get_all(self, q: GetAll = Depends(GetAll)): | ||||
|         return self.repo.get_all(start=q.start, limit=q.limit, override_schema=MultiPurposeLabelSummary) | ||||
|  | ||||
|     @router.post("", response_model=MultiPurposeLabelOut) | ||||
|     def create_one(self, data: MultiPurposeLabelCreate): | ||||
|         save_data = cast(data, MultiPurposeLabelSave, group_id=self.deps.acting_user.group_id) | ||||
|         return self.mixins.create_one(save_data) | ||||
|  | ||||
|     @router.get("/{item_id}", response_model=MultiPurposeLabelOut) | ||||
|     def get_one(self, item_id: UUID4): | ||||
|         return self.repo.get_one(item_id) | ||||
|  | ||||
|     @router.put("/{item_id}", response_model=MultiPurposeLabelOut) | ||||
|     def update_one(self, item_id: UUID4, data: MultiPurposeLabelUpdate): | ||||
|         return self.mixins.update_one(data, item_id) | ||||
|  | ||||
|     @router.delete("/{item_id}", response_model=MultiPurposeLabelOut) | ||||
|     def delete_one(self, item_id: UUID4): | ||||
|         return self.mixins.delete_one(item_id)  # type: ignore | ||||
							
								
								
									
										82
									
								
								mealie/routes/groups/shopping_lists.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								mealie/routes/groups/shopping_lists.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| from functools import cached_property | ||||
| from sqlite3 import IntegrityError | ||||
| from typing import Type | ||||
|  | ||||
| from fastapi import APIRouter, Depends | ||||
| from pydantic import UUID4 | ||||
|  | ||||
| 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_shopping_list import ( | ||||
|     ShoppingListCreate, | ||||
|     ShoppingListOut, | ||||
|     ShoppingListSave, | ||||
|     ShoppingListSummary, | ||||
|     ShoppingListUpdate, | ||||
| ) | ||||
| from mealie.schema.mapper import cast | ||||
| from mealie.schema.query import GetAll | ||||
| from mealie.services.group_services.shopping_lists import ShoppingListService | ||||
|  | ||||
| router = APIRouter(prefix="/groups/shopping/lists", tags=["Group: Shopping Lists"]) | ||||
|  | ||||
|  | ||||
| @controller(router) | ||||
| class ShoppingListRoutes: | ||||
|     deps: SharedDependencies = Depends(SharedDependencies.user) | ||||
|     service: ShoppingListService = Depends(ShoppingListService.private) | ||||
|  | ||||
|     @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 = { | ||||
|             Exception: "An unexpected error occurred.", | ||||
|             IntegrityError: "An unexpected error occurred.", | ||||
|         } | ||||
|  | ||||
|         return registered.get(ex, "An unexpected error occurred.") | ||||
|  | ||||
|     # ======================================================================= | ||||
|     # CRUD Operations | ||||
|  | ||||
|     @property | ||||
|     def mixins(self) -> CrudMixins: | ||||
|         return CrudMixins(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.") | ||||
|  | ||||
|     @router.get("", response_model=list[ShoppingListSummary]) | ||||
|     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) | ||||
|     def create_one(self, data: ShoppingListCreate): | ||||
|         save_data = cast(data, ShoppingListSave, group_id=self.deps.acting_user.group_id) | ||||
|         return self.mixins.create_one(save_data) | ||||
|  | ||||
|     @router.get("/{item_id}", response_model=ShoppingListOut) | ||||
|     def get_one(self, item_id: UUID4): | ||||
|         return self.repo.get_one(item_id) | ||||
|  | ||||
|     @router.put("/{item_id}", response_model=ShoppingListOut) | ||||
|     def update_one(self, item_id: UUID4, data: ShoppingListUpdate): | ||||
|         return self.mixins.update_one(data, item_id) | ||||
|  | ||||
|     @router.delete("/{item_id}", response_model=ShoppingListOut) | ||||
|     def delete_one(self, item_id: UUID4): | ||||
|         return self.mixins.delete_one(item_id)  # type: ignore | ||||
|  | ||||
|     # ======================================================================= | ||||
|     # Other Operations | ||||
|  | ||||
|     @router.post("/{item_id}/recipe/{recipe_id}", response_model=ShoppingListOut) | ||||
|     def add_recipe_ingredients_to_list(self, item_id: UUID4, recipe_id: int): | ||||
|         return self.service.add_recipe_ingredients_to_list(item_id, recipe_id) | ||||
|  | ||||
|     @router.delete("/{item_id}/recipe/{recipe_id}", response_model=ShoppingListOut) | ||||
|     def remove_recipe_ingredients_from_list(self, item_id: UUID4, recipe_id: int): | ||||
|         return self.service.remove_recipe_ingredients_from_list(item_id, recipe_id) | ||||
| @@ -1,45 +0,0 @@ | ||||
| from fastapi import Depends | ||||
| from sqlalchemy.orm.session import Session | ||||
|  | ||||
| from mealie.core.dependencies import get_current_user | ||||
| from mealie.db.db_setup import generate_session | ||||
| from mealie.repos.all_repositories import get_repositories | ||||
| from mealie.routes.routers import UserAPIRouter | ||||
| from mealie.schema.meal_plan import ShoppingListIn, ShoppingListOut | ||||
| from mealie.schema.user import PrivateUser | ||||
|  | ||||
| router = UserAPIRouter(prefix="/shopping-lists", tags=["Shopping Lists: CRUD"]) | ||||
|  | ||||
|  | ||||
| @router.post("", response_model=ShoppingListOut) | ||||
| async def create_shopping_list( | ||||
|     list_in: ShoppingListIn, | ||||
|     current_user: PrivateUser = Depends(get_current_user), | ||||
|     session: Session = Depends(generate_session), | ||||
| ): | ||||
|     """Create Shopping List in the Database""" | ||||
|     db = get_repositories(session) | ||||
|     list_in.group = current_user.group | ||||
|  | ||||
|     return db.shopping_lists.create(list_in) | ||||
|  | ||||
|  | ||||
| @router.get("/{id}", response_model=ShoppingListOut) | ||||
| async def get_shopping_list(id: int, session: Session = Depends(generate_session)): | ||||
|     """Get Shopping List from the Database""" | ||||
|     db = get_repositories(session) | ||||
|     return db.shopping_lists.get(id) | ||||
|  | ||||
|  | ||||
| @router.put("/{id}", response_model=ShoppingListOut) | ||||
| async def update_shopping_list(id: int, new_data: ShoppingListIn, session: Session = Depends(generate_session)): | ||||
|     """Update Shopping List in the Database""" | ||||
|     db = get_repositories(session) | ||||
|     return db.shopping_lists.update(id, new_data) | ||||
|  | ||||
|  | ||||
| @router.delete("/{id}") | ||||
| async def delete_shopping_list(id: int, session: Session = Depends(generate_session)): | ||||
|     """Delete Shopping List from the Database""" | ||||
|     db = get_repositories(session) | ||||
|     return db.shopping_lists.delete(id) | ||||
| @@ -1 +1,2 @@ | ||||
| from .group_shopping_list import * | ||||
| from .webhook import * | ||||
|   | ||||
							
								
								
									
										65
									
								
								mealie/schema/group/group_shopping_list.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								mealie/schema/group/group_shopping_list.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | ||||
| from typing import Optional | ||||
|  | ||||
| from fastapi_camelcase import CamelModel | ||||
| from pydantic import UUID4 | ||||
|  | ||||
| from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit | ||||
|  | ||||
|  | ||||
| class ShoppingListItemCreate(CamelModel): | ||||
|     shopping_list_id: UUID4 | ||||
|     checked: bool = False | ||||
|     position: int = 0 | ||||
|  | ||||
|     is_food: bool = False | ||||
|  | ||||
|     note: Optional[str] = "" | ||||
|     quantity: float = 1 | ||||
|     unit_id: int = None | ||||
|     unit: IngredientUnit = None | ||||
|     food_id: int = None | ||||
|     food: IngredientFood = None | ||||
|     recipe_id: Optional[int] = None | ||||
|  | ||||
|     label_id: Optional[UUID4] = None | ||||
|  | ||||
|  | ||||
| class ShoppingListItemOut(ShoppingListItemCreate): | ||||
|     id: UUID4 | ||||
|     label: "Optional[MultiPurposeLabelSummary]" = None | ||||
|  | ||||
|     class Config: | ||||
|         orm_mode = True | ||||
|  | ||||
|  | ||||
| class ShoppingListCreate(CamelModel): | ||||
|     """ | ||||
|     Create Shopping List | ||||
|     """ | ||||
|  | ||||
|     name: str = None | ||||
|  | ||||
|  | ||||
| class ShoppingListSave(ShoppingListCreate): | ||||
|     group_id: UUID4 | ||||
|  | ||||
|  | ||||
| class ShoppingListSummary(ShoppingListSave): | ||||
|     id: UUID4 | ||||
|  | ||||
|     class Config: | ||||
|         orm_mode = True | ||||
|  | ||||
|  | ||||
| class ShoppingListUpdate(ShoppingListSummary): | ||||
|     list_items: list[ShoppingListItemOut] = [] | ||||
|  | ||||
|  | ||||
| class ShoppingListOut(ShoppingListUpdate): | ||||
|     class Config: | ||||
|         orm_mode = True | ||||
|  | ||||
|  | ||||
| from mealie.schema.labels import MultiPurposeLabelSummary | ||||
|  | ||||
| ShoppingListItemOut.update_forward_refs() | ||||
							
								
								
									
										36
									
								
								mealie/schema/labels/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								mealie/schema/labels/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| from fastapi_camelcase import CamelModel | ||||
| from pydantic import UUID4 | ||||
|  | ||||
| from mealie.schema.recipe import IngredientFood | ||||
|  | ||||
|  | ||||
| class MultiPurposeLabelCreate(CamelModel): | ||||
|     name: str | ||||
|  | ||||
|  | ||||
| class MultiPurposeLabelSave(MultiPurposeLabelCreate): | ||||
|     group_id: UUID4 | ||||
|  | ||||
|  | ||||
| class MultiPurposeLabelUpdate(MultiPurposeLabelSave): | ||||
|     id: UUID4 | ||||
|  | ||||
|  | ||||
| class MultiPurposeLabelSummary(MultiPurposeLabelUpdate): | ||||
|     pass | ||||
|  | ||||
|     class Config: | ||||
|         orm_mode = True | ||||
|  | ||||
|  | ||||
| class MultiPurposeLabelOut(MultiPurposeLabelUpdate): | ||||
|     shopping_list_items: "list[ShoppingListItemOut]" = [] | ||||
|     foods: list[IngredientFood] = [] | ||||
|  | ||||
|     class Config: | ||||
|         orm_mode = True | ||||
|  | ||||
|  | ||||
| from mealie.schema.group.group_shopping_list import ShoppingListItemOut | ||||
|  | ||||
| MultiPurposeLabelOut.update_forward_refs() | ||||
| @@ -1,4 +1,4 @@ | ||||
| from typing import Generic, TypeVar | ||||
| from typing import TypeVar | ||||
|  | ||||
| from pydantic import BaseModel | ||||
|  | ||||
| @@ -6,7 +6,7 @@ T = TypeVar("T", bound=BaseModel) | ||||
| U = TypeVar("U", bound=BaseModel) | ||||
|  | ||||
|  | ||||
| def mapper(source: U, dest: T, **kwargs) -> Generic[T]: | ||||
| def mapper(source: U, dest: T, **_) -> T: | ||||
|     """ | ||||
|     Map a source model to a destination model. Only top-level fields are mapped. | ||||
|     """ | ||||
| @@ -16,3 +16,9 @@ def mapper(source: U, dest: T, **kwargs) -> Generic[T]: | ||||
|             setattr(dest, field, getattr(source, field)) | ||||
|  | ||||
|     return dest | ||||
|  | ||||
|  | ||||
| def cast(source: U, dest: T, **kwargs) -> T: | ||||
|     create_data = {field: getattr(source, field) for field in source.__fields__ if field in dest.__fields__} | ||||
|     create_data.update(kwargs or {}) | ||||
|     return dest(**create_data) | ||||
|   | ||||
							
								
								
									
										6
									
								
								mealie/schema/query.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								mealie/schema/query.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| from fastapi_camelcase import CamelModel | ||||
|  | ||||
|  | ||||
| class GetAll(CamelModel): | ||||
|     start: int = 0 | ||||
|     limit: int = 999 | ||||
| @@ -1,3 +1,5 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import datetime | ||||
| from pathlib import Path | ||||
| from typing import Any, Optional | ||||
| @@ -92,32 +94,32 @@ class RecipeSummary(CamelModel): | ||||
|         orm_mode = True | ||||
|  | ||||
|     @validator("tags", always=True, pre=True) | ||||
|     def validate_tags(cats: list[Any]): | ||||
|     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) | ||||
|     def validate_categories(cats: list[Any]): | ||||
|     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) | ||||
|     def validate_group_id(group_id: list[Any]): | ||||
|     def validate_group_id(group_id: Any): | ||||
|         if isinstance(group_id, int): | ||||
|             return uuid4() | ||||
|         return group_id | ||||
|  | ||||
|     @validator("user_id", always=True, pre=True) | ||||
|     def validate_user_id(user_id: list[Any]): | ||||
|     def validate_user_id(user_id: Any): | ||||
|         if isinstance(user_id, int): | ||||
|             return uuid4() | ||||
|         return user_id | ||||
|  | ||||
|  | ||||
| class Recipe(RecipeSummary): | ||||
|     recipe_ingredient: Optional[list[RecipeIngredient]] = [] | ||||
|     recipe_ingredient: list[RecipeIngredient] = [] | ||||
|     recipe_instructions: Optional[list[RecipeStep]] = [] | ||||
|     nutrition: Optional[Nutrition] | ||||
|  | ||||
| @@ -155,7 +157,7 @@ class Recipe(RecipeSummary): | ||||
|         orm_mode = True | ||||
|  | ||||
|         @classmethod | ||||
|         def getter_dict(_cls, name_orm: RecipeModel): | ||||
|         def getter_dict(cls, name_orm: RecipeModel): | ||||
|             return { | ||||
|                 **GetterDict(name_orm), | ||||
|                 # "recipe_ingredient": [x.note for x in name_orm.recipe_ingredient], | ||||
|   | ||||
| @@ -1,7 +1,17 @@ | ||||
| from typing import Optional | ||||
|  | ||||
| from pydantic import BaseModel | ||||
|  | ||||
|  | ||||
| class ErrorResponse(BaseModel): | ||||
|     message: str | ||||
|     error: bool = True | ||||
|     exception: str = None | ||||
|     exception: Optional[str] = None | ||||
|  | ||||
|     @classmethod | ||||
|     def respond(cls, message: str, exception: Optional[str] = None) -> dict: | ||||
|         """ | ||||
|         This method is an helper to create an obect and convert to a dictionary | ||||
|         in the same call, for use while providing details to a HTTPException | ||||
|         """ | ||||
|         return cls(message=message, exception=exception).dict() | ||||
|   | ||||
| @@ -13,7 +13,6 @@ from mealie.db.models.users import User | ||||
| from mealie.schema.group.group_preferences import ReadGroupPreferences | ||||
| from mealie.schema.recipe import RecipeSummary | ||||
|  | ||||
| from ..meal_plan import ShoppingListOut | ||||
| from ..recipe import CategoryBase | ||||
|  | ||||
| settings = get_app_settings() | ||||
| @@ -148,7 +147,6 @@ class UpdateGroup(GroupBase): | ||||
|  | ||||
| class GroupInDB(UpdateGroup): | ||||
|     users: Optional[list[UserOut]] | ||||
|     shopping_lists: Optional[list[ShoppingListOut]] | ||||
|     preferences: Optional[ReadGroupPreferences] = None | ||||
|  | ||||
|     class Config: | ||||
|   | ||||
							
								
								
									
										63
									
								
								mealie/services/group_services/shopping_lists.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								mealie/services/group_services/shopping_lists.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from functools import cached_property | ||||
|  | ||||
| from pydantic import UUID4 | ||||
|  | ||||
| from mealie.schema.group import ShoppingListCreate, ShoppingListOut, ShoppingListSummary | ||||
| from mealie.schema.group.group_shopping_list import ShoppingListItemCreate | ||||
| from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins | ||||
| from mealie.services._base_http_service.http_services import UserHttpService | ||||
| from mealie.services.events import create_group_event | ||||
|  | ||||
|  | ||||
| class ShoppingListService( | ||||
|     CrudHttpMixins[ShoppingListOut, ShoppingListCreate, ShoppingListCreate], | ||||
|     UserHttpService[int, ShoppingListOut], | ||||
| ): | ||||
|     event_func = create_group_event | ||||
|     _restrict_by_group = True | ||||
|     _schema = ShoppingListSummary | ||||
|  | ||||
|     @cached_property | ||||
|     def repo(self): | ||||
|         return self.db.group_shopping_lists | ||||
|  | ||||
|     def add_recipe_ingredients_to_list(self, list_id: UUID4, recipe_id: int) -> ShoppingListOut: | ||||
|         recipe = self.db.recipes.get_one(recipe_id, "id") | ||||
|         shopping_list = self.repo.get_one(list_id) | ||||
|  | ||||
|         to_create = [] | ||||
|  | ||||
|         for ingredient in recipe.recipe_ingredient: | ||||
|             food_id = None | ||||
|             try: | ||||
|                 food_id = ingredient.food.id | ||||
|             except AttributeError: | ||||
|                 pass | ||||
|  | ||||
|             unit_id = None | ||||
|             try: | ||||
|                 unit_id = ingredient.unit.id | ||||
|             except AttributeError: | ||||
|                 pass | ||||
|  | ||||
|             to_create.append( | ||||
|                 ShoppingListItemCreate( | ||||
|                     shopping_list_id=list_id, | ||||
|                     is_food=True, | ||||
|                     food_id=food_id, | ||||
|                     unit_id=unit_id, | ||||
|                     quantity=ingredient.quantity, | ||||
|                     note=ingredient.note, | ||||
|                     recipe_id=recipe_id, | ||||
|                 ) | ||||
|             ) | ||||
|  | ||||
|         shopping_list.list_items.extend(to_create) | ||||
|         return self.repo.update(shopping_list.id, shopping_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) | ||||
| @@ -1,6 +1,6 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from typing import Callable | ||||
| from typing import Callable, Iterable | ||||
|  | ||||
| from mealie.core import root_logger | ||||
|  | ||||
| @@ -16,28 +16,35 @@ class SchedulerRegistry: | ||||
|     _hourly: list[Callable] = [] | ||||
|     _minutely: list[Callable] = [] | ||||
|  | ||||
|     def _register(name: str, callbacks: list[Callable], callback: Callable): | ||||
|     @staticmethod | ||||
|     def _register(name: str, callbacks: list[Callable], callback: Iterable[Callable]): | ||||
|         for cb in callback: | ||||
|             logger.info(f"Registering {name} callback: {cb.__name__}") | ||||
|             callbacks.append(cb) | ||||
|  | ||||
|     @staticmethod | ||||
|     def register_daily(*callbacks: Callable): | ||||
|         SchedulerRegistry._register("daily", SchedulerRegistry._daily, callbacks) | ||||
|  | ||||
|     @staticmethod | ||||
|     def remove_daily(callback: Callable): | ||||
|         logger.info(f"Removing daily callback: {callback.__name__}") | ||||
|         SchedulerRegistry._daily.remove(callback) | ||||
|  | ||||
|     @staticmethod | ||||
|     def register_hourly(*callbacks: Callable): | ||||
|         SchedulerRegistry._register("daily", SchedulerRegistry._hourly, callbacks) | ||||
|  | ||||
|     @staticmethod | ||||
|     def remove_hourly(callback: Callable): | ||||
|         logger.info(f"Removing hourly callback: {callback.__name__}") | ||||
|         SchedulerRegistry._hourly.remove(callback) | ||||
|  | ||||
|     @staticmethod | ||||
|     def register_minutely(*callbacks: Callable): | ||||
|         SchedulerRegistry._register("minutely", SchedulerRegistry._minutely, callbacks) | ||||
|  | ||||
|     @staticmethod | ||||
|     def remove_minutely(callback: Callable): | ||||
|         logger.info(f"Removing minutely callback: {callback.__name__}") | ||||
|         SchedulerRegistry._minutely.remove(callback) | ||||
|   | ||||
| @@ -1,10 +1,11 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from pathlib import Path | ||||
|  | ||||
| from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore | ||||
| from apscheduler.schedulers.background import BackgroundScheduler | ||||
|  | ||||
| from mealie.core import root_logger | ||||
| from mealie.core.config import get_app_dirs | ||||
|  | ||||
| from .scheduled_func import ScheduledFunc | ||||
| from .scheduler_registry import SchedulerRegistry | ||||
| @@ -13,8 +14,6 @@ logger = root_logger.get_logger() | ||||
|  | ||||
| CWD = Path(__file__).parent | ||||
|  | ||||
| app_dirs = get_app_dirs() | ||||
| TEMP_DATA = app_dirs.DATA_DIR / ".temp" | ||||
| SCHEDULER_DB = CWD / ".scheduler.db" | ||||
| SCHEDULER_DATABASE = f"sqlite:///{SCHEDULER_DB}" | ||||
|  | ||||
| @@ -31,17 +30,13 @@ class SchedulerService: | ||||
|     SchedulerRegistry. See app.py for examples. | ||||
|     """ | ||||
|  | ||||
|     _scheduler: BackgroundScheduler = None | ||||
|     # Not Sure if this is still needed? | ||||
|     # _job_store: dict[str, ScheduledFunc] = {} | ||||
|     _scheduler: BackgroundScheduler | ||||
|  | ||||
|     @staticmethod | ||||
|     def start(): | ||||
|         # Preclean | ||||
|         SCHEDULER_DB.unlink(missing_ok=True) | ||||
|  | ||||
|         # Scaffold | ||||
|         TEMP_DATA.mkdir(parents=True, exist_ok=True) | ||||
|  | ||||
|         # Register Interval Jobs and Start Scheduler | ||||
|         SchedulerService._scheduler = BackgroundScheduler(jobstores={"default": SQLAlchemyJobStore(SCHEDULER_DATABASE)}) | ||||
|         SchedulerService._scheduler.add_job(run_daily, "interval", minutes=MINUTES_DAY, id="Daily Interval Jobs") | ||||
| @@ -54,6 +49,7 @@ class SchedulerService: | ||||
|     def scheduler(cls) -> BackgroundScheduler: | ||||
|         return SchedulerService._scheduler | ||||
|  | ||||
|     @staticmethod | ||||
|     def add_cron_job(job_func: ScheduledFunc): | ||||
|         SchedulerService.scheduler.add_job( | ||||
|             job_func.callback, | ||||
| @@ -68,6 +64,7 @@ class SchedulerService: | ||||
|  | ||||
|         # SchedulerService._job_store[job_func.id] = job_func | ||||
|  | ||||
|     @staticmethod | ||||
|     def update_cron_job(job_func: ScheduledFunc): | ||||
|         SchedulerService.scheduler.reschedule_job( | ||||
|             job_func.id, | ||||
|   | ||||
							
								
								
									
										199
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										199
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							| @@ -14,6 +14,23 @@ category = "main" | ||||
| optional = false | ||||
| python-versions = "*" | ||||
|  | ||||
| [[package]] | ||||
| name = "anyio" | ||||
| version = "3.4.0" | ||||
| description = "High level compatibility layer for multiple asynchronous event loop implementations" | ||||
| category = "main" | ||||
| optional = false | ||||
| python-versions = ">=3.6.2" | ||||
|  | ||||
| [package.dependencies] | ||||
| idna = ">=2.8" | ||||
| sniffio = ">=1.1" | ||||
|  | ||||
| [package.extras] | ||||
| doc = ["sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] | ||||
| test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] | ||||
| trio = ["trio (>=0.16)"] | ||||
|  | ||||
| [[package]] | ||||
| name = "appdirs" | ||||
| version = "1.4.4" | ||||
| @@ -336,21 +353,21 @@ cli = ["requests"] | ||||
|  | ||||
| [[package]] | ||||
| name = "fastapi" | ||||
| version = "0.63.0" | ||||
| version = "0.71.0" | ||||
| description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" | ||||
| category = "main" | ||||
| optional = false | ||||
| python-versions = ">=3.6" | ||||
| python-versions = ">=3.6.1" | ||||
|  | ||||
| [package.dependencies] | ||||
| pydantic = ">=1.0.0,<2.0.0" | ||||
| starlette = "0.13.6" | ||||
| pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.7.3,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0" | ||||
| starlette = "0.17.1" | ||||
|  | ||||
| [package.extras] | ||||
| all = ["requests (>=2.24.0,<3.0.0)", "aiofiles (>=0.5.0,<0.6.0)", "jinja2 (>=2.11.2,<3.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<2.0.0)", "pyyaml (>=5.3.1,<6.0.0)", "graphene (>=2.1.8,<3.0.0)", "ujson (>=3.0.0,<4.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn[standard] (>=0.12.0,<0.14.0)", "async_exit_stack (>=1.0.1,<2.0.0)", "async_generator (>=1.10,<2.0.0)"] | ||||
| dev = ["python-jose[cryptography] (>=3.1.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.14.0)", "graphene (>=2.1.8,<3.0.0)"] | ||||
| doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=6.1.4,<7.0.0)", "markdown-include (>=0.5.1,<0.6.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.2.0)", "typer-cli (>=0.0.9,<0.0.10)", "pyyaml (>=5.3.1,<6.0.0)"] | ||||
| test = ["pytest (==5.4.3)", "pytest-cov (==2.10.0)", "pytest-asyncio (>=0.14.0,<0.15.0)", "mypy (==0.790)", "flake8 (>=3.8.3,<4.0.0)", "black (==20.8b1)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.15.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<2.0.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.4.0)", "orjson (>=3.2.1,<4.0.0)", "async_exit_stack (>=1.0.1,<2.0.0)", "async_generator (>=1.10,<2.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "aiofiles (>=0.5.0,<0.6.0)", "flask (>=1.1.2,<2.0.0)"] | ||||
| all = ["requests (>=2.24.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<3.0.0)", "pyyaml (>=5.3.1,<6.0.0)", "ujson (>=4.0.1,<5.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"] | ||||
| dev = ["python-jose[cryptography] (>=3.3.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"] | ||||
| doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer-cli (>=0.0.12,<0.0.13)", "pyyaml (>=5.3.1,<6.0.0)"] | ||||
| test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==21.9b0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,<5.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "flask (>=1.1.2,<3.0.0)", "anyio[trio] (>=3.2.1,<4.0.0)", "types-ujson (==0.1.1)", "types-orjson (==3.6.0)", "types-dataclasses (==0.1.7)"] | ||||
|  | ||||
| [[package]] | ||||
| name = "fastapi-camelcase" | ||||
| @@ -842,7 +859,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" | ||||
|  | ||||
| [[package]] | ||||
| name = "pydantic" | ||||
| version = "1.8.2" | ||||
| version = "1.9.0" | ||||
| description = "Data validation and settings management using python 3.6 type hinting" | ||||
| category = "main" | ||||
| optional = false | ||||
| @@ -1219,6 +1236,14 @@ category = "main" | ||||
| optional = false | ||||
| python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" | ||||
|  | ||||
| [[package]] | ||||
| name = "sniffio" | ||||
| version = "1.2.0" | ||||
| description = "Sniff out which async library your code is running under" | ||||
| category = "main" | ||||
| optional = false | ||||
| python-versions = ">=3.5" | ||||
|  | ||||
| [[package]] | ||||
| name = "soupsieve" | ||||
| version = "2.2.1" | ||||
| @@ -1229,7 +1254,7 @@ python-versions = ">=3.6" | ||||
|  | ||||
| [[package]] | ||||
| name = "sqlalchemy" | ||||
| version = "1.4.26" | ||||
| version = "1.4.29" | ||||
| description = "Database Abstraction Library" | ||||
| category = "main" | ||||
| optional = false | ||||
| @@ -1261,14 +1286,17 @@ sqlcipher = ["sqlcipher3-binary"] | ||||
|  | ||||
| [[package]] | ||||
| name = "starlette" | ||||
| version = "0.13.6" | ||||
| version = "0.17.1" | ||||
| description = "The little ASGI library that shines." | ||||
| category = "main" | ||||
| optional = false | ||||
| python-versions = ">=3.6" | ||||
|  | ||||
| [package.dependencies] | ||||
| anyio = ">=3.0.0,<4" | ||||
|  | ||||
| [package.extras] | ||||
| full = ["aiofiles", "graphene", "itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests", "ujson"] | ||||
| full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests"] | ||||
|  | ||||
| [[package]] | ||||
| name = "text-unidecode" | ||||
| @@ -1446,7 +1474,7 @@ pgsql = ["psycopg2-binary"] | ||||
| [metadata] | ||||
| lock-version = "1.1" | ||||
| python-versions = "^3.9" | ||||
| content-hash = "b2f08a33545224a00a1a3db706d5dea723f64ef04365f6e1929d3b3875e76932" | ||||
| content-hash = "eb1ef72becee98486ddf7fd709ca90f7e020cb85c567bd9add2d8be34c6c3533" | ||||
|  | ||||
| [metadata.files] | ||||
| aiofiles = [ | ||||
| @@ -1457,6 +1485,10 @@ aniso8601 = [ | ||||
|     {file = "aniso8601-7.0.0-py2.py3-none-any.whl", hash = "sha256:d10a4bf949f619f719b227ef5386e31f49a2b6d453004b21f02661ccc8670c7b"}, | ||||
|     {file = "aniso8601-7.0.0.tar.gz", hash = "sha256:513d2b6637b7853806ae79ffaca6f3e8754bdd547048f5ccc1420aec4b714f1e"}, | ||||
| ] | ||||
| anyio = [ | ||||
|     {file = "anyio-3.4.0-py3-none-any.whl", hash = "sha256:2855a9423524abcdd652d942f8932fda1735210f77a6b392eafd9ff34d3fe020"}, | ||||
|     {file = "anyio-3.4.0.tar.gz", hash = "sha256:24adc69309fb5779bc1e06158e143e0b6d2c56b302a3ac3de3083c705a6ed39d"}, | ||||
| ] | ||||
| appdirs = [ | ||||
|     {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, | ||||
|     {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, | ||||
| @@ -1675,8 +1707,8 @@ extruct = [ | ||||
|     {file = "extruct-0.13.0.tar.gz", hash = "sha256:50a5b5bac4c5e19ecf682bf63a28fde0b1bb57433df7057371f60b58c94a2c64"}, | ||||
| ] | ||||
| fastapi = [ | ||||
|     {file = "fastapi-0.63.0-py3-none-any.whl", hash = "sha256:98d8ea9591d8512fdadf255d2a8fa56515cdd8624dca4af369da73727409508e"}, | ||||
|     {file = "fastapi-0.63.0.tar.gz", hash = "sha256:63c4592f5ef3edf30afa9a44fa7c6b7ccb20e0d3f68cd9eba07b44d552058dcb"}, | ||||
|     {file = "fastapi-0.71.0-py3-none-any.whl", hash = "sha256:a78eca6b084de9667f2d5f37e2ae297270e5a119cd01c2f04815795da92fc87f"}, | ||||
|     {file = "fastapi-0.71.0.tar.gz", hash = "sha256:2b5ac0ae89c80b40d1dd4b2ea0bb1f78d7c4affd3644d080bf050f084759fff2"}, | ||||
| ] | ||||
| fastapi-camelcase = [ | ||||
|     {file = "fastapi_camelcase-1.0.3.tar.gz", hash = "sha256:260249df56bc6bc1e90452659ddd84be92b5e408636d1559ce22a8a1a6d8c5fe"}, | ||||
| @@ -2126,28 +2158,41 @@ pycparser = [ | ||||
|     {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, | ||||
| ] | ||||
| pydantic = [ | ||||
|     {file = "pydantic-1.8.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739"}, | ||||
|     {file = "pydantic-1.8.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4"}, | ||||
|     {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e"}, | ||||
|     {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840"}, | ||||
|     {file = "pydantic-1.8.2-cp36-cp36m-win_amd64.whl", hash = "sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b"}, | ||||
|     {file = "pydantic-1.8.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20"}, | ||||
|     {file = "pydantic-1.8.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb"}, | ||||
|     {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1"}, | ||||
|     {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23"}, | ||||
|     {file = "pydantic-1.8.2-cp37-cp37m-win_amd64.whl", hash = "sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287"}, | ||||
|     {file = "pydantic-1.8.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd"}, | ||||
|     {file = "pydantic-1.8.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505"}, | ||||
|     {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e"}, | ||||
|     {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820"}, | ||||
|     {file = "pydantic-1.8.2-cp38-cp38-win_amd64.whl", hash = "sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3"}, | ||||
|     {file = "pydantic-1.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316"}, | ||||
|     {file = "pydantic-1.8.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62"}, | ||||
|     {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f"}, | ||||
|     {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b"}, | ||||
|     {file = "pydantic-1.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3"}, | ||||
|     {file = "pydantic-1.8.2-py3-none-any.whl", hash = "sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833"}, | ||||
|     {file = "pydantic-1.8.2.tar.gz", hash = "sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b"}, | ||||
|     {file = "pydantic-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb23bcc093697cdea2708baae4f9ba0e972960a835af22560f6ae4e7e47d33f5"}, | ||||
|     {file = "pydantic-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1d5278bd9f0eee04a44c712982343103bba63507480bfd2fc2790fa70cd64cf4"}, | ||||
|     {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab624700dc145aa809e6f3ec93fb8e7d0f99d9023b713f6a953637429b437d37"}, | ||||
|     {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8d7da6f1c1049eefb718d43d99ad73100c958a5367d30b9321b092771e96c25"}, | ||||
|     {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3c3b035103bd4e2e4a28da9da7ef2fa47b00ee4a9cf4f1a735214c1bcd05e0f6"}, | ||||
|     {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3011b975c973819883842c5ab925a4e4298dffccf7782c55ec3580ed17dc464c"}, | ||||
|     {file = "pydantic-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:086254884d10d3ba16da0588604ffdc5aab3f7f09557b998373e885c690dd398"}, | ||||
|     {file = "pydantic-1.9.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0fe476769acaa7fcddd17cadd172b156b53546ec3614a4d880e5d29ea5fbce65"}, | ||||
|     {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8e9dcf1ac499679aceedac7e7ca6d8641f0193c591a2d090282aaf8e9445a46"}, | ||||
|     {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1e4c28f30e767fd07f2ddc6f74f41f034d1dd6bc526cd59e63a82fe8bb9ef4c"}, | ||||
|     {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:c86229333cabaaa8c51cf971496f10318c4734cf7b641f08af0a6fbf17ca3054"}, | ||||
|     {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:c0727bda6e38144d464daec31dff936a82917f431d9c39c39c60a26567eae3ed"}, | ||||
|     {file = "pydantic-1.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:dee5ef83a76ac31ab0c78c10bd7d5437bfdb6358c95b91f1ba7ff7b76f9996a1"}, | ||||
|     {file = "pydantic-1.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d9c9bdb3af48e242838f9f6e6127de9be7063aad17b32215ccc36a09c5cf1070"}, | ||||
|     {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ee7e3209db1e468341ef41fe263eb655f67f5c5a76c924044314e139a1103a2"}, | ||||
|     {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b6037175234850ffd094ca77bf60fb54b08b5b22bc85865331dd3bda7a02fa1"}, | ||||
|     {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b2571db88c636d862b35090ccf92bf24004393f85c8870a37f42d9f23d13e032"}, | ||||
|     {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8b5ac0f1c83d31b324e57a273da59197c83d1bb18171e512908fe5dc7278a1d6"}, | ||||
|     {file = "pydantic-1.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:bbbc94d0c94dd80b3340fc4f04fd4d701f4b038ebad72c39693c794fd3bc2d9d"}, | ||||
|     {file = "pydantic-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e0896200b6a40197405af18828da49f067c2fa1f821491bc8f5bde241ef3f7d7"}, | ||||
|     {file = "pydantic-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bdfdadb5994b44bd5579cfa7c9b0e1b0e540c952d56f627eb227851cda9db77"}, | ||||
|     {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:574936363cd4b9eed8acdd6b80d0143162f2eb654d96cb3a8ee91d3e64bf4cf9"}, | ||||
|     {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c556695b699f648c58373b542534308922c46a1cda06ea47bc9ca45ef5b39ae6"}, | ||||
|     {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f947352c3434e8b937e3aa8f96f47bdfe6d92779e44bb3f41e4c213ba6a32145"}, | ||||
|     {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5e48ef4a8b8c066c4a31409d91d7ca372a774d0212da2787c0d32f8045b1e034"}, | ||||
|     {file = "pydantic-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:96f240bce182ca7fe045c76bcebfa0b0534a1bf402ed05914a6f1dadff91877f"}, | ||||
|     {file = "pydantic-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:815ddebb2792efd4bba5488bc8fde09c29e8ca3227d27cf1c6990fc830fd292b"}, | ||||
|     {file = "pydantic-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6c5b77947b9e85a54848343928b597b4f74fc364b70926b3c4441ff52620640c"}, | ||||
|     {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c68c3bc88dbda2a6805e9a142ce84782d3930f8fdd9655430d8576315ad97ce"}, | ||||
|     {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a79330f8571faf71bf93667d3ee054609816f10a259a109a0738dac983b23c3"}, | ||||
|     {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f5a64b64ddf4c99fe201ac2724daada8595ada0d102ab96d019c1555c2d6441d"}, | ||||
|     {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a733965f1a2b4090a5238d40d983dcd78f3ecea221c7af1497b845a9709c1721"}, | ||||
|     {file = "pydantic-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cc6a4cb8a118ffec2ca5fcb47afbacb4f16d0ab8b7350ddea5e8ef7bcc53a16"}, | ||||
|     {file = "pydantic-1.9.0-py3-none-any.whl", hash = "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3"}, | ||||
|     {file = "pydantic-1.9.0.tar.gz", hash = "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a"}, | ||||
| ] | ||||
| pydantic-to-typescript = [ | ||||
|     {file = "pydantic-to-typescript-1.0.7.tar.gz", hash = "sha256:dccf668e97626e616d20f2b1b99a568b5ac16344f3b2c850ebc463118b21a3d7"}, | ||||
| @@ -2334,51 +2379,55 @@ six = [ | ||||
|     {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, | ||||
|     {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, | ||||
| ] | ||||
| sniffio = [ | ||||
|     {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"}, | ||||
|     {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"}, | ||||
| ] | ||||
| soupsieve = [ | ||||
|     {file = "soupsieve-2.2.1-py3-none-any.whl", hash = "sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b"}, | ||||
|     {file = "soupsieve-2.2.1.tar.gz", hash = "sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc"}, | ||||
| ] | ||||
| sqlalchemy = [ | ||||
|     {file = "SQLAlchemy-1.4.26-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:c2f2114b0968a280f94deeeaa31cfbac9175e6ac7bd3058b3ce6e054ecd762b3"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:91efbda4e6d311812f23996242bad7665c1392209554f8a31ec6db757456db5c"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp27-cp27m-win32.whl", hash = "sha256:de996756d894a2d52c132742e3b6d64ecd37e0919ddadf4dc3981818777c7e67"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp27-cp27m-win_amd64.whl", hash = "sha256:463ef692259ff8189be42223e433542347ae17e33f91c1013e9c5c64e2798088"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c757ba1279b85b3460e72e8b92239dae6f8b060a75fb24b3d9be984dd78cfa55"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:c24c01dcd03426a5fe5ee7af735906bec6084977b9027a3605d11d949a565c01"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c46f013ff31b80cbe36410281675e1fb4eaf3e25c284fd8a69981c73f6fa4cb4"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fb2aa74a6e3c2cebea38dd21633671841fbe70ea486053cba33d68e3e22ccc0a"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad7e403fc1e3cb76e802872694e30d6ca6129b9bc6ad4e7caa48ca35f8a144f8"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp310-cp310-win32.whl", hash = "sha256:7ef421c3887b39c6f352e5022a53ac18de8387de331130481cb956b2d029cad6"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp310-cp310-win_amd64.whl", hash = "sha256:908fad32c53b17aad12d722379150c3c5317c422437e44032256a77df1746292"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:1ef37c9ec2015ce2f0dc1084514e197f2f199d3dc3514190db7620b78e6004c8"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:090536fd23bf49077ee94ff97142bc5ee8bad24294c3d7c8d5284267c885dde7"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e700d48056475d077f867e6a36e58546de71bdb6fdc3d34b879e3240827fefab"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:295b90efef1278f27fe27d94a45460ae3c17f5c5c2b32c163e29c359740a1599"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp36-cp36m-win32.whl", hash = "sha256:cc6b21f19bc9d4cd77cbcba5f3b260436ce033f1053cea225b6efea2603d201e"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp36-cp36m-win_amd64.whl", hash = "sha256:ba84026e84379326bbf2f0c50792f2ae56ab9c01937df5597b6893810b8ca369"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:f1e97c5f36b94542f72917b62f3a2f92be914b2cf33b80fa69cede7529241d2a"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c185c928e2638af9bae13acc3f70e0096eac76471a1101a10f96b80666b8270"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bca660b76672e15d70a7dba5e703e1ce451a0257b6bd2028e62b0487885e8ae9"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff8f91a7b1c4a1c7772caa9efe640f2768828897044748f2458b708f1026e2d4"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp37-cp37m-win32.whl", hash = "sha256:a95bf9c725012dcd7ea3cac16bf647054e0d62b31d67467d228338e6a163e4ff"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp37-cp37m-win_amd64.whl", hash = "sha256:07ac4461a1116b317519ddf6f34bcb00b011b5c1370ebeaaf56595504ffc7e84"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:5039faa365e7522a8eb4736a54afd24a7e75dcc33b81ab2f0e6c456140f1ad64"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7e8ef103eaa72a857746fd57dda5b8b5961e8e82a528a3f8b7e2884d8506f0b7"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:31f4426cfad19b5a50d07153146b2bcb372a279975d5fa39f98883c0ef0f3313"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2feb028dc75e13ba93456a42ac042b255bf94dbd692bf80b47b22653bb25ccf8"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp38-cp38-win32.whl", hash = "sha256:2ce42ad1f59eb85c55c44fb505f8854081ee23748f76b62a7f569cfa9b6d0604"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp38-cp38-win_amd64.whl", hash = "sha256:dbf588ab09e522ac2cbd010919a592c6aae2f15ccc3cd9a96d01c42fbc13f63e"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:a6506c17b0b6016656783232d0bdd03fd333f1f654d51a14d93223f953903646"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a882dedb9dfa6f33524953c3e3d72bcf518a5defd6d5863150a821928b19ad3"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1dee515578d04bc80c4f9a8c8cfe93f455db725059e885f1b1da174d91c4d077"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c0c5f54560a92691d54b0768d67b4d3159e514b426cfcb1258af8c195577e8f"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp39-cp39-win32.whl", hash = "sha256:b86f762cee3709722ab4691981958cbec475ea43406a6916a7ec375db9cbd9e9"}, | ||||
|     {file = "SQLAlchemy-1.4.26-cp39-cp39-win_amd64.whl", hash = "sha256:5c6774b34782116ad9bdec61c2dbce9faaca4b166a0bc8e7b03c2b870b121d94"}, | ||||
|     {file = "SQLAlchemy-1.4.26.tar.gz", hash = "sha256:6bc7f9d7d90ef55e8c6db1308a8619cd8f40e24a34f759119b95e7284dca351a"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:da64423c05256f4ab8c0058b90202053b201cbe3a081f3a43eb590cd554395ab"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:0fc4eec2f46b40bdd42112b3be3fbbf88e194bcf02950fbb88bcdc1b32f07dc7"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp27-cp27m-win32.whl", hash = "sha256:101d2e100ba9182c9039699588e0b2d833c54b3bad46c67c192159876c9f27ea"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp27-cp27m-win_amd64.whl", hash = "sha256:ceac84dd9abbbe115e8be0c817bed85d9fa639b4d294e7817f9e61162d5f766c"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:15b65887b6c324cad638c7671cb95985817b733242a7eb69edd7cdf6953be1e0"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:78abc507d17753ed434b6cc0c0693126279723d5656d9775bfcac966a99a899b"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb8c993706e86178ce15a6b86a335a2064f52254b640e7f53365e716423d33f4"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:804e22d5b6165a4f3f019dd9c94bec5687de985a9c54286b93ded9f7846b8c82"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56d9d62021946263d4478c9ca012fbd1805f10994cb615c88e7bfd1ae14604d8"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp310-cp310-win32.whl", hash = "sha256:027f356c727db24f3c75828c7feb426f87ce1241242d08958e454bd025810660"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp310-cp310-win_amd64.whl", hash = "sha256:debaf09a823061f88a8dee04949814cf7e82fb394c5bca22c780cb03172ca23b"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:dc27dcc6c72eb38be7f144e9c2c4372d35a3684d3a6dd43bd98c1238358ee17c"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4ddd4f2e247128c58bb3dd4489922874afce157d2cff0b2295d67fcd0f22494"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9ce960a1dc60524136cf6f75621588e2508a117e04a6e3eedb0968bd13b8c824"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5919e647e1d4805867ea556ed4967c68b4d8b266059fa35020dbaed8ffdd60f3"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp36-cp36m-win32.whl", hash = "sha256:886359f734b95ad1ef443b13bb4518bcade4db4f9553c9ce33d6d04ebda8d44e"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp36-cp36m-win_amd64.whl", hash = "sha256:e9cc6d844e24c307c3272677982a9b33816aeb45e4977791c3bdd47637a8d810"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:5e9cd33459afa69c88fa648e803d1f1245e3caa60bfe8b80a9595e5edd3bda9c"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eeaebceb24b46e884c4ad3c04f37feb178b81f6ce720af19bfa2592ca32fdef7"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e89347d3bd2ef873832b47e85f4bbd810a5e626c5e749d90a07638da100eb1c8"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a717c2e70fd1bb477161c4cc85258e41d978584fbe5522613618195f7e87d9b"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp37-cp37m-win32.whl", hash = "sha256:f74d6c05d2d163464adbdfbc1ab85048cc15462ff7d134b8aed22bd521e1faa5"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp37-cp37m-win_amd64.whl", hash = "sha256:621854dbb4d2413c759a5571564170de45ef37299df52e78e62b42e2880192e1"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:f3909194751bb6cb7c5511dd18bcf77e6e3f0b31604ed4004dffa9461f71e737"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd49d21d1f03c81fbec9080ecdc4486d5ddda67e7fbb75ebf48294465c022cdc"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e5f6959466a42b6569774c257e55f9cd85200d5b0ba09f0f5d8b5845349c5822"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0072f9887aabe66db23f818bbe950cfa1b6127c5cb769b00bcc07935b3adb0ad"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp38-cp38-win32.whl", hash = "sha256:ad618d687d26d4cbfa9c6fa6141d59e05bcdfc60cb6e1f1d3baa18d8c62fef5f"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp38-cp38-win_amd64.whl", hash = "sha256:878daecb6405e786b07f97e1c77a9cfbbbec17432e8a90c487967e32cfdecb33"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:e027bdf0a4cf6bd0a3ad3b998643ea374d7991bd117b90bf9982e41ceb742941"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5de7adfb91d351f44062b8dedf29f49d4af7cb765be65816e79223a4e31062b"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fbc6e63e481fa323036f305ada96a3362e1d60dd2bfa026cac10c3553e6880e9"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7dd0502cb091660ad0d89c5e95a29825f37cde2a5249957838e975871fbffaad"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp39-cp39-win32.whl", hash = "sha256:37b46bfc4af3dc226acb6fa28ecd2e1fd223433dc5e15a2bad62bf0a0cbb4e8b"}, | ||||
|     {file = "SQLAlchemy-1.4.29-cp39-cp39-win_amd64.whl", hash = "sha256:08cfd35eecaba79be930c9bfd2e1f0c67a7e1314355d83a378f9a512b1cf7587"}, | ||||
|     {file = "SQLAlchemy-1.4.29.tar.gz", hash = "sha256:fa2bad14e1474ba649cfc969c1d2ec915dd3e79677f346bbfe08e93ef9020b39"}, | ||||
| ] | ||||
| starlette = [ | ||||
|     {file = "starlette-0.13.6-py3-none-any.whl", hash = "sha256:bd2ffe5e37fb75d014728511f8e68ebf2c80b0fa3d04ca1479f4dc752ae31ac9"}, | ||||
|     {file = "starlette-0.13.6.tar.gz", hash = "sha256:ebe8ee08d9be96a3c9f31b2cb2a24dbdf845247b745664bd8a3f9bd0c977fdbc"}, | ||||
|     {file = "starlette-0.17.1-py3-none-any.whl", hash = "sha256:26a18cbda5e6b651c964c12c88b36d9898481cd428ed6e063f5f29c418f73050"}, | ||||
|     {file = "starlette-0.17.1.tar.gz", hash = "sha256:57eab3cc975a28af62f6faec94d355a410634940f10b30d68d31cb5ec1b44ae8"}, | ||||
| ] | ||||
| text-unidecode = [ | ||||
|     {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"}, | ||||
|   | ||||
| @@ -13,10 +13,10 @@ python = "^3.9" | ||||
| aiofiles = "0.5.0" | ||||
| aniso8601 = "7.0.0" | ||||
| appdirs = "1.4.4" | ||||
| fastapi = "^0.63.0" | ||||
| fastapi = "^0.71.0" | ||||
| uvicorn = {extras = ["standard"], version = "^0.13.0"} | ||||
| APScheduler = "^3.6.3" | ||||
| SQLAlchemy = "^1.3.22" | ||||
| SQLAlchemy = "^1.4.29" | ||||
| Jinja2 = "^2.11.2" | ||||
| python-dotenv = "^0.15.0" | ||||
| python-slugify = "^4.0.1" | ||||
| @@ -38,6 +38,7 @@ gunicorn = "^20.1.0" | ||||
| emails = "^0.6" | ||||
| python-i18n = "^0.3.9" | ||||
| python-ldap = "^3.3.1" | ||||
| pydantic = "^1.9.0" | ||||
|  | ||||
| [tool.poetry.dev-dependencies] | ||||
| pylint = "^2.6.0" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user