mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-19 05:09:40 +02:00
feat: ✨ (WIP) base-shoppinglist infra (#911)
* feat: ✨ base-shoppinglist infra (WIP) * add type checker * implement controllers * apply router fixes * add checked section hide/animation * add label support * formatting * fix overflow images * add experimental banner * fix #912 word break issue * remove any type errors * bump dependencies * remove templates * fix build errors * bump node version * fix template literal
This commit is contained in:
parent
86c99b10a2
commit
6db1357064
66 changed files with 3455 additions and 1311 deletions
2
.github/workflows/frontend-lint.yml
vendored
2
.github/workflows/frontend-lint.yml
vendored
|
@ -15,7 +15,7 @@ jobs:
|
|||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
node: [15]
|
||||
node: [16]
|
||||
|
||||
steps:
|
||||
- name: Checkout 🛎
|
||||
|
|
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
@ -42,6 +42,7 @@
|
|||
"python.testing.pytestArgs": ["tests"],
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.analysis.typeCheckingMode": "off",
|
||||
"search.mode": "reuseEditor",
|
||||
"vetur.validation.template": false,
|
||||
"python.sortImports.path": "${workspaceFolder}/.venv/bin/isort"
|
||||
|
|
|
@ -135,7 +135,6 @@ WORKDIR /
|
|||
|
||||
# copy frontend
|
||||
# COPY --from=frontend-build /app/dist $MEALIE_HOME/dist
|
||||
COPY ./dev/data/templates $MEALIE_HOME/data/templates
|
||||
COPY ./Caddyfile $MEALIE_HOME
|
||||
|
||||
# Grab CRF++ Model Release
|
||||
|
|
24
dev/data/templates/recipes.md
Normal file
24
dev/data/templates/recipes.md
Normal file
|
@ -0,0 +1,24 @@
|
|||
|
||||
|
||||

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