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: strategy:
matrix: matrix:
os: [ubuntu-latest] os: [ubuntu-latest]
node: [15] node: [16]
steps: steps:
- name: Checkout 🛎 - name: Checkout 🛎

View File

@ -42,6 +42,7 @@
"python.testing.pytestArgs": ["tests"], "python.testing.pytestArgs": ["tests"],
"python.testing.pytestEnabled": true, "python.testing.pytestEnabled": true,
"python.testing.unittestEnabled": false, "python.testing.unittestEnabled": false,
"python.analysis.typeCheckingMode": "off",
"search.mode": "reuseEditor", "search.mode": "reuseEditor",
"vetur.validation.template": false, "vetur.validation.template": false,
"python.sortImports.path": "${workspaceFolder}/.venv/bin/isort" "python.sortImports.path": "${workspaceFolder}/.venv/bin/isort"

View File

@ -135,7 +135,6 @@ WORKDIR /
# copy frontend # copy frontend
# COPY --from=frontend-build /app/dist $MEALIE_HOME/dist # COPY --from=frontend-build /app/dist $MEALIE_HOME/dist
COPY ./dev/data/templates $MEALIE_HOME/data/templates
COPY ./Caddyfile $MEALIE_HOME COPY ./Caddyfile $MEALIE_HOME
# Grab CRF++ Model Release # 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 WORKDIR /app
@ -21,7 +21,7 @@ RUN rm -rf node_modules && \
--non-interactive \ --non-interactive \
--production=true --production=true
FROM node:15-alpine FROM node:16-alpine
WORKDIR /app 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 { ToolsApi } from "./class-interfaces/tools";
import { GroupMigrationApi } from "./class-interfaces/group-migrations"; import { GroupMigrationApi } from "./class-interfaces/group-migrations";
import { GroupReportsApi } from "./class-interfaces/group-reports"; 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"; import { ApiRequestInstance } from "~/types/api";
class Api { class Api {
@ -46,6 +48,8 @@ class Api {
public groupReports: GroupReportsApi; public groupReports: GroupReportsApi;
public grouperServerTasks: GroupServerTaskAPI; public grouperServerTasks: GroupServerTaskAPI;
public tools: ToolsApi; public tools: ToolsApi;
public shopping: ShoppingApi;
public multiPurposeLabels: MultiPurposeLabelsApi;
// Utils // Utils
public upload: UploadFile; public upload: UploadFile;
@ -74,6 +78,8 @@ class Api {
// Group // Group
this.groupMigration = new GroupMigrationApi(requests); this.groupMigration = new GroupMigrationApi(requests);
this.groupReports = new GroupReportsApi(requests); this.groupReports = new GroupReportsApi(requests);
this.shopping = new ShoppingApi(requests);
this.multiPurposeLabels = new MultiPurposeLabelsApi(requests);
// Admin // Admin
this.events = new EventsAPI(requests); this.events = new EventsAPI(requests);

View File

@ -22,3 +22,20 @@
.theme--dark.v-card { .theme--dark.v-card {
background-color: #2b2b2b !important; 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 show-print
:menu-top="false" :menu-top="false"
:slug="slug" :slug="slug"
:name="name"
:menu-icon="$globals.icons.mdiDotsHorizontal" :menu-icon="$globals.icons.mdiDotsHorizontal"
fab fab
color="info" color="info"
@ -69,6 +68,7 @@
edit: false, edit: false,
download: true, download: true,
mealplanner: true, mealplanner: true,
shoppingList: true,
print: true, print: true,
share: true, share: true,
}" }"

View File

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

View File

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

View File

@ -11,7 +11,7 @@
<BaseCardSectionTitle :title="key"> </BaseCardSectionTitle> <BaseCardSectionTitle :title="key"> </BaseCardSectionTitle>
<v-row> <v-row>
<v-col v-for="(item, index) in itms" :key="'cat' + index" cols="12" :sm="12" :md="6" :lg="4" :xl="3"> <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-card-actions>
<v-icon> <v-icon>
{{ icon }} {{ icon }}

View File

@ -46,6 +46,21 @@
<v-select v-model="newMealType" :return-object="false" :items="planTypeOptions" label="Entry Type"></v-select> <v-select v-model="newMealType" :return-object="false" :items="planTypeOptions" label="Entry Type"></v-select>
</v-card-text> </v-card-text>
</BaseDialog> </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 <v-menu
offset-y offset-y
left left
@ -76,17 +91,19 @@
</template> </template>
<script lang="ts"> <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 RecipeDialogShare from "./RecipeDialogShare.vue";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
import { MealType, planTypeOptions } from "~/composables/use-group-mealplan"; import { MealType, planTypeOptions } from "~/composables/use-group-mealplan";
import { ShoppingListSummary } from "~/api/class-interfaces/group-shopping-lists";
export interface ContextMenuIncludes { export interface ContextMenuIncludes {
delete: boolean; delete: boolean;
edit: boolean; edit: boolean;
download: boolean; download: boolean;
mealplanner: boolean; mealplanner: boolean;
shoppingList: boolean;
print: boolean; print: boolean;
share: boolean; share: boolean;
} }
@ -110,6 +127,7 @@ export default defineComponent({
edit: true, edit: true,
download: true, download: true,
mealplanner: true, mealplanner: true,
shoppingList: true,
print: true, print: true,
share: true, share: true,
}), }),
@ -160,6 +178,7 @@ export default defineComponent({
shareDialog: false, shareDialog: false,
recipeDeleteDialog: false, recipeDeleteDialog: false,
mealplannerDialog: false, mealplannerDialog: false,
shoppingListDialog: false,
loading: false, loading: false,
menuItems: [] as ContextMenuItem[], menuItems: [] as ContextMenuItem[],
newMealdate: "", newMealdate: "",
@ -197,6 +216,12 @@ export default defineComponent({
color: undefined, color: undefined,
event: "mealplanner", event: "mealplanner",
}, },
shoppingList: {
title: "Add to List",
icon: $globals.icons.cartCheck,
color: undefined,
event: "shoppingList",
},
print: { print: {
title: i18n.t("general.print") as string, title: i18n.t("general.print") as string,
icon: $globals.icons.printer, icon: $globals.icons.printer,
@ -229,6 +254,23 @@ export default defineComponent({
// =========================================================================== // ===========================================================================
// Context Menu Event Handler // 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(); const router = useRouter();
async function deleteRecipe() { async function deleteRecipe() {
@ -270,6 +312,10 @@ export default defineComponent({
mealplanner: () => { mealplanner: () => {
state.mealplannerDialog = true; state.mealplannerDialog = true;
}, },
shoppingList: () => {
getShoppingLists();
state.shoppingListDialog = true;
},
share: () => { share: () => {
state.shareDialog = true; state.shareDialog = true;
}, },
@ -289,6 +335,8 @@ export default defineComponent({
} }
return { return {
shoppingLists,
addRecipeToList,
...toRefs(state), ...toRefs(state),
contextMenuEventHandler, contextMenuEventHandler,
deleteRecipe, 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 --> <!-- Secondary Links -->
<template v-if="secondaryLinks"> <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-divider></v-divider>
<v-list nav dense exact> <v-list nav dense exact>
<template v-for="nav in secondaryLinks"> <template v-for="nav in secondaryLinks">
@ -161,6 +163,10 @@ export default defineComponent({
type: String, type: String,
default: null, default: null,
}, },
secondaryHeaderLink: {
type: String,
default: null,
},
}, },
setup(props, context) { setup(props, context) {
// V-Model Support // 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>
</v-list-item-group> </v-list-item-group>
</v-list> </v-list>
<!-- Event --> <!-- Links -->
<v-list v-else-if="mode === MODES.link" dense> <v-list v-else-if="mode === MODES.link" dense>
<v-list-item-group v-model="itemGroup"> <v-list-item-group v-model="itemGroup">
<v-list-item v-for="(item, index) in items" :key="index" :to="item.to"> <v-list-item v-for="(item, index) in items" :key="index" :to="item.to">
@ -58,6 +58,13 @@ const MODES = {
event: "event", event: "event",
}; };
export interface MenuItem {
text: string;
icon: string;
to?: string;
event: string;
}
export default defineComponent({ export default defineComponent({
props: { props: {
mode: { mode: {
@ -65,7 +72,7 @@ export default defineComponent({
default: "model", default: "model",
}, },
items: { items: {
type: Array, type: Array as () => MenuItem[],
required: true, required: true,
}, },
disabled: { disabled: {
@ -92,6 +99,8 @@ export default defineComponent({
const activeObj = ref({ const activeObj = ref({
text: "DEFAULT", text: "DEFAULT",
value: "", value: "",
icon: undefined,
event: undefined,
}); });
let startIndex = 0; let startIndex = 0;

View File

@ -19,7 +19,7 @@ export function detectServerBaseUrl(req?: IncomingMessage | null) {
} else if (req.socket.remoteAddress) { } else if (req.socket.remoteAddress) {
// @ts-ignore // @ts-ignore
const protocol = req.socket.encrypted ? "https:" : "http:"; const protocol = req.socket.encrypted ? "https:" : "http:";
return `${protocol}//${req.socket.localAddress}:${req.socket.localPort}`; return `${protocol}//${req.socket.localAddress || ""}:${req.socket.localPort || ""}`;
} }
return ""; return "";

View File

@ -7,6 +7,7 @@
absolute absolute
:top-link="topLinks" :top-link="topLinks"
secondary-header="Cookbooks" secondary-header="Cookbooks"
secondary-header-link="/user/group/cookbooks"
:secondary-links="cookbookLinks || []" :secondary-links="cookbookLinks || []"
:bottom-links="isAdmin ? bottomLink : []" :bottom-links="isAdmin ? bottomLink : []"
> >
@ -135,7 +136,7 @@ export default defineComponent({
icon: this.$globals.icons.cartCheck, icon: this.$globals.icons.cartCheck,
title: "Shopping List", title: "Shopping List",
subtitle: "Create a new shopping list", subtitle: "Create a new shopping list",
to: "/user/group/shopping-list/create", to: "/user/group/shopping-lists/create",
restricted: true, restricted: true,
}, },
], ],
@ -157,7 +158,7 @@ export default defineComponent({
{ {
icon: this.$globals.icons.formatListCheck, icon: this.$globals.icons.formatListCheck,
title: this.$t("shopping-list.shopping-lists"), title: this.$t("shopping-list.shopping-lists"),
to: "/shopping-list", to: "/shopping-lists",
restricted: true, restricted: true,
}, },
{ {

View File

@ -229,8 +229,8 @@ export default defineComponent({
const weekRange = computed(() => { const weekRange = computed(() => {
return { return {
start: subDays(state.today, 1), start: subDays(state.today as Date, 1),
end: addDays(state.today, 6), end: addDays(state.today as Date, 6),
}; };
}); });
@ -248,12 +248,12 @@ export default defineComponent({
function forwardOneWeek() { function forwardOneWeek() {
if (!state.today) return; if (!state.today) return;
state.today = addDays(state.today, +5); state.today = addDays(state.today as Date, +5);
} }
function backOneWeek() { function backOneWeek() {
if (!state.today) return; if (!state.today) return;
state.today = addDays(state.today, -5); state.today = addDays(state.today as Date, -5);
} }
function onMoveCallback(evt: SortableEvent) { function onMoveCallback(evt: SortableEvent) {

View File

@ -33,8 +33,9 @@
<v-img <v-img
:key="imageKey" :key="imageKey"
:max-width="enableLandscape ? null : '50%'" :max-width="enableLandscape ? null : '50%'"
:min-height="hideImage ? '50' : imageHeight" min-height="50"
:src="recipeImage(recipe.slug, '', imageKey)" :height="hideImage ? undefined : imageHeight"
:src="recipeImage(recipe.slug, imageKey)"
class="d-print-none" class="d-print-none"
@error="hideImage = true" @error="hideImage = true"
> >
@ -78,7 +79,12 @@
> >
<div v-if="form" class="d-flex justify-start align-center"> <div v-if="form" class="d-flex justify-start align-center">
<RecipeImageUploadBtn class="my-1" :slug="recipe.slug" @upload="uploadImage" @refresh="imageKey++" /> <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> </div>
<!-- Recipe Title Section --> <!-- Recipe Title Section -->
<template v-if="!form && enableLandscape"> <template v-if="!form && enableLandscape">
@ -771,4 +777,3 @@ export default defineComponent({
}, },
}); });
</script> </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, mdiTimerSand,
mdiRefresh, mdiRefresh,
mdiArrowRightBold, mdiArrowRightBold,
mdiChevronRight,
} from "@mdi/js"; } from "@mdi/js";
export const icons = { export const icons = {
@ -222,4 +223,5 @@ export const icons = {
back: mdiArrowLeftBoldOutline, back: mdiArrowLeftBoldOutline,
slotMachine: mdiSlotMachine, slotMachine: mdiSlotMachine,
chevronDown: mdiChevronDown, 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.db.models._model_base import SqlAlchemyBase
from mealie.repos.all_repositories import get_repositories from mealie.repos.all_repositories import get_repositories
from mealie.repos.repository_factory import AllRepositories 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.init_users import default_user_init
from mealie.repos.seed.seeders import IngredientFoodsSeeder, IngredientUnitsSeeder, MultiPurposeLabelSeeder
from mealie.schema.user.user import GroupBase from mealie.schema.user.user import GroupBase
from mealie.services.events import create_general_event from mealie.services.events import create_general_event
from mealie.services.group_services.group_utils import create_new_group from mealie.services.group_services.group_utils import create_new_group
logger = root_logger.get_logger("init_db") logger = root_logger.get_logger("init_db")
settings = get_app_settings()
def create_all_models(): def create_all_models():
import mealie.db.models._all_models # noqa: F401 import mealie.db.models._all_models # noqa: F401
@ -22,12 +20,25 @@ def create_all_models():
def init_db(db: AllRepositories) -> None: def init_db(db: AllRepositories) -> None:
# TODO: Port other seed data to use abstract seeder class
default_group_init(db) default_group_init(db)
default_user_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): def default_group_init(db: AllRepositories):
settings = get_app_settings()
logger.info("Generating Default Group") logger.info("Generating Default Group")
create_new_group(db, GroupBase(name=settings.DEFAULT_GROUP)) create_new_group(db, GroupBase(name=settings.DEFAULT_GROUP))

View File

@ -1,5 +1,6 @@
from .event import * from .event import *
from .group import * from .group import *
from .labels import *
from .recipe.recipe import * from .recipe.recipe import *
from .server import * from .server import *
from .sign_up 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] = [] elems_to_create: list[dict] = []
updated_elems: list[dict] = [] updated_elems: list[dict] = []
cfg = _get_config(relation_cls)
for elem in all_elements: for elem in all_elements:
elem_id = elem.get(get_attr, None) if isinstance(elem, dict) else elem 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() 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): elif isinstance(elem, dict):
for key, value in elem.items(): 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) updated_elems.append(existing_elem)

View File

@ -5,6 +5,7 @@ import sqlalchemy.orm as orm
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from mealie.core.config import get_app_settings from mealie.core.config import get_app_settings
from mealie.db.models.labels import MultiPurposeLabel
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import GUID, auto_init from .._model_utils import GUID, auto_init
@ -47,6 +48,8 @@ class Group(SqlAlchemyBase, BaseMixins):
"single_parent": True, "single_parent": True,
} }
labels = orm.relationship(MultiPurposeLabel, **common_args)
mealplans = orm.relationship(GroupMealPlan, order_by="GroupMealPlan.date", **common_args) mealplans = orm.relationship(GroupMealPlan, order_by="GroupMealPlan.date", **common_args)
webhooks = orm.relationship(GroupWebhooksModel, **common_args) webhooks = orm.relationship(GroupWebhooksModel, **common_args)
cookbooks = orm.relationship(CookBook, **common_args) cookbooks = orm.relationship(CookBook, **common_args)

View File

@ -1,51 +1,64 @@
import sqlalchemy.orm as orm from sqlalchemy import Boolean, Column, Float, ForeignKey, Integer, String, orm
from requests import Session
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
from sqlalchemy.ext.orderinglist import ordering_list from sqlalchemy.ext.orderinglist import ordering_list
from mealie.db.models.labels import MultiPurposeLabel
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils.guid import GUID from .._model_utils import GUID, auto_init
from .group import Group from ..recipe.ingredient import IngredientFoodModel, IngredientUnitModel
class ShoppingListItem(SqlAlchemyBase, BaseMixins): class ShoppingListItem(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_list_items" __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) # Id's
text = Column(String) id = Column(GUID, primary_key=True, default=GUID.generate)
quantity = Column(Integer) shopping_list_id = Column(GUID, ForeignKey("shopping_lists.id"))
checked = Column(Boolean)
def __init__(self, title, text, quantity, checked, **_) -> None: # Meta
self.title = title recipe_id = Column(Integer, nullable=True)
self.text = text is_ingredient = Column(Boolean, default=True)
self.quantity = quantity position = Column(Integer, nullable=False, default=0)
self.checked = checked 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): class ShoppingList(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_lists" __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_id = Column(GUID, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="shopping_lists") group = orm.relationship("Group", back_populates="shopping_lists")
name = Column(String) name = Column(String)
items: list[ShoppingListItem] = orm.relationship( list_items = orm.relationship(
ShoppingListItem, ShoppingListItem,
cascade="all, delete, delete-orphan", cascade="all, delete, delete-orphan",
order_by="ShoppingListItem.position", order_by="ShoppingListItem.position",
collection_class=ordering_list("position"), collection_class=ordering_list("position"),
) )
def __init__(self, name, group, items, session=None, **_) -> None: @auto_init()
self.name = name def __init__(self, **_) -> None:
self.group = Group.get_ref(session, group) pass
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()

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 sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase 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 import auto_init
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
@ -27,6 +28,9 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
description = Column(String) description = Column(String)
ingredients = orm.relationship("RecipeIngredient", back_populates="food") 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() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:
pass pass
@ -51,8 +55,6 @@ class RecipeIngredient(SqlAlchemyBase, BaseMixins):
reference_id = Column(GUID) # Reference Links reference_id = Column(GUID) # Reference Links
# Extras
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:
pass pass

View File

@ -26,6 +26,12 @@ class RecipeInstruction(SqlAlchemyBase):
ingredient_references = orm.relationship("RecipeIngredientRefLink", cascade="all, delete-orphan") ingredient_references = orm.relationship("RecipeIngredientRefLink", cascade="all, delete-orphan")
class Config:
exclude = {
"id",
"ingredient_references",
}
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, ingredient_references, **_) -> None:
pass 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.exports import GroupDataExportsModel
from mealie.db.models.group.invite_tokens import GroupInviteToken from mealie.db.models.group.invite_tokens import GroupInviteToken
from mealie.db.models.group.preferences import GroupPreferencesModel 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.group.webhooks import GroupWebhooksModel
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.category import Category from mealie.db.models.recipe.category import Category
from mealie.db.models.recipe.comment import RecipeComment from mealie.db.models.recipe.comment import RecipeComment
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel 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.events import EventNotificationIn
from mealie.schema.group.group_exports import GroupDataExport from mealie.schema.group.group_exports import GroupDataExport
from mealie.schema.group.group_preferences import ReadGroupPreferences 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.invite_token import ReadInviteToken
from mealie.schema.group.webhook import ReadWebhook 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.meal_plan.new_meal import ReadPlanEntry
from mealie.schema.recipe import Recipe, RecipeCategoryResponse, RecipeCommentOut, RecipeTagResponse, RecipeTool from mealie.schema.recipe import Recipe, RecipeCategoryResponse, RecipeCommentOut, RecipeTagResponse, RecipeTool
from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit 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_group import RepositoryGroup
from .repository_meals import RepositoryMeals from .repository_meals import RepositoryMeals
from .repository_recipes import RepositoryRecipes from .repository_recipes import RepositoryRecipes
from .repository_shopping_list import RepositoryShoppingList
from .repository_users import RepositoryUsers from .repository_users import RepositoryUsers
pk_id = "id" pk_id = "id"
@ -176,3 +181,15 @@ class AllRepositories:
@cached_property @cached_property
def group_report_entries(self) -> RepositoryGeneric[ReportEntryOut, ReportEntryModel]: def group_report_entries(self) -> RepositoryGeneric[ReportEntryOut, ReportEntryModel]:
return RepositoryGeneric(self.session, pk_id, ReportEntryModel, ReportEntryOut) 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}) filter = self._filter_builder(**{match_key: match_value})
return self.session.query(self.sql_model).filter_by(**filter).one() 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 key = key or self.primary_key
q = self.session.query(self.sql_model) 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 fastapi import APIRouter
from . import ( from . import admin, app, auth, categories, comments, groups, parser, recipe, shared, tags, tools, unit_and_foods, users
admin,
app,
auth,
categories,
comments,
groups,
parser,
recipe,
shared,
shopping_lists,
tags,
tools,
unit_and_foods,
users,
)
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@ -31,5 +16,4 @@ router.include_router(unit_and_foods.router)
router.include_router(tools.router) router.include_router(tools.router)
router.include_router(categories.router) router.include_router(categories.router)
router.include_router(tags.router) router.include_router(tags.router)
router.include_router(shopping_lists.router)
router.include_router(admin.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.meal_service import MealService
from mealie.services.group_services.reports_service import GroupReportService 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() router = APIRouter()
@ -20,18 +20,18 @@ cookbook_router = RouterFactory(service=CookbookService, prefix="/groups/cookboo
@router.get("/groups/mealplans/today", tags=["Groups: Mealplans"]) @router.get("/groups/mealplans/today", tags=["Groups: Mealplans"])
def get_todays_meals(m_service: MealService = Depends(MealService.private)): def get_todays_meals(ms: MealService = Depends(MealService.private)):
return m_service.get_today() return ms.get_today()
meal_plan_router = RouterFactory(service=MealService, prefix="/groups/mealplans", tags=["Groups: Mealplans"]) meal_plan_router = RouterFactory(service=MealService, prefix="/groups/mealplans", tags=["Groups: Mealplans"])
@meal_plan_router.get("") @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) start = start or date.today() - timedelta(days=999)
limit = limit 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) router.include_router(cookbook_router)
@ -47,9 +47,12 @@ report_router = RouterFactory(service=GroupReportService, prefix="/groups/report
@report_router.get("") @report_router.get("")
def get_all_reports( 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(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 * 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 from pydantic import BaseModel
@ -6,7 +6,7 @@ T = TypeVar("T", bound=BaseModel)
U = TypeVar("U", 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. 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)) setattr(dest, field, getattr(source, field))
return dest 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 import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@ -92,32 +94,32 @@ class RecipeSummary(CamelModel):
orm_mode = True orm_mode = True
@validator("tags", always=True, pre=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): if isinstance(cats, list) and cats and isinstance(cats[0], str):
return [RecipeTag(name=c, slug=slugify(c)) for c in cats] return [RecipeTag(name=c, slug=slugify(c)) for c in cats]
return cats return cats
@validator("recipe_category", always=True, pre=True) @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): if isinstance(cats, list) and cats and isinstance(cats[0], str):
return [RecipeCategory(name=c, slug=slugify(c)) for c in cats] return [RecipeCategory(name=c, slug=slugify(c)) for c in cats]
return cats return cats
@validator("group_id", always=True, pre=True) @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): if isinstance(group_id, int):
return uuid4() return uuid4()
return group_id return group_id
@validator("user_id", always=True, pre=True) @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): if isinstance(user_id, int):
return uuid4() return uuid4()
return user_id return user_id
class Recipe(RecipeSummary): class Recipe(RecipeSummary):
recipe_ingredient: Optional[list[RecipeIngredient]] = [] recipe_ingredient: list[RecipeIngredient] = []
recipe_instructions: Optional[list[RecipeStep]] = [] recipe_instructions: Optional[list[RecipeStep]] = []
nutrition: Optional[Nutrition] nutrition: Optional[Nutrition]
@ -155,7 +157,7 @@ class Recipe(RecipeSummary):
orm_mode = True orm_mode = True
@classmethod @classmethod
def getter_dict(_cls, name_orm: RecipeModel): def getter_dict(cls, name_orm: RecipeModel):
return { return {
**GetterDict(name_orm), **GetterDict(name_orm),
# "recipe_ingredient": [x.note for x in name_orm.recipe_ingredient], # "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 from pydantic import BaseModel
class ErrorResponse(BaseModel): class ErrorResponse(BaseModel):
message: str message: str
error: bool = True 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.group.group_preferences import ReadGroupPreferences
from mealie.schema.recipe import RecipeSummary from mealie.schema.recipe import RecipeSummary
from ..meal_plan import ShoppingListOut
from ..recipe import CategoryBase from ..recipe import CategoryBase
settings = get_app_settings() settings = get_app_settings()
@ -148,7 +147,6 @@ class UpdateGroup(GroupBase):
class GroupInDB(UpdateGroup): class GroupInDB(UpdateGroup):
users: Optional[list[UserOut]] users: Optional[list[UserOut]]
shopping_lists: Optional[list[ShoppingListOut]]
preferences: Optional[ReadGroupPreferences] = None preferences: Optional[ReadGroupPreferences] = None
class Config: 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 __future__ import annotations
from typing import Callable from typing import Callable, Iterable
from mealie.core import root_logger from mealie.core import root_logger
@ -16,28 +16,35 @@ class SchedulerRegistry:
_hourly: list[Callable] = [] _hourly: list[Callable] = []
_minutely: 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: for cb in callback:
logger.info(f"Registering {name} callback: {cb.__name__}") logger.info(f"Registering {name} callback: {cb.__name__}")
callbacks.append(cb) callbacks.append(cb)
@staticmethod
def register_daily(*callbacks: Callable): def register_daily(*callbacks: Callable):
SchedulerRegistry._register("daily", SchedulerRegistry._daily, callbacks) SchedulerRegistry._register("daily", SchedulerRegistry._daily, callbacks)
@staticmethod
def remove_daily(callback: Callable): def remove_daily(callback: Callable):
logger.info(f"Removing daily callback: {callback.__name__}") logger.info(f"Removing daily callback: {callback.__name__}")
SchedulerRegistry._daily.remove(callback) SchedulerRegistry._daily.remove(callback)
@staticmethod
def register_hourly(*callbacks: Callable): def register_hourly(*callbacks: Callable):
SchedulerRegistry._register("daily", SchedulerRegistry._hourly, callbacks) SchedulerRegistry._register("daily", SchedulerRegistry._hourly, callbacks)
@staticmethod
def remove_hourly(callback: Callable): def remove_hourly(callback: Callable):
logger.info(f"Removing hourly callback: {callback.__name__}") logger.info(f"Removing hourly callback: {callback.__name__}")
SchedulerRegistry._hourly.remove(callback) SchedulerRegistry._hourly.remove(callback)
@staticmethod
def register_minutely(*callbacks: Callable): def register_minutely(*callbacks: Callable):
SchedulerRegistry._register("minutely", SchedulerRegistry._minutely, callbacks) SchedulerRegistry._register("minutely", SchedulerRegistry._minutely, callbacks)
@staticmethod
def remove_minutely(callback: Callable): def remove_minutely(callback: Callable):
logger.info(f"Removing minutely callback: {callback.__name__}") logger.info(f"Removing minutely callback: {callback.__name__}")
SchedulerRegistry._minutely.remove(callback) SchedulerRegistry._minutely.remove(callback)

View File

@ -1,10 +1,11 @@
from __future__ import annotations
from pathlib import Path from pathlib import Path
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from mealie.core import root_logger from mealie.core import root_logger
from mealie.core.config import get_app_dirs
from .scheduled_func import ScheduledFunc from .scheduled_func import ScheduledFunc
from .scheduler_registry import SchedulerRegistry from .scheduler_registry import SchedulerRegistry
@ -13,8 +14,6 @@ logger = root_logger.get_logger()
CWD = Path(__file__).parent CWD = Path(__file__).parent
app_dirs = get_app_dirs()
TEMP_DATA = app_dirs.DATA_DIR / ".temp"
SCHEDULER_DB = CWD / ".scheduler.db" SCHEDULER_DB = CWD / ".scheduler.db"
SCHEDULER_DATABASE = f"sqlite:///{SCHEDULER_DB}" SCHEDULER_DATABASE = f"sqlite:///{SCHEDULER_DB}"
@ -31,17 +30,13 @@ class SchedulerService:
SchedulerRegistry. See app.py for examples. SchedulerRegistry. See app.py for examples.
""" """
_scheduler: BackgroundScheduler = None _scheduler: BackgroundScheduler
# Not Sure if this is still needed?
# _job_store: dict[str, ScheduledFunc] = {}
@staticmethod
def start(): def start():
# Preclean # Preclean
SCHEDULER_DB.unlink(missing_ok=True) SCHEDULER_DB.unlink(missing_ok=True)
# Scaffold
TEMP_DATA.mkdir(parents=True, exist_ok=True)
# Register Interval Jobs and Start Scheduler # Register Interval Jobs and Start Scheduler
SchedulerService._scheduler = BackgroundScheduler(jobstores={"default": SQLAlchemyJobStore(SCHEDULER_DATABASE)}) SchedulerService._scheduler = BackgroundScheduler(jobstores={"default": SQLAlchemyJobStore(SCHEDULER_DATABASE)})
SchedulerService._scheduler.add_job(run_daily, "interval", minutes=MINUTES_DAY, id="Daily Interval Jobs") SchedulerService._scheduler.add_job(run_daily, "interval", minutes=MINUTES_DAY, id="Daily Interval Jobs")
@ -54,6 +49,7 @@ class SchedulerService:
def scheduler(cls) -> BackgroundScheduler: def scheduler(cls) -> BackgroundScheduler:
return SchedulerService._scheduler return SchedulerService._scheduler
@staticmethod
def add_cron_job(job_func: ScheduledFunc): def add_cron_job(job_func: ScheduledFunc):
SchedulerService.scheduler.add_job( SchedulerService.scheduler.add_job(
job_func.callback, job_func.callback,
@ -68,6 +64,7 @@ class SchedulerService:
# SchedulerService._job_store[job_func.id] = job_func # SchedulerService._job_store[job_func.id] = job_func
@staticmethod
def update_cron_job(job_func: ScheduledFunc): def update_cron_job(job_func: ScheduledFunc):
SchedulerService.scheduler.reschedule_job( SchedulerService.scheduler.reschedule_job(
job_func.id, job_func.id,

199
poetry.lock generated
View File

@ -14,6 +14,23 @@ category = "main"
optional = false optional = false
python-versions = "*" 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]] [[package]]
name = "appdirs" name = "appdirs"
version = "1.4.4" version = "1.4.4"
@ -336,21 +353,21 @@ cli = ["requests"]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.63.0" version = "0.71.0"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6.1"
[package.dependencies] [package.dependencies]
pydantic = ">=1.0.0,<2.0.0" 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.13.6" starlette = "0.17.1"
[package.extras] [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)"] 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.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)"] 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 (>=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)"] 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 (==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)"] 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]] [[package]]
name = "fastapi-camelcase" name = "fastapi-camelcase"
@ -842,7 +859,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "1.8.2" version = "1.9.0"
description = "Data validation and settings management using python 3.6 type hinting" description = "Data validation and settings management using python 3.6 type hinting"
category = "main" category = "main"
optional = false optional = false
@ -1219,6 +1236,14 @@ category = "main"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 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]] [[package]]
name = "soupsieve" name = "soupsieve"
version = "2.2.1" version = "2.2.1"
@ -1229,7 +1254,7 @@ python-versions = ">=3.6"
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "1.4.26" version = "1.4.29"
description = "Database Abstraction Library" description = "Database Abstraction Library"
category = "main" category = "main"
optional = false optional = false
@ -1261,14 +1286,17 @@ sqlcipher = ["sqlcipher3-binary"]
[[package]] [[package]]
name = "starlette" name = "starlette"
version = "0.13.6" version = "0.17.1"
description = "The little ASGI library that shines." description = "The little ASGI library that shines."
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
[package.dependencies]
anyio = ">=3.0.0,<4"
[package.extras] [package.extras]
full = ["aiofiles", "graphene", "itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests", "ujson"] full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests"]
[[package]] [[package]]
name = "text-unidecode" name = "text-unidecode"
@ -1446,7 +1474,7 @@ pgsql = ["psycopg2-binary"]
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.9" python-versions = "^3.9"
content-hash = "b2f08a33545224a00a1a3db706d5dea723f64ef04365f6e1929d3b3875e76932" content-hash = "eb1ef72becee98486ddf7fd709ca90f7e020cb85c567bd9add2d8be34c6c3533"
[metadata.files] [metadata.files]
aiofiles = [ aiofiles = [
@ -1457,6 +1485,10 @@ aniso8601 = [
{file = "aniso8601-7.0.0-py2.py3-none-any.whl", hash = "sha256:d10a4bf949f619f719b227ef5386e31f49a2b6d453004b21f02661ccc8670c7b"}, {file = "aniso8601-7.0.0-py2.py3-none-any.whl", hash = "sha256:d10a4bf949f619f719b227ef5386e31f49a2b6d453004b21f02661ccc8670c7b"},
{file = "aniso8601-7.0.0.tar.gz", hash = "sha256:513d2b6637b7853806ae79ffaca6f3e8754bdd547048f5ccc1420aec4b714f1e"}, {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 = [ appdirs = [
{file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
{file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, {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"}, {file = "extruct-0.13.0.tar.gz", hash = "sha256:50a5b5bac4c5e19ecf682bf63a28fde0b1bb57433df7057371f60b58c94a2c64"},
] ]
fastapi = [ fastapi = [
{file = "fastapi-0.63.0-py3-none-any.whl", hash = "sha256:98d8ea9591d8512fdadf255d2a8fa56515cdd8624dca4af369da73727409508e"}, {file = "fastapi-0.71.0-py3-none-any.whl", hash = "sha256:a78eca6b084de9667f2d5f37e2ae297270e5a119cd01c2f04815795da92fc87f"},
{file = "fastapi-0.63.0.tar.gz", hash = "sha256:63c4592f5ef3edf30afa9a44fa7c6b7ccb20e0d3f68cd9eba07b44d552058dcb"}, {file = "fastapi-0.71.0.tar.gz", hash = "sha256:2b5ac0ae89c80b40d1dd4b2ea0bb1f78d7c4affd3644d080bf050f084759fff2"},
] ]
fastapi-camelcase = [ fastapi-camelcase = [
{file = "fastapi_camelcase-1.0.3.tar.gz", hash = "sha256:260249df56bc6bc1e90452659ddd84be92b5e408636d1559ce22a8a1a6d8c5fe"}, {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"}, {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"},
] ]
pydantic = [ pydantic = [
{file = "pydantic-1.8.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739"}, {file = "pydantic-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb23bcc093697cdea2708baae4f9ba0e972960a835af22560f6ae4e7e47d33f5"},
{file = "pydantic-1.8.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4"}, {file = "pydantic-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1d5278bd9f0eee04a44c712982343103bba63507480bfd2fc2790fa70cd64cf4"},
{file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e"}, {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab624700dc145aa809e6f3ec93fb8e7d0f99d9023b713f6a953637429b437d37"},
{file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840"}, {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.8.2-cp36-cp36m-win_amd64.whl", hash = "sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b"}, {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3c3b035103bd4e2e4a28da9da7ef2fa47b00ee4a9cf4f1a735214c1bcd05e0f6"},
{file = "pydantic-1.8.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20"}, {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3011b975c973819883842c5ab925a4e4298dffccf7782c55ec3580ed17dc464c"},
{file = "pydantic-1.8.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb"}, {file = "pydantic-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:086254884d10d3ba16da0588604ffdc5aab3f7f09557b998373e885c690dd398"},
{file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1"}, {file = "pydantic-1.9.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0fe476769acaa7fcddd17cadd172b156b53546ec3614a4d880e5d29ea5fbce65"},
{file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23"}, {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8e9dcf1ac499679aceedac7e7ca6d8641f0193c591a2d090282aaf8e9445a46"},
{file = "pydantic-1.8.2-cp37-cp37m-win_amd64.whl", hash = "sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287"}, {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.8.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd"}, {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:c86229333cabaaa8c51cf971496f10318c4734cf7b641f08af0a6fbf17ca3054"},
{file = "pydantic-1.8.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505"}, {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:c0727bda6e38144d464daec31dff936a82917f431d9c39c39c60a26567eae3ed"},
{file = "pydantic-1.8.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e"}, {file = "pydantic-1.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:dee5ef83a76ac31ab0c78c10bd7d5437bfdb6358c95b91f1ba7ff7b76f9996a1"},
{file = "pydantic-1.8.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820"}, {file = "pydantic-1.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d9c9bdb3af48e242838f9f6e6127de9be7063aad17b32215ccc36a09c5cf1070"},
{file = "pydantic-1.8.2-cp38-cp38-win_amd64.whl", hash = "sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3"}, {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ee7e3209db1e468341ef41fe263eb655f67f5c5a76c924044314e139a1103a2"},
{file = "pydantic-1.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316"}, {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.8.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62"}, {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b2571db88c636d862b35090ccf92bf24004393f85c8870a37f42d9f23d13e032"},
{file = "pydantic-1.8.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f"}, {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8b5ac0f1c83d31b324e57a273da59197c83d1bb18171e512908fe5dc7278a1d6"},
{file = "pydantic-1.8.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b"}, {file = "pydantic-1.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:bbbc94d0c94dd80b3340fc4f04fd4d701f4b038ebad72c39693c794fd3bc2d9d"},
{file = "pydantic-1.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3"}, {file = "pydantic-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e0896200b6a40197405af18828da49f067c2fa1f821491bc8f5bde241ef3f7d7"},
{file = "pydantic-1.8.2-py3-none-any.whl", hash = "sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833"}, {file = "pydantic-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bdfdadb5994b44bd5579cfa7c9b0e1b0e540c952d56f627eb227851cda9db77"},
{file = "pydantic-1.8.2.tar.gz", hash = "sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b"}, {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 = [ pydantic-to-typescript = [
{file = "pydantic-to-typescript-1.0.7.tar.gz", hash = "sha256:dccf668e97626e616d20f2b1b99a568b5ac16344f3b2c850ebc463118b21a3d7"}, {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-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, {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 = [ soupsieve = [
{file = "soupsieve-2.2.1-py3-none-any.whl", hash = "sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b"}, {file = "soupsieve-2.2.1-py3-none-any.whl", hash = "sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b"},
{file = "soupsieve-2.2.1.tar.gz", hash = "sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc"}, {file = "soupsieve-2.2.1.tar.gz", hash = "sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc"},
] ]
sqlalchemy = [ sqlalchemy = [
{file = "SQLAlchemy-1.4.26-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:c2f2114b0968a280f94deeeaa31cfbac9175e6ac7bd3058b3ce6e054ecd762b3"}, {file = "SQLAlchemy-1.4.29-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:da64423c05256f4ab8c0058b90202053b201cbe3a081f3a43eb590cd554395ab"},
{file = "SQLAlchemy-1.4.26-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:91efbda4e6d311812f23996242bad7665c1392209554f8a31ec6db757456db5c"}, {file = "SQLAlchemy-1.4.29-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:0fc4eec2f46b40bdd42112b3be3fbbf88e194bcf02950fbb88bcdc1b32f07dc7"},
{file = "SQLAlchemy-1.4.26-cp27-cp27m-win32.whl", hash = "sha256:de996756d894a2d52c132742e3b6d64ecd37e0919ddadf4dc3981818777c7e67"}, {file = "SQLAlchemy-1.4.29-cp27-cp27m-win32.whl", hash = "sha256:101d2e100ba9182c9039699588e0b2d833c54b3bad46c67c192159876c9f27ea"},
{file = "SQLAlchemy-1.4.26-cp27-cp27m-win_amd64.whl", hash = "sha256:463ef692259ff8189be42223e433542347ae17e33f91c1013e9c5c64e2798088"}, {file = "SQLAlchemy-1.4.29-cp27-cp27m-win_amd64.whl", hash = "sha256:ceac84dd9abbbe115e8be0c817bed85d9fa639b4d294e7817f9e61162d5f766c"},
{file = "SQLAlchemy-1.4.26-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c757ba1279b85b3460e72e8b92239dae6f8b060a75fb24b3d9be984dd78cfa55"}, {file = "SQLAlchemy-1.4.29-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:15b65887b6c324cad638c7671cb95985817b733242a7eb69edd7cdf6953be1e0"},
{file = "SQLAlchemy-1.4.26-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:c24c01dcd03426a5fe5ee7af735906bec6084977b9027a3605d11d949a565c01"}, {file = "SQLAlchemy-1.4.29-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:78abc507d17753ed434b6cc0c0693126279723d5656d9775bfcac966a99a899b"},
{file = "SQLAlchemy-1.4.26-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c46f013ff31b80cbe36410281675e1fb4eaf3e25c284fd8a69981c73f6fa4cb4"}, {file = "SQLAlchemy-1.4.29-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb8c993706e86178ce15a6b86a335a2064f52254b640e7f53365e716423d33f4"},
{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.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.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.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.26-cp310-cp310-win32.whl", hash = "sha256:7ef421c3887b39c6f352e5022a53ac18de8387de331130481cb956b2d029cad6"}, {file = "SQLAlchemy-1.4.29-cp310-cp310-win32.whl", hash = "sha256:027f356c727db24f3c75828c7feb426f87ce1241242d08958e454bd025810660"},
{file = "SQLAlchemy-1.4.26-cp310-cp310-win_amd64.whl", hash = "sha256:908fad32c53b17aad12d722379150c3c5317c422437e44032256a77df1746292"}, {file = "SQLAlchemy-1.4.29-cp310-cp310-win_amd64.whl", hash = "sha256:debaf09a823061f88a8dee04949814cf7e82fb394c5bca22c780cb03172ca23b"},
{file = "SQLAlchemy-1.4.26-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:1ef37c9ec2015ce2f0dc1084514e197f2f199d3dc3514190db7620b78e6004c8"}, {file = "SQLAlchemy-1.4.29-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:dc27dcc6c72eb38be7f144e9c2c4372d35a3684d3a6dd43bd98c1238358ee17c"},
{file = "SQLAlchemy-1.4.26-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:090536fd23bf49077ee94ff97142bc5ee8bad24294c3d7c8d5284267c885dde7"}, {file = "SQLAlchemy-1.4.29-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4ddd4f2e247128c58bb3dd4489922874afce157d2cff0b2295d67fcd0f22494"},
{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.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.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.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.26-cp36-cp36m-win32.whl", hash = "sha256:cc6b21f19bc9d4cd77cbcba5f3b260436ce033f1053cea225b6efea2603d201e"}, {file = "SQLAlchemy-1.4.29-cp36-cp36m-win32.whl", hash = "sha256:886359f734b95ad1ef443b13bb4518bcade4db4f9553c9ce33d6d04ebda8d44e"},
{file = "SQLAlchemy-1.4.26-cp36-cp36m-win_amd64.whl", hash = "sha256:ba84026e84379326bbf2f0c50792f2ae56ab9c01937df5597b6893810b8ca369"}, {file = "SQLAlchemy-1.4.29-cp36-cp36m-win_amd64.whl", hash = "sha256:e9cc6d844e24c307c3272677982a9b33816aeb45e4977791c3bdd47637a8d810"},
{file = "SQLAlchemy-1.4.26-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:f1e97c5f36b94542f72917b62f3a2f92be914b2cf33b80fa69cede7529241d2a"}, {file = "SQLAlchemy-1.4.29-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:5e9cd33459afa69c88fa648e803d1f1245e3caa60bfe8b80a9595e5edd3bda9c"},
{file = "SQLAlchemy-1.4.26-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c185c928e2638af9bae13acc3f70e0096eac76471a1101a10f96b80666b8270"}, {file = "SQLAlchemy-1.4.29-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eeaebceb24b46e884c4ad3c04f37feb178b81f6ce720af19bfa2592ca32fdef7"},
{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.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.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.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.26-cp37-cp37m-win32.whl", hash = "sha256:a95bf9c725012dcd7ea3cac16bf647054e0d62b31d67467d228338e6a163e4ff"}, {file = "SQLAlchemy-1.4.29-cp37-cp37m-win32.whl", hash = "sha256:f74d6c05d2d163464adbdfbc1ab85048cc15462ff7d134b8aed22bd521e1faa5"},
{file = "SQLAlchemy-1.4.26-cp37-cp37m-win_amd64.whl", hash = "sha256:07ac4461a1116b317519ddf6f34bcb00b011b5c1370ebeaaf56595504ffc7e84"}, {file = "SQLAlchemy-1.4.29-cp37-cp37m-win_amd64.whl", hash = "sha256:621854dbb4d2413c759a5571564170de45ef37299df52e78e62b42e2880192e1"},
{file = "SQLAlchemy-1.4.26-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:5039faa365e7522a8eb4736a54afd24a7e75dcc33b81ab2f0e6c456140f1ad64"}, {file = "SQLAlchemy-1.4.29-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:f3909194751bb6cb7c5511dd18bcf77e6e3f0b31604ed4004dffa9461f71e737"},
{file = "SQLAlchemy-1.4.26-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7e8ef103eaa72a857746fd57dda5b8b5961e8e82a528a3f8b7e2884d8506f0b7"}, {file = "SQLAlchemy-1.4.29-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd49d21d1f03c81fbec9080ecdc4486d5ddda67e7fbb75ebf48294465c022cdc"},
{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.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.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.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.26-cp38-cp38-win32.whl", hash = "sha256:2ce42ad1f59eb85c55c44fb505f8854081ee23748f76b62a7f569cfa9b6d0604"}, {file = "SQLAlchemy-1.4.29-cp38-cp38-win32.whl", hash = "sha256:ad618d687d26d4cbfa9c6fa6141d59e05bcdfc60cb6e1f1d3baa18d8c62fef5f"},
{file = "SQLAlchemy-1.4.26-cp38-cp38-win_amd64.whl", hash = "sha256:dbf588ab09e522ac2cbd010919a592c6aae2f15ccc3cd9a96d01c42fbc13f63e"}, {file = "SQLAlchemy-1.4.29-cp38-cp38-win_amd64.whl", hash = "sha256:878daecb6405e786b07f97e1c77a9cfbbbec17432e8a90c487967e32cfdecb33"},
{file = "SQLAlchemy-1.4.26-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:a6506c17b0b6016656783232d0bdd03fd333f1f654d51a14d93223f953903646"}, {file = "SQLAlchemy-1.4.29-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:e027bdf0a4cf6bd0a3ad3b998643ea374d7991bd117b90bf9982e41ceb742941"},
{file = "SQLAlchemy-1.4.26-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a882dedb9dfa6f33524953c3e3d72bcf518a5defd6d5863150a821928b19ad3"}, {file = "SQLAlchemy-1.4.29-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5de7adfb91d351f44062b8dedf29f49d4af7cb765be65816e79223a4e31062b"},
{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.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.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.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.26-cp39-cp39-win32.whl", hash = "sha256:b86f762cee3709722ab4691981958cbec475ea43406a6916a7ec375db9cbd9e9"}, {file = "SQLAlchemy-1.4.29-cp39-cp39-win32.whl", hash = "sha256:37b46bfc4af3dc226acb6fa28ecd2e1fd223433dc5e15a2bad62bf0a0cbb4e8b"},
{file = "SQLAlchemy-1.4.26-cp39-cp39-win_amd64.whl", hash = "sha256:5c6774b34782116ad9bdec61c2dbce9faaca4b166a0bc8e7b03c2b870b121d94"}, {file = "SQLAlchemy-1.4.29-cp39-cp39-win_amd64.whl", hash = "sha256:08cfd35eecaba79be930c9bfd2e1f0c67a7e1314355d83a378f9a512b1cf7587"},
{file = "SQLAlchemy-1.4.26.tar.gz", hash = "sha256:6bc7f9d7d90ef55e8c6db1308a8619cd8f40e24a34f759119b95e7284dca351a"}, {file = "SQLAlchemy-1.4.29.tar.gz", hash = "sha256:fa2bad14e1474ba649cfc969c1d2ec915dd3e79677f346bbfe08e93ef9020b39"},
] ]
starlette = [ starlette = [
{file = "starlette-0.13.6-py3-none-any.whl", hash = "sha256:bd2ffe5e37fb75d014728511f8e68ebf2c80b0fa3d04ca1479f4dc752ae31ac9"}, {file = "starlette-0.17.1-py3-none-any.whl", hash = "sha256:26a18cbda5e6b651c964c12c88b36d9898481cd428ed6e063f5f29c418f73050"},
{file = "starlette-0.13.6.tar.gz", hash = "sha256:ebe8ee08d9be96a3c9f31b2cb2a24dbdf845247b745664bd8a3f9bd0c977fdbc"}, {file = "starlette-0.17.1.tar.gz", hash = "sha256:57eab3cc975a28af62f6faec94d355a410634940f10b30d68d31cb5ec1b44ae8"},
] ]
text-unidecode = [ text-unidecode = [
{file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"}, {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"},

View File

@ -13,10 +13,10 @@ python = "^3.9"
aiofiles = "0.5.0" aiofiles = "0.5.0"
aniso8601 = "7.0.0" aniso8601 = "7.0.0"
appdirs = "1.4.4" appdirs = "1.4.4"
fastapi = "^0.63.0" fastapi = "^0.71.0"
uvicorn = {extras = ["standard"], version = "^0.13.0"} uvicorn = {extras = ["standard"], version = "^0.13.0"}
APScheduler = "^3.6.3" APScheduler = "^3.6.3"
SQLAlchemy = "^1.3.22" SQLAlchemy = "^1.4.29"
Jinja2 = "^2.11.2" Jinja2 = "^2.11.2"
python-dotenv = "^0.15.0" python-dotenv = "^0.15.0"
python-slugify = "^4.0.1" python-slugify = "^4.0.1"
@ -38,6 +38,7 @@ gunicorn = "^20.1.0"
emails = "^0.6" emails = "^0.6"
python-i18n = "^0.3.9" python-i18n = "^0.3.9"
python-ldap = "^3.3.1" python-ldap = "^3.3.1"
pydantic = "^1.9.0"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pylint = "^2.6.0" pylint = "^2.6.0"