1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-07-19 05:09:40 +02: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:
Hayden 2022-01-08 22:24:34 -09:00 committed by GitHub
parent 86c99b10a2
commit 6db1357064
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
66 changed files with 3455 additions and 1311 deletions

View file

@ -15,7 +15,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest]
node: [15]
node: [16]
steps:
- name: Checkout 🛎

View file

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

View file

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

View file

@ -0,0 +1,24 @@
![Recipe Image](../../images/{{ recipe.slug }}/original.jpg)
# {{ 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 }}

View file

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

View file

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

View 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);
}
}

View file

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

View file

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

View file

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

View file

@ -38,6 +38,7 @@
edit: true,
download: true,
mealplanner: true,
shoppingList: true,
print: false,
share: true,
}"

View file

@ -39,6 +39,7 @@
edit: true,
download: true,
mealplanner: true,
shoppingList: true,
print: false,
share: true,
}"

View file

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

View file

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

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.9 KiB

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,5 +1,6 @@
from .event import *
from .group import *
from .labels import *
from .recipe.recipe import *
from .server import *
from .sign_up import *

View file

@ -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,7 +90,8 @@ def handle_one_to_many_list(session: Session, get_attr, relation_cls, all_elemen
elif isinstance(elem, dict):
for key, value in elem.items():
setattr(existing_elem, key, value)
if key not in cfg.exclude:
setattr(existing_elem, key, value)
updated_elems.append(existing_elem)

View file

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

View file

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

View 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

View file

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

View file

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

View file

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

View file

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

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

View 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):
...

View file

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

View 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"
}
]

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

View file

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

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

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

View 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

View file

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

View 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

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

View file

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

View file

@ -1 +1,2 @@
from .group_shopping_list import *
from .webhook import *

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

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

View file

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

@ -0,0 +1,6 @@
from fastapi_camelcase import CamelModel
class GetAll(CamelModel):
start: int = 0
limit: int = 999

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

View file

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