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

feature/finish-recipe-assets (#384)

* add features to readme

* Copy markdown reference

* prop as whole recipe

* parameter as url instead of query

* add card styling to editor

* move images to /recipes/{slug}/images

* add image to breaking changes

* fix delete and import errors

* fix debug/about response

* logger updates

* dashboard ui

* add server side events

* unorganized routes

* default slot

* add backup viewer to dashboard

* format

* add dialog to backup imports

* initial event support

* delete assets when removed

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-05-03 19:32:37 -08:00 committed by GitHub
parent f2d2b79a57
commit 5580d177c3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 1276 additions and 266 deletions

View file

@ -10,8 +10,8 @@
encode gzip encode gzip
uri strip_suffix / uri strip_suffix /
handle_path /api/recipes/image/* { handle_path /api/recipes/media/* {
root * /app/data/img/ root * /app/data/recipes/
file_server file_server
} }

View file

@ -57,14 +57,16 @@ Mealie is a self hosted recipe manager and meal planner with a RestAPI backend a
## Key Features ## Key Features
- 🔍 Fuzzy search - 🔍 Fuzzy search
- 🏷️ Tag recipes with categories or tags to flexible sorting - 🏷️ Tag recipes with categories or tags for flexible sorting
- 🕸 Import recipes from around the web by URL - 🕸 Import recipes from around the web by URL
- 💪 Powerful bulk Category/Tag assignment
- 📱 Beautiful Mobile Views - 📱 Beautiful Mobile Views
- 📆 Create Meal Plans - 📆 Create Meal Plans
- 🛒 Generate shopping lists - 🛒 Generate shopping lists
- 🐳 Easy setup with Docker - 🐳 Easy setup with Docker
- 🎨 Customize your interface with color themes layouts - 🎨 Customize your interface with color themes
- 💾 Export all your data in any format with Jinja2 Templates, with easy data restoration from the user interface. - 💾 Export all your data in any format with Jinja2 Templates
- 🔒 Keep your data safe with automated backup and easy restore options
- 🌍 localized in many languages - 🌍 localized in many languages
- Plus tons more! - Plus tons more!
- Flexible API - Flexible API

View file

@ -11,6 +11,9 @@
#### Database #### Database
Database version has been bumped from v0.4.x -> v0.5.0. You will need to export and import your data. Database version has been bumped from v0.4.x -> v0.5.0. You will need to export and import your data.
#### Image Directory
the /data/img directory has been depreciated. All images are now stored in the /recipes/{slug}/image directory. Images should be migrated automatically, but you may experience issues related to this change.
## Bug Fixes ## Bug Fixes
- Fixed #332 - Language settings are saved for one browser - Fixed #332 - Language settings are saved for one browser
- Fixes #281 - Slow Handling of Large Sets of Recipes - Fixes #281 - Slow Handling of Large Sets of Recipes

59
frontend/src/api/about.js Normal file
View file

@ -0,0 +1,59 @@
import { baseURL } from "./api-utils";
import { apiReq } from "./api-utils";
const prefix = baseURL + "about";
const aboutURLs = {
version: `${prefix}/version`,
debug: `${prefix}`,
lastRecipe: `${prefix}/last-recipe-json`,
demo: `${prefix}/is-demo`,
log: num => `${prefix}/log/${num}`,
statistics: `${prefix}/statistics`,
events: `${prefix}/events`,
event: id => `${prefix}/events/${id}`,
};
export const aboutAPI = {
async getEvents() {
const resposne = await apiReq.get(aboutURLs.events);
return resposne.data;
},
async deleteEvent(id) {
const resposne = await apiReq.delete(aboutURLs.event(id));
return resposne.data;
},
async deleteAllEvents() {
const resposne = await apiReq.delete(aboutURLs.events);
return resposne.data;
},
// async getAppInfo() {
// const response = await apiReq.get(aboutURLs.version);
// return response.data;
// },
// async getDebugInfo() {
// const response = await apiReq.get(aboutURLs.debug);
// return response.data;
// },
// async getLogText(num) {
// const response = await apiReq.get(aboutURLs.log(num));
// return response.data;
// },
// async getLastJson() {
// const response = await apiReq.get(aboutURLs.lastRecipe);
// return response.data;
// },
// async getIsDemo() {
// const response = await apiReq.get(aboutURLs.demo);
// return response.data;
// },
// async getStatistics() {
// const response = await apiReq.get(aboutURLs.statistics);
// return response.data;
// },
};

View file

@ -11,6 +11,7 @@ import { userAPI } from "./users";
import { signupAPI } from "./signUps"; import { signupAPI } from "./signUps";
import { groupAPI } from "./groups"; import { groupAPI } from "./groups";
import { siteSettingsAPI } from "./siteSettings"; import { siteSettingsAPI } from "./siteSettings";
import { aboutAPI } from "./about";
/** /**
* The main object namespace for interacting with the backend database * The main object namespace for interacting with the backend database
@ -30,4 +31,5 @@ export const api = {
users: userAPI, users: userAPI,
signUps: signupAPI, signUps: signupAPI,
groups: groupAPI, groups: groupAPI,
about: aboutAPI,
}; };

View file

@ -8,11 +8,13 @@ const debugURLs = {
debug: `${prefix}`, debug: `${prefix}`,
lastRecipe: `${prefix}/last-recipe-json`, lastRecipe: `${prefix}/last-recipe-json`,
demo: `${prefix}/is-demo`, demo: `${prefix}/is-demo`,
log: num => `${prefix}/log/${num}`,
statistics: `${prefix}/statistics`,
}; };
export const metaAPI = { export const metaAPI = {
async getAppInfo() { async getAppInfo() {
let response = await apiReq.get(debugURLs.version); const response = await apiReq.get(debugURLs.version);
return response.data; return response.data;
}, },
@ -21,13 +23,23 @@ export const metaAPI = {
return response.data; return response.data;
}, },
async getLogText(num) {
const response = await apiReq.get(debugURLs.log(num));
return response.data;
},
async getLastJson() { async getLastJson() {
let response = await apiReq.get(debugURLs.lastRecipe); const response = await apiReq.get(debugURLs.lastRecipe);
return response.data; return response.data;
}, },
async getIsDemo() { async getIsDemo() {
let response = await apiReq.get(debugURLs.demo); const response = await apiReq.get(debugURLs.demo);
return response.data;
},
async getStatistics() {
const response = await apiReq.get(debugURLs.statistics);
return response.data; return response.data;
}, },
}; };

View file

@ -14,9 +14,9 @@ const recipeURLs = {
recipe: slug => prefix + slug, recipe: slug => prefix + slug,
update: slug => prefix + slug, update: slug => prefix + slug,
delete: slug => prefix + slug, delete: slug => prefix + slug,
createAsset: slug => `${prefix}media/${slug}/assets`,
recipeImage: slug => `${prefix}${slug}/image`, recipeImage: slug => `${prefix}${slug}/image`,
updateImage: slug => `${prefix}${slug}/image`, updateImage: slug => `${prefix}${slug}/image`,
createAsset: slug => `${prefix}${slug}/asset`,
}; };
export const recipeAPI = { export const recipeAPI = {
@ -84,7 +84,7 @@ export const recipeAPI = {
fd.append("extension", fileObject.name.split(".").pop()); fd.append("extension", fileObject.name.split(".").pop());
fd.append("name", name); fd.append("name", name);
fd.append("icon", icon); fd.append("icon", icon);
let response = apiReq.post(recipeURLs.createAsset(recipeSlug), fd); const response = apiReq.post(recipeURLs.createAsset(recipeSlug), fd);
return response; return response;
}, },
@ -135,14 +135,14 @@ export const recipeAPI = {
}, },
recipeImage(recipeSlug) { recipeImage(recipeSlug) {
return `/api/recipes/image/${recipeSlug}/original.webp`; return `/api/recipes/media/${recipeSlug}/image/original.webp`;
}, },
recipeSmallImage(recipeSlug) { recipeSmallImage(recipeSlug) {
return `/api/recipes/image/${recipeSlug}/min-original.webp`; return `/api/recipes/media/${recipeSlug}/image/min-original.webp`;
}, },
recipeTinyImage(recipeSlug) { recipeTinyImage(recipeSlug) {
return `/api/recipes/image/${recipeSlug}/tiny-original.webp`; return `/api/recipes/media/${recipeSlug}/image/tiny-original.webp`;
}, },
}; };

View file

@ -18,15 +18,20 @@
v-if="!edit" v-if="!edit"
color="primary" color="primary"
icon icon
:href="`/api/recipes/${slug}/asset?file_name=${item.fileName}`" :href="`/api/recipes/media/${slug}/assets/${item.fileName}`"
target="_blank" target="_blank"
top top
> >
<v-icon> mdi-download</v-icon> <v-icon> mdi-download</v-icon>
</v-btn> </v-btn>
<v-btn v-else color="error" icon @click="deleteAsset(i)" top> <div v-else>
<v-icon>mdi-delete</v-icon> <v-btn color="error" icon @click="deleteAsset(i)" top>
</v-btn> <v-icon>mdi-delete</v-icon>
</v-btn>
<v-btn color="primary" icon @click="copyLink(item.name, item.fileName)" top>
<v-icon>mdi-content-copy</v-icon>
</v-btn>
</div>
</v-list-item-action> </v-list-item-action>
</v-list-item> </v-list-item>
</v-list> </v-list>
@ -107,6 +112,11 @@ export default {
], ],
}; };
}, },
computed: {
baseURL() {
return window.location.origin;
},
},
methods: { methods: {
setFileObject(obj) { setFileObject(obj) {
this.fileObject = obj; this.fileObject = obj;
@ -124,6 +134,13 @@ export default {
deleteAsset(index) { deleteAsset(index) {
this.value.splice(index, 1); this.value.splice(index, 1);
}, },
copyLink(name, fileName) {
const copyText = `![${name}](${this.baseURL}/api/recipes/media/${this.slug}/assets/${fileName})`;
navigator.clipboard.writeText(copyText).then(
() => console.log("Copied", copyText),
() => console.log("Copied Failed", copyText)
);
},
}, },
}; };
</script> </script>

View file

@ -27,23 +27,36 @@
<v-row> <v-row>
<v-col cols="12" sm="12" md="4" lg="4"> <v-col cols="12" sm="12" md="4" lg="4">
<Ingredients :edit="true" v-model="value.recipeIngredient" /> <Ingredients :edit="true" v-model="value.recipeIngredient" />
<v-card class="mt-6">
<v-card-title class="py-2">
{{ $t("recipe.categories") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<CategoryTagSelector
:return-object="false"
v-model="value.recipeCategory"
:show-add="true"
:show-label="false"
/>
</v-card-text>
</v-card>
<h2 class="mt-6">{{ $t("recipe.categories") }}</h2> <v-card class="mt-2">
<CategoryTagSelector <v-card-title class="py-2">
:return-object="false" {{ $t("tag.tags") }}
v-model="value.recipeCategory" </v-card-title>
:show-add="true" <v-divider class="mx-2"></v-divider>
:show-label="false" <v-card-text>
/> <CategoryTagSelector
:return-object="false"
<h2 class="mt-4">{{ $t("tag.tags") }}</h2> v-model="value.tags"
<CategoryTagSelector :show-add="true"
:return-object="false" :tag-selector="true"
v-model="value.tags" :show-label="false"
:show-add="true" />
:tag-selector="true" </v-card-text>
:show-label="false" </v-card>
/>
<Nutrition v-model="value.nutrition" :edit="true" /> <Nutrition v-model="value.nutrition" :edit="true" />
<Assets v-model="value.assets" :edit="true" :slug="value.slug" /> <Assets v-model="value.assets" :edit="true" :slug="value.slug" />
<ExtrasEditor :extras="value.extras" @save="saveExtras" /> <ExtrasEditor :extras="value.extras" @save="saveExtras" />

View file

@ -1,14 +1,14 @@
<template> <template>
<div> <div>
<v-card-title class="headline"> <v-card-title class="headline">
{{ name }} {{ recipe.name }}
</v-card-title> </v-card-title>
<v-card-text> <v-card-text>
<vue-markdown :source="description"> </vue-markdown> <vue-markdown :source="recipe.description"> </vue-markdown>
<v-row dense disabled> <v-row dense disabled>
<v-col> <v-col>
<v-btn <v-btn
v-if="yields" v-if="recipe.yields"
dense dense
small small
:hover="false" :hover="false"
@ -21,59 +21,59 @@
{{ yields }} {{ yields }}
</v-btn> </v-btn>
</v-col> </v-col>
<Rating :value="rating" :name="name" :slug="slug" /> <Rating :value="recipe.rating" :name="recipe.name" :slug="recipe.slug" />
</v-row> </v-row>
<v-row> <v-row>
<v-col cols="12" sm="12" md="4" lg="4"> <v-col cols="12" sm="12" md="4" lg="4">
<Ingredients :value="ingredients" :edit="false" /> <Ingredients :value="recipe.recipeIngredient" :edit="false" />
<div v-if="medium"> <div v-if="medium">
<v-card class="mt-2" v-if="categories.length > 0"> <v-card class="mt-2" v-if="recipe.recipeCategory.length > 0">
<v-card-title class="py-2"> <v-card-title class="py-2">
{{ $t("recipe.categories") }} {{ $t("recipe.categories") }}
</v-card-title> </v-card-title>
<v-divider class="mx-2"></v-divider> <v-divider class="mx-2"></v-divider>
<v-card-text> <v-card-text>
<RecipeChips :items="categories" /> <RecipeChips :items="recipe.recipeCategory" />
</v-card-text> </v-card-text>
</v-card> </v-card>
<v-card class="mt-2" v-if="tags.length > 0"> <v-card class="mt-2" v-if="recipe.tags.length > 0">
<v-card-title class="py-2"> <v-card-title class="py-2">
{{ $t("tag.tags") }} {{ $t("tag.tags") }}
</v-card-title> </v-card-title>
<v-divider class="mx-2"></v-divider> <v-divider class="mx-2"></v-divider>
<v-card-text> <v-card-text>
<RecipeChips :items="tags" :isCategory="false" /> <RecipeChips :items="recipe.tags" :isCategory="false" />
</v-card-text> </v-card-text>
</v-card> </v-card>
<Nutrition :value="nutrition" :edit="false" /> <Nutrition v-if="recipe.settings.showNutrition" :value="recipe.nutrition" :edit="false" />
<Assets :value="assets" :edit="false" :slug="slug" /> <Assets v-if="recipe.settings.showAssets" :value="recipe.assets" :edit="false" :slug="recipe.slug" />
</div> </div>
</v-col> </v-col>
<v-divider v-if="medium" class="my-divider" :vertical="true"></v-divider> <v-divider v-if="medium" class="my-divider" :vertical="true"></v-divider>
<v-col cols="12" sm="12" md="8" lg="8"> <v-col cols="12" sm="12" md="8" lg="8">
<Instructions :value="instructions" :edit="false" /> <Instructions :value="recipe.recipeInstructions" :edit="false" />
<Notes :value="notes" :edit="false" /> <Notes :value="recipe.notes" :edit="false" />
</v-col> </v-col>
</v-row> </v-row>
<div v-if="!medium"> <div v-if="!medium">
<RecipeChips :title="$t('recipe.categories')" :items="categories" /> <RecipeChips :title="$t('recipe.categories')" :items="recipe.recipeCategory" />
<RecipeChips :title="$t('tag.tags')" :items="tags" /> <RecipeChips :title="$t('tag.tags')" :items="recipe.tags" />
<Nutrition :value="nutrition" :edit="false" /> <Nutrition v-if="recipe.settings.showNutrition" :value="recipe.nutrition" :edit="false" />
<Assets :value="assets" :edit="false" :slug="slug" /> <Assets v-if="recipe.settings.showAssets" :value="recipe.assets" :edit="false" :slug="recipe.slug" />
</div> </div>
<v-row class="mt-2 mb-1"> <v-row class="mt-2 mb-1">
<v-col></v-col> <v-col></v-col>
<v-btn <v-btn
v-if="orgURL" v-if="recipe.orgURL"
dense dense
small small
:hover="false" :hover="false"
type="label" type="label"
:ripple="false" :ripple="false"
elevation="0" elevation="0"
:href="orgURL" :href="recipe.orgURL"
color="secondary darken-1" color="secondary darken-1"
target="_blank" target="_blank"
class="rounded-sm mr-4" class="rounded-sm mr-4"
@ -107,19 +107,7 @@ export default {
Rating, Rating,
}, },
props: { props: {
name: String, recipe: Object,
slug: String,
description: String,
ingredients: Array,
instructions: Array,
categories: Array,
tags: Array,
notes: Array,
rating: Number,
yields: String,
orgURL: String,
nutrition: Object,
assets: Array,
}, },
data() { data() {
return { return {

View file

@ -1,7 +1,11 @@
<template> <template>
<v-btn color="accent" text :loading="downloading" @click="downloadFile"> <div>
{{ showButtonText }} <slot v-bind="{ downloading, downloadFile }">
</v-btn> <v-btn color="accent" text :loading="downloading" @click="downloadFile">
{{ showButtonText }}
</v-btn>
</slot>
</div>
</template> </template>
<script> <script>

View file

@ -1,10 +1,12 @@
<template> <template>
<v-form ref="file"> <v-form ref="file">
<input ref="uploader" class="d-none" type="file" @change="onFileChanged" /> <input ref="uploader" class="d-none" type="file" @change="onFileChanged" />
<v-btn :loading="isSelecting" @click="onButtonClick" color="accent" :text="textBtn"> <slot v-bind="{ isSelecting, onButtonClick }">
<v-icon left> {{ icon }}</v-icon> <v-btn :loading="isSelecting" @click="onButtonClick" color="accent" :text="textBtn">
{{ text ? text : defaultText }} <v-icon left> {{ icon }}</v-icon>
</v-btn> {{ text ? text : defaultText }}
</v-btn>
</slot>
</v-form> </v-form>
</template> </template>
@ -25,7 +27,7 @@ export default {
default: true, default: true,
}, },
}, },
data: () => ({ data: () => ({
file: null, file: null,
isSelecting: false, isSelecting: false,
}), }),

View file

@ -0,0 +1,81 @@
<template>
<div class="mt-2">
<v-card>
<v-card-title class="headline">
Log
<v-spacer></v-spacer>
<v-text-field
class="ml-auto shrink mb-n7"
solo
label="Log Lines"
type="number"
append-icon="mdi-refresh-circle"
v-model="lines"
@click:append="getLogText"
suffix="lines"
single-line
>
</v-text-field>
<TheDownloadBtn :button-text="$t('about.download-log')" download-url="/api/debug/log">
<template v-slot:default="{ downloadFile }">
<v-btn bottom right relative fab icon color="primary" @click="downloadFile">
<v-icon> mdi-download </v-icon>
</v-btn>
</template>
</TheDownloadBtn>
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<div v-for="(item, index) in splitText" :key="index" :class="getClass(item)">
{{ item }}
</div>
</v-card-text>
</v-card>
</div>
</template>
<script>
import TheDownloadBtn from "@/components/UI/Buttons/TheDownloadBtn";
import { api } from "@/api";
export default {
components: { TheDownloadBtn },
data() {
return {
lines: 200,
text: "",
};
},
mounted() {
this.getLogText();
},
computed: {
splitText() {
return this.text.split("/n");
},
},
methods: {
async getLogText() {
this.text = await api.meta.getLogText(this.lines);
},
getClass(text) {
const isError = text.includes("ERROR:");
if (isError) {
return "log--error";
}
},
},
};
</script>
<style scoped>
.log-text {
background-color: #e0e0e077;
}
.log--error {
color: #ef5350;
}
.line-number {
color: black;
font-weight: bold;
}
</style>

View file

@ -147,6 +147,11 @@ export default {
}, },
adminLinks() { adminLinks() {
return [ return [
{
icon: "mdi-view-dashboard",
to: "/admin/dashboard",
title: this.$t("general.dashboard"),
},
{ {
icon: "mdi-cog", icon: "mdi-cog",
to: "/admin/settings", to: "/admin/settings",

View file

@ -9,5 +9,14 @@
"day": "numeric", "day": "numeric",
"weekday": "long", "weekday": "long",
"year": "numeric" "year": "numeric"
},
"long": {
"year": "numeric",
"month": "long",
"day": "numeric",
"weekday": "long",
"hour": "numeric",
"minute": "numeric",
"hour12": true
} }
} }

View file

@ -9,5 +9,14 @@
"day": "numeric", "day": "numeric",
"weekday": "long", "weekday": "long",
"year": "numeric" "year": "numeric"
},
"long": {
"year": "numeric",
"month": "long",
"day": "numeric",
"weekday": "long",
"hour": "numeric",
"minute": "numeric",
"hour12": true
} }
} }

View file

@ -14,10 +14,10 @@
"demo-status": "Demo Status", "demo-status": "Demo Status",
"development": "Development", "development": "Development",
"download-log": "Download Log", "download-log": "Download Log",
"download-recipe-json": "Download Recipe JSON", "download-recipe-json": "Last Scraped JSON",
"not-demo": "Not Demo", "not-demo": "Not Demo",
"production": "Production", "production": "Production",
"sqlite-file": "SQLite File", "database-url": "Database URL",
"version": "Version" "version": "Version"
}, },
"category": { "category": {
@ -27,7 +27,8 @@
"category-deletion-failed": "Category deletion failed", "category-deletion-failed": "Category deletion failed",
"category-filter": "Category Filter", "category-filter": "Category Filter",
"category-update-failed": "Category update failed", "category-update-failed": "Category update failed",
"category-updated": "Category updated" "category-updated": "Category updated",
"category": "Category"
}, },
"general": { "general": {
"apply": "Apply", "apply": "Apply",
@ -36,6 +37,7 @@
"confirm": "Confirm", "confirm": "Confirm",
"create": "Create", "create": "Create",
"current-parenthesis": "(Current)", "current-parenthesis": "(Current)",
"dashboard": "Dashboard",
"delete": "Delete", "delete": "Delete",
"disabled": "Disabled", "disabled": "Disabled",
"download": "Download", "download": "Download",

View file

@ -22,19 +22,26 @@
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<TheDownloadBtn :button-text="$t('about.download-recipe-json')" download-url="/api/debug/last-recipe-json" /> <TheDownloadBtn download-url="/api/debug/last-recipe-json">
<TheDownloadBtn :button-text="$t('about.download-log')" download-url="/api/debug/log" /> <template v-slot:default="{ downloadFile }">
<v-btn color="primary" @click="downloadFile">
<v-icon left> mdi-code-braces </v-icon> {{ $t("about.download-recipe-json") }}
</v-btn>
</template>
</TheDownloadBtn>
</v-card-actions> </v-card-actions>
<v-divider></v-divider> <v-divider></v-divider>
</v-card> </v-card>
<LogCard />
</div> </div>
</template> </template>
<script> <script>
import { api } from "@/api"; import { api } from "@/api";
import TheDownloadBtn from "@/components/UI/Buttons/TheDownloadBtn"; import TheDownloadBtn from "@/components/UI/Buttons/TheDownloadBtn";
import LogCard from "@/components/UI/LogCard.vue";
export default { export default {
components: { TheDownloadBtn }, components: { TheDownloadBtn, LogCard },
data() { data() {
return { return {
prettyInfo: [], prettyInfo: [],
@ -79,9 +86,9 @@ export default {
value: debugInfo.dbType, value: debugInfo.dbType,
}, },
{ {
name: this.$t("about.sqlite-file"), name: this.$t("about.database-url"),
icon: "mdi-file-cabinet", icon: "mdi-file-cabinet",
value: debugInfo.sqliteFile, value: debugInfo.dbUrl,
}, },
{ {
name: this.$t("about.default-group"), name: this.$t("about.default-group"),
@ -93,5 +100,3 @@ export default {
}, },
}; };
</script> </script>
<style lang="scss" scoped></style>

View file

@ -19,7 +19,7 @@
<div class="text-truncate"> <div class="text-truncate">
<strong>{{ backup.name }}</strong> <strong>{{ backup.name }}</strong>
</div> </div>
<div class="text-truncate">{{ $d(new Date(backup.date), "medium") }}</div> <div class="text-truncate">{{ $d(Date.parse(backup.date), "medium") }}</div>
</v-col> </v-col>
</v-row> </v-row>
</v-card-text> </v-card-text>

View file

@ -15,7 +15,7 @@
</v-toolbar-items> </v-toolbar-items>
</v-toolbar> </v-toolbar>
<v-card-title> {{ name }} </v-card-title> <v-card-title> {{ name }} </v-card-title>
<v-card-subtitle class="mb-n3"> {{ $d(new Date(date), "medium") }} </v-card-subtitle> <v-card-subtitle class="mb-n3" v-if="date"> {{ $d(new Date(date), "medium") }} </v-card-subtitle>
<v-divider></v-divider> <v-divider></v-divider>
<v-card-text> <v-card-text>

View file

@ -0,0 +1,144 @@
<template>
<div>
<ImportSummaryDialog ref="report" />
<ImportDialog
:name="selectedName"
:date="selectedDate"
ref="import_dialog"
@import="importBackup"
@delete="deleteBackup"
/>
<StatCard icon="mdi-backup-restore" :color="color">
<template v-slot:after-heading>
<div class="ml-auto text-right">
<div class="body-3 grey--text font-weight-light" v-text="'Backups'" />
<h3 class="display-2 font-weight-light text--primary">
<small> {{ total }}</small>
</h3>
</div>
</template>
<div class="d-flex row py-3 justify-end">
<TheUploadBtn url="/api/backups/upload" @uploaded="getAvailableBackups">
<template v-slot="{ isSelecting, onButtonClick }">
<v-btn :loading="isSelecting" class="mx-2" small :color="color" @click="onButtonClick">
<v-icon left> mdi-cloud-upload </v-icon> Upload
</v-btn>
</template>
</TheUploadBtn>
<v-btn :loading="loading" class="mx-2" small :color="color" @click="createBackup">
<v-icon left> mdi-plus </v-icon> Create
</v-btn>
</div>
<template v-slot:bottom>
<v-virtual-scroll height="290" item-height="70" :items="availableBackups">
<template v-slot:default="{ item }">
<v-list-item @click.prevent="openDialog(item)">
<v-list-item-avatar>
<v-icon large dark :color="color">
mdi-backup-restore
</v-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title v-text="item.name"></v-list-item-title>
<v-list-item-subtitle>
{{ $d(Date.parse(item.date), "medium") }}
</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action class="ml-auto">
<v-btn large icon @click.stop="deleteBackup(item.name)">
<v-icon color="error">mdi-delete</v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>
</template>
</v-virtual-scroll>
</template>
</StatCard>
</div>
</template>
<script>
import TheUploadBtn from "@/components/UI/Buttons/TheUploadBtn";
import ImportSummaryDialog from "@/components/ImportSummaryDialog";
import { api } from "@/api";
import StatCard from "./StatCard";
import ImportDialog from "../Backup/ImportDialog";
export default {
components: { StatCard, ImportDialog, TheUploadBtn, ImportSummaryDialog },
data() {
return {
color: "secondary",
selectedName: "",
selectedDate: "",
loading: false,
events: [],
availableBackups: [],
};
},
computed: {
total() {
return this.availableBackups.length;
},
},
mounted() {
this.getAvailableBackups();
},
methods: {
async getAvailableBackups() {
const response = await api.backups.requestAvailable();
this.availableBackups = response.imports;
console.log(this.availableBackups);
},
async deleteBackup(name) {
this.loading = true;
await api.backups.delete(name);
this.loading = false;
this.getAvailableBackups();
},
openDialog(backup) {
this.selectedDate = backup.date;
this.selectedName = backup.name;
this.$refs.import_dialog.open();
},
async importBackup(data) {
this.loading = true;
const response = await api.backups.import(data.name, data);
if (response) {
const importData = response.data;
this.$refs.report.open(importData);
}
this.loading = false;
},
async createBackup() {
this.loading = true;
let data = {
tag: this.tag,
options: {
recipes: true,
settings: true,
themes: true,
users: true,
groups: true,
},
templates: [],
};
if (await api.backups.create(data)) {
this.getAvailableBackups();
}
this.loading = false;
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,110 @@
<template>
<div>
<StatCard icon="mdi-bell-ring" :color="color">
<template v-slot:after-heading>
<div class="ml-auto text-right">
<div class="body-3 grey--text font-weight-light" v-text="'Events'" />
<h3 class="display-2 font-weight-light text--primary">
<small> {{ total }} </small>
</h3>
</div>
</template>
<div class="d-flex row py-3 justify-end">
<v-btn class="mx-2" small :color="color" @click="deleteAll">
<v-icon left> mdi-notification-clear-all </v-icon> Clear
</v-btn>
</div>
<template v-slot:bottom>
<v-virtual-scroll height="290" item-height="70" :items="events">
<template v-slot:default="{ item }">
<v-list-item>
<v-list-item-avatar>
<v-icon large dark :color="icons[item.category].color">
{{ icons[item.category].icon }}
</v-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title v-text="item.title"></v-list-item-title>
<v-list-item-subtitle v-text="item.text"></v-list-item-subtitle>
<v-list-item-subtitle>
{{ $d(Date.parse(item.timeStamp), "long") }}
</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action class="ml-auto">
<v-btn large icon @click="deleteEvent(item.id)">
<v-icon color="error">mdi-delete</v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>
</template>
</v-virtual-scroll>
</template>
</StatCard>
</div>
</template>
<script>
import { api } from "@/api";
import StatCard from "./StatCard";
export default {
components: { StatCard },
data() {
return {
color: "secondary",
total: 0,
events: [],
icons: {
general: {
icon: "mdi-information",
color: "info",
},
recipe: {
icon: "mdi-silverware-fork-knife",
color: "primary",
},
backup: {
icon: "mdi-backup-restore",
color: "primary",
},
schedule: {
icon: "mdi-calendar-clock",
color: "primary",
},
migration: {
icon: "mdi-database-import",
color: "primary",
},
signup: {
icon: "mdi-account",
color: "primary",
},
},
};
},
mounted() {
this.getEvents();
},
methods: {
async getEvents() {
const events = await api.about.getEvents();
this.events = events.events;
this.total = events.total;
},
async deleteEvent(id) {
await api.about.deleteEvent(id);
this.getEvents();
},
async deleteAll() {
await api.about.deleteAllEvents();
this.getEvents();
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,100 @@
w<template>
<v-card v-bind="$attrs" :class="classes" class="v-card--material pa-3">
<div class="d-flex grow flex-wrap">
<v-sheet
:color="color"
:max-height="icon ? 90 : undefined"
:width="icon ? 'auto' : '100%'"
elevation="6"
class="text-start v-card--material__heading mb-n6 mt-n10 pa-7"
dark
>
<v-icon v-if="icon" size="40" v-text="icon" />
<div v-if="text" class="headline font-weight-thin" v-text="text" />
</v-sheet>
<div v-if="$slots['after-heading']" class="ml-auto">
<slot name="after-heading" />
</div>
</div>
<slot />
<template v-if="$slots.actions">
<v-divider class="mt-2" />
<v-card-actions class="pb-0">
<slot name="actions" />
</v-card-actions>
</template>
<template v-if="$slots.bottom">
<v-divider class="mt-2" />
<div class="pb-0">
<slot name="bottom" />
</div>
</template>
</v-card>
</template>
<script>
export default {
name: "MaterialCard",
props: {
avatar: {
type: String,
default: "",
},
color: {
type: String,
default: "primary",
},
icon: {
type: String,
default: undefined,
},
image: {
type: Boolean,
default: false,
},
text: {
type: String,
default: "",
},
title: {
type: String,
default: "",
},
},
computed: {
classes() {
return {
"v-card--material--has-heading": this.hasHeading,
};
},
hasHeading() {
return false;
},
hasAltHeading() {
return false;
},
},
};
</script>
<style lang="sass">
.v-card--material
&__avatar
position: relative
top: -64px
margin-bottom: -32px
&__heading
position: relative
top: -40px
transition: .3s ease
z-index: 1
</style>

View file

@ -0,0 +1,119 @@
<template>
<div class="mt-10">
<v-row>
<v-col cols="12" sm="12" md="4">
<StatCard icon="mdi-silverware-fork-knife">
<template v-slot:after-heading>
<div class="ml-auto text-right">
<div class="body-3 grey--text font-weight-light" v-text="'Recipes'" />
<h3 class="display-2 font-weight-light text--primary">
<small> {{ statistics.totalRecipes }}</small>
</h3>
</div>
</template>
<template v-slot:actions>
<div class="d-flex row py-3 justify-space-around">
<v-btn small color="primary" :to="{ path: '/admin/toolbox/', query: { tab: 'organize', filter: 'tag' } }">
<v-icon left> mdi-tag </v-icon> Untagged {{ statistics.untaggedRecipes }}
</v-btn>
<v-btn
small
color="primary"
:to="{ path: '/admin/toolbox/', query: { tab: 'organize', filter: 'category' } }"
>
<v-icon left> mdi-tag </v-icon> Uncategorized {{ statistics.uncategorizedRecipes }}
</v-btn>
</div>
</template>
</StatCard>
</v-col>
<v-col cols="12" sm="12" md="4">
<StatCard icon="mdi-account">
<template v-slot:after-heading>
<div class="ml-auto text-right">
<div class="body-3 grey--text font-weight-light" v-text="'Users'" />
<h3 class="display-2 font-weight-light text--primary">
<small> {{ statistics.totalUsers }}</small>
</h3>
</div>
</template>
<template v-slot:actions>
<div class="ml-auto">
<v-btn color="primary" small to="/admin/manage-users?tab=users">
<v-icon left>mdi-account</v-icon>
Manage Users
</v-btn>
</div>
</template>
</StatCard>
</v-col>
<v-col cols="12" sm="12" md="4">
<StatCard icon="mdi-account-group">
<template v-slot:after-heading>
<div class="ml-auto text-right">
<div class="body-3 grey--text font-weight-light" v-text="'Groups'" />
<h3 class="display-2 font-weight-light text--primary">
<small> {{ statistics.totalGroups }}</small>
</h3>
</div>
</template>
<template v-slot:actions>
<div class="ml-auto">
<v-btn color="primary" small to="/admin/manage-users?tab=groups">
<v-icon left>mdi-account-group</v-icon>
Manage Groups
</v-btn>
</div>
</template>
</StatCard>
</v-col>
</v-row>
<v-row class="mt-10">
<v-col cols="12" sm="12" lg="6">
<EventViewer />
</v-col>
<v-col cols="12" sm="12" lg="6"> <BackupViewer /> </v-col>
</v-row>
</div>
</template>
<script>
import { api } from "@/api";
import StatCard from "./StatCard";
import EventViewer from "./EventViewer";
import BackupViewer from "./BackupViewer";
export default {
components: { StatCard, EventViewer, BackupViewer },
data() {
return {
statistics: {
totalGroups: 0,
totalRecipes: 0,
totalUsers: 0,
uncategorizedRecipes: 0,
untaggedRecipes: 0,
},
};
},
mounted() {
this.getStatistics();
},
methods: {
async getStatistics() {
this.statistics = await api.meta.getStatistics();
},
},
};
</script>
<style>
.grid-style {
flex-grow: inherit;
display: inline-flex;
flex-wrap: wrap;
gap: 10px;
}
</style>

View file

@ -160,10 +160,10 @@ export default {
methods: { methods: {
updateClipboard(newClip) { updateClipboard(newClip) {
navigator.clipboard.writeText(newClip).then( navigator.clipboard.writeText(newClip).then(
function() { () => {
console.log("Copied", newClip); console.log("Copied", newClip);
}, },
function() { () => {
console.log("Copy Failed", newClip); console.log("Copy Failed", newClip);
} }
); );

View file

@ -4,30 +4,30 @@
<v-tabs v-model="tab" background-color="primary" centered dark icons-and-text> <v-tabs v-model="tab" background-color="primary" centered dark icons-and-text>
<v-tabs-slider></v-tabs-slider> <v-tabs-slider></v-tabs-slider>
<v-tab> <v-tab href="#users">
{{ $t("user.users") }} {{ $t("user.users") }}
<v-icon>mdi-account</v-icon> <v-icon>mdi-account</v-icon>
</v-tab> </v-tab>
<v-tab> <v-tab href="#sign-ups">
{{ $t("signup.sign-up-links") }} {{ $t("signup.sign-up-links") }}
<v-icon>mdi-account-plus-outline</v-icon> <v-icon>mdi-account-plus-outline</v-icon>
</v-tab> </v-tab>
<v-tab> <v-tab href="#groups">
{{ $t("group.groups") }} {{ $t("group.groups") }}
<v-icon>mdi-account-group</v-icon> <v-icon>mdi-account-group</v-icon>
</v-tab> </v-tab>
</v-tabs> </v-tabs>
<v-tabs-items v-model="tab"> <v-tabs-items v-model="tab">
<v-tab-item> <v-tab-item value="users">
<TheUserTable /> <TheUserTable />
</v-tab-item> </v-tab-item>
<v-tab-item> <v-tab-item value="sign-ups">
<TheSignUpTable /> <TheSignUpTable />
</v-tab-item> </v-tab-item>
<v-tab-item> <v-tab-item value="groups">
<GroupDashboard /> <GroupDashboard />
</v-tab-item> </v-tab-item>
</v-tabs-items> </v-tabs-items>
@ -42,9 +42,17 @@ import TheSignUpTable from "./TheSignUpTable";
export default { export default {
components: { TheUserTable, GroupDashboard, TheSignUpTable }, components: { TheUserTable, GroupDashboard, TheSignUpTable },
data() { data() {
return { return {};
tab: 0, },
}; computed: {
tab: {
set(tab) {
this.$router.replace({ query: { ...this.$route.query, tab } });
},
get() {
return this.$route.query.tab;
},
},
}, },
mounted() { mounted() {
this.$store.dispatch("requestAllGroups"); this.$store.dispatch("requestAllGroups");

View file

@ -0,0 +1,72 @@
<template>
<v-card outlined class="mt-n1">
<div class="d-flex justify-center align-center pa-2 flex-wrap">
<v-btn-toggle v-model="filter" mandatory color="primary">
<v-btn small value="category">
<v-icon>mdi-tag-multiple</v-icon>
{{ $t("category.category") }}
</v-btn>
<v-btn small value="tag">
<v-icon>mdi-tag-multiple</v-icon>
{{ $t("tag.tags") }}
</v-btn>
</v-btn-toggle>
<v-spacer v-if="!isMobile"> </v-spacer>
<FuseSearchBar :raw-data="allItems" @results="filterItems" :search="searchString">
<v-text-field
v-model="searchString"
clearable
solo
dense
class="mx-2"
hide-details
single-line
:placeholder="$t('search.search')"
prepend-inner-icon="mdi-magnify"
>
</v-text-field>
</FuseSearchBar>
</div>
</v-card>
</template>
<script>
import FuseSearchBar from "@/components/UI/Search/FuseSearchBar";
export default {
components: { FuseSearchBar },
data() {
return {
buttonToggle: 0,
allItems: [],
searchString: "",
searchResults: [],
};
},
computed: {
isMobile() {
return this.$vuetify.breakpoint.name === "xs";
},
isCategory() {
return this.buttonToggle === 0;
},
filter: {
set(filter) {
this.$router.replace({ query: { ...this.$route.query, filter } });
},
get() {
return this.$route.query.filter;
},
},
},
methods: {
filterItems(val) {
this.searchResults = val.map(x => x.item);
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -4,20 +4,25 @@
<v-tabs v-model="tab" background-color="primary" centered dark icons-and-text> <v-tabs v-model="tab" background-color="primary" centered dark icons-and-text>
<v-tabs-slider></v-tabs-slider> <v-tabs-slider></v-tabs-slider>
<v-tab> <v-tab href="#category-editor">
{{ $t("recipe.categories") }} {{ $t("recipe.categories") }}
<v-icon>mdi-tag-multiple-outline</v-icon> <v-icon>mdi-tag-multiple-outline</v-icon>
</v-tab> </v-tab>
<v-tab> <v-tab href="#tag-editor">
{{ $t("tag.tags") }} {{ $t("tag.tags") }}
<v-icon>mdi-tag-multiple-outline</v-icon> <v-icon>mdi-tag-multiple-outline</v-icon>
</v-tab> </v-tab>
<v-tab href="#organize">
Organize
<v-icon>mdi-broom</v-icon>
</v-tab>
</v-tabs> </v-tabs>
<v-tabs-items v-model="tab"> <v-tabs-items v-model="tab">
<v-tab-item><CategoryTagEditor :is-tags="false"/></v-tab-item> <v-tab-item value="category-editor"> <CategoryTagEditor :is-tags="false"/></v-tab-item>
<v-tab-item><CategoryTagEditor :is-tags="true" /> </v-tab-item> <v-tab-item value="tag-editor"> <CategoryTagEditor :is-tags="true" /> </v-tab-item>
<v-tab-item value="organize"> <RecipeOrganizer :is-tags="true" /> </v-tab-item>
</v-tabs-items> </v-tabs-items>
</v-card> </v-card>
</div> </div>
@ -25,14 +30,24 @@
<script> <script>
import CategoryTagEditor from "./CategoryTagEditor"; import CategoryTagEditor from "./CategoryTagEditor";
import RecipeOrganizer from "./RecipeOrganizer";
export default { export default {
components: { components: {
CategoryTagEditor, CategoryTagEditor,
RecipeOrganizer,
},
computed: {
tab: {
set(tab) {
this.$router.replace({ query: { ...this.$route.query, tab } });
},
get() {
return this.$route.query.tab;
},
},
}, },
data() { data() {
return { return {};
tab: 0,
};
}, },
}; };
</script> </script>

View file

@ -37,7 +37,6 @@ export default {
return this.$store.getters.getSiteSettings; return this.$store.getters.getSiteSettings;
}, },
recentRecipes() { recentRecipes() {
console.log("Recent Recipes");
return this.$store.getters.getRecentRecipes; return this.$store.getters.getRecentRecipes;
}, },
}, },

View file

@ -25,22 +25,7 @@
class="sticky" class="sticky"
/> />
<RecipeViewer <RecipeViewer v-if="!form" :recipe="recipeDetails" />
v-if="!form"
:name="recipeDetails.name"
:ingredients="recipeDetails.recipeIngredient"
:description="recipeDetails.description"
:instructions="recipeDetails.recipeInstructions"
:tags="recipeDetails.tags"
:categories="recipeDetails.recipeCategory"
:notes="recipeDetails.notes"
:rating="recipeDetails.rating"
:yields="recipeDetails.recipeYield"
:orgURL="recipeDetails.orgURL"
:nutrition="recipeDetails.nutrition"
:assets="recipeDetails.assets"
:slug="recipeDetails.slug"
/>
<VJsoneditor <VJsoneditor
@error="logError()" @error="logError()"
class="mt-10" class="mt-10"

View file

@ -8,6 +8,7 @@ import ManageUsers from "@/pages/Admin/ManageUsers";
import Settings from "@/pages/Admin/Settings"; import Settings from "@/pages/Admin/Settings";
import About from "@/pages/Admin/About"; import About from "@/pages/Admin/About";
import ToolBox from "@/pages/Admin/ToolBox"; import ToolBox from "@/pages/Admin/ToolBox";
import Dashboard from "@/pages/Admin/Dashboard";
import { store } from "../store"; import { store } from "../store";
export const adminRoutes = { export const adminRoutes = {
@ -87,5 +88,12 @@ export const adminRoutes = {
title: "general.about", title: "general.about",
}, },
}, },
{
path: "dashboard",
component: Dashboard,
meta: {
title: "general.dashboard",
},
},
], ],
}; };

View file

@ -4,11 +4,13 @@ from fastapi import FastAPI
from mealie.core import root_logger from mealie.core import root_logger
from mealie.core.config import APP_VERSION, settings from mealie.core.config import APP_VERSION, settings
from mealie.routes import backup_routes, debug_routes, migration_routes, theme_routes, utility_routes from mealie.routes import backup_routes, debug_routes, migration_routes, theme_routes, utility_routes
from mealie.routes.about import about_router
from mealie.routes.groups import groups from mealie.routes.groups import groups
from mealie.routes.mealplans import mealplans from mealie.routes.mealplans import mealplans
from mealie.routes.recipe import router as recipe_router from mealie.routes.recipe import router as recipe_router
from mealie.routes.site_settings import all_settings from mealie.routes.site_settings import all_settings
from mealie.routes.users import users from mealie.routes.users import users
from mealie.services.events import create_general_event
logger = root_logger.get_logger() logger = root_logger.get_logger()
@ -31,6 +33,7 @@ def api_routers():
app.include_router(groups.router) app.include_router(groups.router)
# Recipes # Recipes
app.include_router(recipe_router) app.include_router(recipe_router)
app.include_router(about_router)
# Meal Routes # Meal Routes
app.include_router(mealplans.router) app.include_router(mealplans.router)
# Settings Routes # Settings Routes
@ -53,6 +56,7 @@ def system_startup():
logger.info("-----SYSTEM STARTUP----- \n") logger.info("-----SYSTEM STARTUP----- \n")
logger.info("------APP SETTINGS------") logger.info("------APP SETTINGS------")
logger.info(settings.json(indent=4, exclude={"SECRET", "DEFAULT_PASSWORD", "SFTP_PASSWORD", "SFTP_USERNAME"})) logger.info(settings.json(indent=4, exclude={"SECRET", "DEFAULT_PASSWORD", "SFTP_PASSWORD", "SFTP_USERNAME"}))
create_general_event("Application Startup", f"Mealie API started on port {settings.API_PORT}")
def main(): def main():

View file

@ -4,8 +4,8 @@ import sys
from mealie.core.config import DATA_DIR from mealie.core.config import DATA_DIR
LOGGER_FILE = DATA_DIR.joinpath("mealie.log") LOGGER_FILE = DATA_DIR.joinpath("mealie.log")
LOGGER_FORMAT = "%(levelname)s: \t%(message)s"
DATE_FORMAT = "%d-%b-%y %H:%M:%S" DATE_FORMAT = "%d-%b-%y %H:%M:%S"
LOGGER_FORMAT = "%(levelname)s: %(asctime)s \t%(message)s"
logging.basicConfig(level=logging.INFO, format=LOGGER_FORMAT, datefmt="%d-%b-%y %H:%M:%S") logging.basicConfig(level=logging.INFO, format=LOGGER_FORMAT, datefmt="%d-%b-%y %H:%M:%S")
@ -30,6 +30,9 @@ def logger_init() -> logging.Logger:
return logger return logger
root_logger = logger_init()
def get_logger(module=None) -> logging.Logger: def get_logger(module=None) -> logging.Logger:
""" Returns a child logger for mealie """ """ Returns a child logger for mealie """
global root_logger global root_logger
@ -38,6 +41,3 @@ def get_logger(module=None) -> logging.Logger:
return root_logger return root_logger
return root_logger.getChild(module) return root_logger.getChild(module)
root_logger = logger_init()

View file

@ -1,6 +1,7 @@
from logging import getLogger from logging import getLogger
from mealie.db.db_base import BaseDocument from mealie.db.db_base import BaseDocument
from mealie.db.models.event import Event
from mealie.db.models.group import Group from mealie.db.models.group import Group
from mealie.db.models.mealplan import MealPlanModel from mealie.db.models.mealplan import MealPlanModel
from mealie.db.models.recipe.recipe import Category, RecipeModel, Tag from mealie.db.models.recipe.recipe import Category, RecipeModel, Tag
@ -9,6 +10,7 @@ from mealie.db.models.sign_up import SignUp
from mealie.db.models.theme import SiteThemeModel from mealie.db.models.theme import SiteThemeModel
from mealie.db.models.users import User from mealie.db.models.users import User
from mealie.schema.category import RecipeCategoryResponse, RecipeTagResponse from mealie.schema.category import RecipeCategoryResponse, RecipeTagResponse
from mealie.schema.events import Event as EventSchema
from mealie.schema.meal import MealPlanInDB from mealie.schema.meal import MealPlanInDB
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from mealie.schema.settings import CustomPageOut from mealie.schema.settings import CustomPageOut
@ -18,7 +20,6 @@ from mealie.schema.theme import SiteTheme
from mealie.schema.user import GroupInDB, UserInDB from mealie.schema.user import GroupInDB, UserInDB
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
logger = getLogger() logger = getLogger()
@ -35,6 +36,26 @@ class _Recipes(BaseDocument):
return f"{slug}.{extension}" return f"{slug}.{extension}"
def count_uncategorized(self, session: Session, count=True, override_schema=None) -> int:
eff_schema = override_schema or self.schema
if count:
return session.query(self.sql_model).filter(RecipeModel.recipe_category == None).count() # noqa: 711
else:
return [
eff_schema.from_orm(x)
for x in session.query(self.sql_model).filter(RecipeModel.tags == None).all() # noqa: 711
]
def count_untagged(self, session: Session, count=True, override_schema=None) -> int:
eff_schema = override_schema or self.schema
if count:
return session.query(self.sql_model).filter(RecipeModel.tags == None).count() # noqa: 711
else:
return [
eff_schema.from_orm(x)
for x in session.query(self.sql_model).filter(RecipeModel.tags == None).all() # noqa: 711
]
class _Categories(BaseDocument): class _Categories(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
@ -110,8 +131,6 @@ class _Groups(BaseDocument):
""" """
group: GroupInDB = session.query(self.sql_model).filter_by(**{match_key: match_value}).one_or_none() group: GroupInDB = session.query(self.sql_model).filter_by(**{match_key: match_value}).one_or_none()
# Potentially not needed? column is sorted by SqlAlchemy based on startDate
# return sorted(group.mealplans, key=lambda mealplan: mealplan.startDate)
return group.mealplans return group.mealplans
@ -129,6 +148,13 @@ class _CustomPages(BaseDocument):
self.schema = CustomPageOut self.schema = CustomPageOut
class _Events(BaseDocument):
def __init__(self) -> None:
self.primary_key = "id"
self.sql_model = Event
self.schema = EventSchema
class Database: class Database:
def __init__(self) -> None: def __init__(self) -> None:
self.recipes = _Recipes() self.recipes = _Recipes()
@ -141,6 +167,7 @@ class Database:
self.sign_ups = _SignUps() self.sign_ups = _SignUps()
self.groups = _Groups() self.groups = _Groups()
self.custom_pages = _CustomPages() self.custom_pages = _CustomPages()
self.events = _Events()
db = Database() db = Database()

View file

@ -23,6 +23,14 @@ class BaseDocument:
) -> List[dict]: ) -> List[dict]:
eff_schema = override_schema or self.schema eff_schema = override_schema or self.schema
if order_by:
order_attr = getattr(self.sql_model, str(order_by))
return [
eff_schema.from_orm(x)
for x in session.query(self.sql_model).order_by(order_attr.desc()).offset(start).limit(limit).all()
]
return [eff_schema.from_orm(x) for x in session.query(self.sql_model).offset(start).limit(limit).all()] return [eff_schema.from_orm(x) for x in session.query(self.sql_model).offset(start).limit(limit).all()]
def get_all_limit_columns(self, session: Session, fields: List[str], limit: int = None) -> List[SqlAlchemyBase]: def get_all_limit_columns(self, session: Session, fields: List[str], limit: int = None) -> List[SqlAlchemyBase]:
@ -154,3 +162,14 @@ class BaseDocument:
session.commit() session.commit()
return results_as_model return results_as_model
def delete_all(self, session: Session) -> None:
session.query(self.sql_model).delete()
session.commit()
def count_all(self, session: Session, match_key=None, match_value=None) -> int:
if None in [match_key, match_value]:
return session.query(self.sql_model).count()
else:
return session.query(self.sql_model).filter_by(**{match_key: match_value}).count()

View file

@ -5,6 +5,7 @@ from mealie.db.database import db
from mealie.db.db_setup import create_session from mealie.db.db_setup import create_session
from mealie.schema.settings import SiteSettings from mealie.schema.settings import SiteSettings
from mealie.schema.theme import SiteTheme from mealie.schema.theme import SiteTheme
from mealie.services.events import create_general_event
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
logger = root_logger.get_logger("init_db") logger = root_logger.get_logger("init_db")
@ -58,6 +59,7 @@ def main():
else: else:
print("Database Doesn't Exists, Initializing...") print("Database Doesn't Exists, Initializing...")
init_db() init_db()
create_general_event("Initialize Database", "Initialize database with default values", session)
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -1,7 +1,8 @@
from mealie.db.models.event import *
from mealie.db.models.group import *
from mealie.db.models.mealplan import * from mealie.db.models.mealplan import *
from mealie.db.models.recipe.recipe import * from mealie.db.models.recipe.recipe import *
from mealie.db.models.settings import * from mealie.db.models.settings import *
from mealie.db.models.sign_up import *
from mealie.db.models.theme import * from mealie.db.models.theme import *
from mealie.db.models.users import * from mealie.db.models.users import *
from mealie.db.models.sign_up import *
from mealie.db.models.group import *

17
mealie/db/models/event.py Normal file
View file

@ -0,0 +1,17 @@
import sqlalchemy as sa
from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase
class Event(SqlAlchemyBase, BaseMixins):
__tablename__ = "events"
id = sa.Column(sa.Integer, primary_key=True)
title = sa.Column(sa.String)
text = sa.Column(sa.String)
time_stamp = sa.Column(sa.DateTime)
category = sa.Column(sa.String)
def __init__(self, title, text, time_stamp, category, *args, **kwargs) -> None:
self.title = title
self.text = text
self.time_stamp = time_stamp
self.category = category

View file

@ -4,5 +4,5 @@ SqlAlchemyBase = dec.declarative_base()
class BaseMixins: class BaseMixins:
def _pass_on_me(): def update(self, *args, **kwarg):
pass self.__init__(*args, **kwarg)

View file

@ -0,0 +1,7 @@
from fastapi import APIRouter
from .events import router as events_router
about_router = APIRouter(prefix="/api/about")
about_router.include_router(events_router)

View file

@ -0,0 +1,28 @@
from fastapi import APIRouter, Depends
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user
from mealie.schema.events import EventsOut
from sqlalchemy.orm.session import Session
router = APIRouter(prefix="/events", tags=["App Events"])
@router.get("", response_model=EventsOut)
async def get_events(session: Session = Depends(generate_session), current_user=Depends(get_current_user)):
""" Get event from the Database """
# Get Item
return EventsOut(total=db.events.count_all(session), events=db.events.get_all(session, order_by="time_stamp"))
@router.delete("")
async def delete_events(session: Session = Depends(generate_session), current_user=Depends(get_current_user)):
""" Get event from the Database """
# Get Item
return db.events.delete_all(session)
@router.delete("/{id}")
async def delete_event(id: int, session: Session = Depends(generate_session), current_user=Depends(get_current_user)):
""" Delete event from the Database """
return db.events.delete(session, id)

View file

@ -1,17 +1,21 @@
import operator import operator
import shutil import shutil
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from mealie.core.config import app_dirs from mealie.core.config import app_dirs
from mealie.core.root_logger import get_logger
from mealie.core.security import create_file_token from mealie.core.security import create_file_token
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user
from mealie.schema.backup import BackupJob, ImportJob, Imports, LocalBackup from mealie.schema.backup import BackupJob, ImportJob, Imports, LocalBackup
from mealie.services.backups import imports from mealie.services.backups import imports
from mealie.services.backups.exports import backup_all from mealie.services.backups.exports import backup_all
from mealie.services.events import create_backup_event
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
router = APIRouter(prefix="/api/backups", tags=["Backups"], dependencies=[Depends(get_current_user)]) router = APIRouter(prefix="/api/backups", tags=["Backups"], dependencies=[Depends(get_current_user)])
logger = get_logger()
@router.get("/available", response_model=Imports) @router.get("/available", response_model=Imports)
@ -43,8 +47,10 @@ def export_database(data: BackupJob, session: Session = Depends(generate_session
export_users=data.options.users, export_users=data.options.users,
export_groups=data.options.groups, export_groups=data.options.groups,
) )
create_backup_event("Database Backup", f"Manual Backup Created '{Path(export_path).name}'", session)
return {"export_path": export_path} return {"export_path": export_path}
except Exception: except Exception as e:
logger.error(e)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)
@ -72,7 +78,7 @@ async def download_backup_file(file_name: str):
def import_database(file_name: str, import_data: ImportJob, session: Session = Depends(generate_session)): def import_database(file_name: str, import_data: ImportJob, session: Session = Depends(generate_session)):
""" Import a database backup file generated from Mealie. """ """ Import a database backup file generated from Mealie. """
return imports.import_database( db_import = imports.import_database(
session=session, session=session,
archive=import_data.name, archive=import_data.name,
import_recipes=import_data.recipes, import_recipes=import_data.recipes,
@ -84,6 +90,8 @@ def import_database(file_name: str, import_data: ImportJob, session: Session = D
force_import=import_data.force, force_import=import_data.force,
rebase=import_data.rebase, rebase=import_data.rebase,
) )
create_backup_event("Database Restore", f"Restored Database File {file_name}", session)
return db_import
@router.delete("/{file_name}/delete", status_code=status.HTTP_200_OK) @router.delete("/{file_name}/delete", status_code=status.HTTP_200_OK)

View file

@ -2,8 +2,11 @@ from fastapi import APIRouter, Depends
from mealie.core.config import APP_VERSION, app_dirs, settings from mealie.core.config import APP_VERSION, app_dirs, settings
from mealie.core.root_logger import LOGGER_FILE from mealie.core.root_logger import LOGGER_FILE
from mealie.core.security import create_file_token from mealie.core.security import create_file_token
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user
from mealie.schema.debug import AppInfo, DebugInfo from mealie.schema.about import AppInfo, AppStatistics, DebugInfo
from sqlalchemy.orm.session import Session
router = APIRouter(prefix="/api/debug", tags=["Debug"]) router = APIRouter(prefix="/api/debug", tags=["Debug"])
@ -18,11 +21,23 @@ async def get_debug_info(current_user=Depends(get_current_user)):
demo_status=settings.IS_DEMO, demo_status=settings.IS_DEMO,
api_port=settings.API_PORT, api_port=settings.API_PORT,
api_docs=settings.API_DOCS, api_docs=settings.API_DOCS,
db_type=settings.DB_ENGINE,
db_url=settings.DB_URL, db_url=settings.DB_URL,
default_group=settings.DEFAULT_GROUP, default_group=settings.DEFAULT_GROUP,
) )
@router.get("/statistics")
async def get_app_statistics(session: Session = Depends(generate_session)):
return AppStatistics(
total_recipes=db.recipes.count_all(session),
uncategorized_recipes=db.recipes.count_uncategorized(session),
untagged_recipes=db.recipes.count_untagged(session),
total_users=db.users.count_all(session),
total_groups=db.groups.count_all(session),
)
@router.get("/version") @router.get("/version")
async def get_mealie_version(): async def get_mealie_version():
""" Returns the current version of mealie""" """ Returns the current version of mealie"""

View file

@ -88,7 +88,7 @@ def get_todays_image(session: Session = Depends(generate_session), group_name: s
recipe = get_todays_meal(session, group_in_db) recipe = get_todays_meal(session, group_in_db)
if recipe: if recipe:
recipe_image = image.read_image(recipe.slug, image_type=image.IMG_OPTIONS.ORIGINAL_IMAGE) recipe_image = recipe.image_dir.joinpath(image.ImageOptions.ORIGINAL_IMAGE)
else: else:
raise HTTPException(status.HTTP_404_NOT_FOUND) raise HTTPException(status.HTTP_404_NOT_FOUND)
if recipe_image: if recipe_image:

View file

@ -1,10 +1,10 @@
from fastapi import APIRouter from fastapi import APIRouter
from mealie.routes.recipe import all_recipe_routes, category_routes, recipe_assets, recipe_crud_routes, tag_routes from mealie.routes.recipe import all_recipe_routes, category_routes, recipe_crud_routes, recipe_media, tag_routes
router = APIRouter() router = APIRouter()
router.include_router(all_recipe_routes.router) router.include_router(all_recipe_routes.router)
router.include_router(recipe_crud_routes.router) router.include_router(recipe_crud_routes.router)
router.include_router(recipe_assets.router) router.include_router(recipe_media.router)
router.include_router(category_routes.router) router.include_router(category_routes.router)
router.include_router(tag_routes.router) router.include_router(tag_routes.router)

View file

@ -8,7 +8,7 @@ from sqlalchemy.orm.session import Session
router = APIRouter(tags=["Query All Recipes"]) router = APIRouter(tags=["Query All Recipes"])
@router.get("/api/recipes/summary") @router.get("/api/recipes/summary", response_model=list[RecipeSummary])
async def get_recipe_summary( async def get_recipe_summary(
start=0, start=0,
limit=9999, limit=9999,
@ -29,6 +29,16 @@ async def get_recipe_summary(
return db.recipes.get_all(session, limit=limit, start=start, override_schema=RecipeSummary) return db.recipes.get_all(session, limit=limit, start=start, override_schema=RecipeSummary)
@router.get("/api/recipes/summary/untagged", response_model=list[RecipeSummary])
async def get_untagged_recipes(session: Session = Depends(generate_session)):
return db.recipes.count_untagged(session, False, override_schema=RecipeSummary)
@router.get("/api/recipes/summary/uncategorized", response_model=list[RecipeSummary])
async def get_uncategorized_recipes(session: Session = Depends(generate_session)):
return db.recipes.count_uncategorized(session, False, override_schema=RecipeSummary)
@router.post("/api/recipes/category") @router.post("/api/recipes/category")
def filter_by_category(categories: list, session: Session = Depends(generate_session)): def filter_by_category(categories: list, session: Session = Depends(generate_session)):
""" pass a list of categories and get a list of recipes associated with those categories """ """ pass a list of categories and get a list of recipes associated with those categories """

View file

@ -4,7 +4,9 @@ from mealie.db.database import db
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user
from mealie.schema.recipe import Recipe, RecipeURLIn from mealie.schema.recipe import Recipe, RecipeURLIn
from mealie.services.image.image import delete_image, rename_image, scrape_image, write_image from mealie.services.events import create_recipe_event
from mealie.services.image.image import scrape_image, write_image
from mealie.services.recipe.media import check_assets, delete_assets
from mealie.services.scraper.scraper import create_from_url from mealie.services.scraper.scraper import create_from_url
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
@ -21,6 +23,8 @@ def create_from_json(
""" Takes in a JSON string and loads data into the database as a new entry""" """ Takes in a JSON string and loads data into the database as a new entry"""
recipe: Recipe = db.recipes.create(session, data.dict()) recipe: Recipe = db.recipes.create(session, data.dict())
create_recipe_event("Recipe Created", f"Recipe '{recipe.name}' created", session=session)
return recipe.slug return recipe.slug
@ -34,6 +38,7 @@ def parse_recipe_url(
recipe = create_from_url(url.url) recipe = create_from_url(url.url)
recipe: Recipe = db.recipes.create(session, recipe.dict()) recipe: Recipe = db.recipes.create(session, recipe.dict())
create_recipe_event("Recipe Created (URL)", f"'{recipe.name}' by {current_user.full_name}", session=session)
return recipe.slug return recipe.slug
@ -57,8 +62,7 @@ def update_recipe(
recipe: Recipe = db.recipes.update(session, recipe_slug, data.dict()) recipe: Recipe = db.recipes.update(session, recipe_slug, data.dict())
print(recipe.assets) print(recipe.assets)
if recipe_slug != recipe.slug: check_assets(original_slug=recipe_slug, recipe=recipe)
rename_image(original_slug=recipe_slug, new_slug=recipe.slug)
return recipe return recipe
@ -75,8 +79,8 @@ def patch_recipe(
recipe: Recipe = db.recipes.patch( recipe: Recipe = db.recipes.patch(
session, recipe_slug, new_data=data.dict(exclude_unset=True, exclude_defaults=True) session, recipe_slug, new_data=data.dict(exclude_unset=True, exclude_defaults=True)
) )
if recipe_slug != recipe.slug:
rename_image(original_slug=recipe_slug, new_slug=recipe.slug) check_assets(original_slug=recipe_slug, recipe=recipe)
return recipe return recipe
@ -90,10 +94,10 @@ def delete_recipe(
""" Deletes a recipe by slug """ """ Deletes a recipe by slug """
try: try:
delete_data = db.recipes.delete(session, recipe_slug) recipe: Recipe = db.recipes.delete(session, recipe_slug)
delete_image(recipe_slug) delete_assets(recipe_slug=recipe_slug)
create_recipe_event("Recipe Deleted", f"'{recipe.name}' deleted by {current_user.full_name}", session=session)
return delete_data return recipe
except Exception: except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST) raise HTTPException(status.HTTP_400_BAD_REQUEST)

View file

@ -3,7 +3,6 @@ from enum import Enum
from fastapi import APIRouter, Depends, File, Form, HTTPException, status from fastapi import APIRouter, Depends, File, Form, HTTPException, status
from fastapi.datastructures import UploadFile from fastapi.datastructures import UploadFile
from mealie.core.config import app_dirs
from mealie.db.database import db from mealie.db.database import db
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user
@ -12,7 +11,7 @@ from slugify import slugify
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from starlette.responses import FileResponse from starlette.responses import FileResponse
router = APIRouter(prefix="/api/recipes", tags=["Recipe Media"]) router = APIRouter(prefix="/api/recipes/media", tags=["Recipe Media"])
class ImageType(str, Enum): class ImageType(str, Enum):
@ -21,25 +20,30 @@ class ImageType(str, Enum):
tiny = "tiny-original.webp" tiny = "tiny-original.webp"
@router.get("/image/{recipe_slug}/{file_name}") @router.get("/{recipe_slug}/image/{file_name}")
async def get_recipe_img(recipe_slug: str, file_name: ImageType = ImageType.original): async def get_recipe_img(recipe_slug: str, file_name: ImageType = ImageType.original):
"""Takes in a recipe slug, returns the static image. This route is proxied in the docker image """Takes in a recipe slug, returns the static image. This route is proxied in the docker image
and should not hit the API in production""" and should not hit the API in production"""
recipe_image = app_dirs.IMG_DIR.joinpath(recipe_slug, file_name.value) recipe_image = Recipe(slug=recipe_slug).image_dir.joinpath(file_name.value)
if recipe_image: if recipe_image:
return FileResponse(recipe_image) return FileResponse(recipe_image)
else: else:
raise HTTPException(status.HTTP_404_NOT_FOUND) raise HTTPException(status.HTTP_404_NOT_FOUND)
@router.get("/{recipe_slug}/asset") @router.get("/{recipe_slug}/assets/{file_name}")
async def get_recipe_asset(recipe_slug, file_name: str): async def get_recipe_asset(recipe_slug: str, file_name: str):
""" Returns a recipe asset """ """ Returns a recipe asset """
file = app_dirs.RECIPE_DATA_DIR.joinpath(recipe_slug, file_name) file = Recipe(slug=recipe_slug).asset_dir.joinpath(file_name)
return FileResponse(file)
try:
return FileResponse(file)
except Exception:
raise HTTPException(status.HTTP_404_NOT_FOUND)
@router.post("/{recipe_slug}/asset", response_model=RecipeAsset) @router.post("/{recipe_slug}/assets", response_model=RecipeAsset)
def upload_recipe_asset( def upload_recipe_asset(
recipe_slug: str, recipe_slug: str,
name: str = Form(...), name: str = Form(...),
@ -52,8 +56,7 @@ def upload_recipe_asset(
""" Upload a file to store as a recipe asset """ """ Upload a file to store as a recipe asset """
file_name = slugify(name) + "." + extension file_name = slugify(name) + "." + extension
asset_in = RecipeAsset(name=name, icon=icon, file_name=file_name) asset_in = RecipeAsset(name=name, icon=icon, file_name=file_name)
dest = app_dirs.RECIPE_DATA_DIR.joinpath(recipe_slug, file_name) dest = Recipe(slug=recipe_slug).asset_dir.joinpath(file_name)
dest.parent.mkdir(exist_ok=True, parents=True)
with dest.open("wb") as buffer: with dest.open("wb") as buffer:
shutil.copyfileobj(file.file, buffer) shutil.copyfileobj(file.file, buffer)

View file

@ -1,6 +1,6 @@
import shutil import shutil
from fastapi import APIRouter, Depends, File, UploadFile, status, HTTPException from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from mealie.core import security from mealie.core import security
from mealie.core.config import app_dirs, settings from mealie.core.config import app_dirs, settings
@ -9,6 +9,7 @@ from mealie.db.database import db
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user
from mealie.schema.user import ChangePassword, UserBase, UserIn, UserInDB, UserOut from mealie.schema.user import ChangePassword, UserBase, UserIn, UserInDB, UserOut
from mealie.services.events import create_sign_up_event
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
router = APIRouter(prefix="/api/users", tags=["Users"]) router = APIRouter(prefix="/api/users", tags=["Users"])
@ -22,7 +23,7 @@ async def create_user(
): ):
new_user.password = get_password_hash(new_user.password) new_user.password = get_password_hash(new_user.password)
create_sign_up_event("User Created", f"Created by {current_user.full_name}", session=session)
return db.users.create(session, new_user.dict()) return db.users.create(session, new_user.dict())

View file

@ -1,14 +1,14 @@
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from mealie.core.security import get_password_hash from mealie.core.security import get_password_hash
from mealie.db.database import db from mealie.db.database import db
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from fastapi import APIRouter, Depends
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user
from mealie.schema.sign_up import SignUpIn, SignUpOut, SignUpToken from mealie.schema.sign_up import SignUpIn, SignUpOut, SignUpToken
from mealie.schema.user import UserIn, UserInDB from mealie.schema.user import UserIn, UserInDB
from mealie.services.events import create_sign_up_event
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from fastapi import HTTPException, status
router = APIRouter(prefix="/api/users/sign-ups", tags=["User Signup"]) router = APIRouter(prefix="/api/users/sign-ups", tags=["User Signup"])
@ -20,9 +20,7 @@ async def get_all_open_sign_ups(
): ):
""" Returns a list of open sign up links """ """ Returns a list of open sign up links """
all_sign_ups = db.sign_ups.get_all(session) return db.sign_ups.get_all(session)
return all_sign_ups
@router.post("", response_model=SignUpToken) @router.post("", response_model=SignUpToken)
@ -41,6 +39,7 @@ async def create_user_sign_up_key(
"name": key_data.name, "name": key_data.name,
"admin": key_data.admin, "admin": key_data.admin,
} }
create_sign_up_event("Sign-up Token Created", f"Created by {current_user.full_name}", session=session)
return db.sign_ups.create(session, sign_up) return db.sign_ups.create(session, sign_up)
@ -63,6 +62,7 @@ async def create_user_with_token(
db.users.create(session, new_user.dict()) db.users.create(session, new_user.dict())
# DeleteToken # DeleteToken
create_sign_up_event("Sign-up Token Used", f"New User {new_user.full_name}", session=session)
db.sign_ups.delete(session, token) db.sign_ups.delete(session, token)

View file

@ -1,7 +1,16 @@
from pathlib import Path from pathlib import Path
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
class AppStatistics(CamelModel):
total_recipes: int
total_users: int
total_groups: int
uncategorized_recipes: int
untagged_recipes: int
class AppInfo(CamelModel): class AppInfo(CamelModel):
production: bool production: bool
version: str version: str
@ -11,5 +20,6 @@ class AppInfo(CamelModel):
class DebugInfo(AppInfo): class DebugInfo(AppInfo):
api_port: int api_port: int
api_docs: bool api_docs: bool
db_type: str
db_url: Path db_url: Path
default_group: str default_group: str

31
mealie/schema/events.py Normal file
View file

@ -0,0 +1,31 @@
from datetime import datetime
from enum import Enum
from typing import Optional
from fastapi_camelcase import CamelModel
from pydantic import Field
class EventCategory(str, Enum):
general = "general"
recipe = "recipe"
backup = "backup"
scheduled = "scheduled"
migration = "migration"
sign_up = "signup"
class Event(CamelModel):
id: Optional[int]
title: str
text: str
time_stamp: datetime = Field(default_factory=datetime.now)
category: EventCategory = EventCategory.general
class Config:
orm_mode = True
class EventsOut(CamelModel):
total: int
events: list[Event]

View file

@ -1,7 +1,9 @@
import datetime import datetime
from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
from mealie.core.config import app_dirs
from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models.recipe.recipe import RecipeModel
from pydantic import BaseModel, Field, validator from pydantic import BaseModel, Field, validator
from pydantic.utils import GetterDict from pydantic.utils import GetterDict
@ -58,8 +60,8 @@ class Nutrition(CamelModel):
class RecipeSummary(CamelModel): class RecipeSummary(CamelModel):
id: Optional[int] id: Optional[int]
name: str name: Optional[str]
slug: Optional[str] = "" slug: str = ""
image: Optional[Any] image: Optional[Any]
description: Optional[str] description: Optional[str]
@ -98,6 +100,28 @@ class Recipe(RecipeSummary):
org_url: Optional[str] = Field(None, alias="orgURL") org_url: Optional[str] = Field(None, alias="orgURL")
extras: Optional[dict] = {} extras: Optional[dict] = {}
@staticmethod
def directory_from_slug(slug) -> Path:
return app_dirs.RECIPE_DATA_DIR.joinpath(slug)
@property
def directory(self) -> Path:
dir = app_dirs.RECIPE_DATA_DIR.joinpath(self.slug)
dir.mkdir(exist_ok=True, parents=True)
return dir
@property
def asset_dir(self) -> Path:
dir = self.directory.joinpath("assets")
dir.mkdir(exist_ok=True, parents=True)
return dir
@property
def image_dir(self) -> Path:
dir = self.directory.joinpath("images")
dir.mkdir(exist_ok=True, parents=True)
return dir
class Config: class Config:
orm_mode = True orm_mode = True
@ -140,6 +164,8 @@ class Recipe(RecipeSummary):
@validator("slug", always=True, pre=True) @validator("slug", always=True, pre=True)
def validate_slug(slug: str, values): def validate_slug(slug: str, values):
if not values["name"]:
return slug
name: str = values["name"] name: str = values["name"]
calc_slug: str = slugify(name) calc_slug: str = slugify(name)

View file

@ -9,6 +9,7 @@ from mealie.core import root_logger
from mealie.core.config import app_dirs from mealie.core.config import app_dirs
from mealie.db.database import db from mealie.db.database import db
from mealie.db.db_setup import create_session from mealie.db.db_setup import create_session
from mealie.services.events import create_backup_event
from pathvalidate import sanitize_filename from pathvalidate import sanitize_filename
from pydantic.main import BaseModel from pydantic.main import BaseModel
@ -32,7 +33,7 @@ class ExportDatabase:
export_tag = datetime.now().strftime("%Y-%b-%d") export_tag = datetime.now().strftime("%Y-%b-%d")
self.main_dir = app_dirs.TEMP_DIR.joinpath(export_tag) self.main_dir = app_dirs.TEMP_DIR.joinpath(export_tag)
self.img_dir = self.main_dir.joinpath("images") self.recipes = self.main_dir.joinpath("recipes")
self.templates_dir = self.main_dir.joinpath("templates") self.templates_dir = self.main_dir.joinpath("templates")
try: try:
@ -43,7 +44,7 @@ class ExportDatabase:
required_dirs = [ required_dirs = [
self.main_dir, self.main_dir,
self.img_dir, self.recipes,
self.templates_dir, self.templates_dir,
] ]
@ -67,10 +68,10 @@ class ExportDatabase:
with open(out_file, "w") as f: with open(out_file, "w") as f:
f.write(content) f.write(content)
def export_images(self): def export_recipe_dirs(self):
shutil.copytree(app_dirs.IMG_DIR, self.img_dir, dirs_exist_ok=True) shutil.copytree(app_dirs.RECIPE_DATA_DIR, self.recipes, dirs_exist_ok=True)
def export_items(self, items: list[BaseModel], folder_name: str, export_list=True): def export_items(self, items: list[BaseModel], folder_name: str, export_list=True, slug_folder=False):
items = [x.dict() for x in items] items = [x.dict() for x in items]
out_dir = self.main_dir.joinpath(folder_name) out_dir = self.main_dir.joinpath(folder_name)
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
@ -79,8 +80,10 @@ class ExportDatabase:
ExportDatabase._write_json_file(items, out_dir.joinpath(f"{folder_name}.json")) ExportDatabase._write_json_file(items, out_dir.joinpath(f"{folder_name}.json"))
else: else:
for item in items: for item in items:
filename = sanitize_filename(f"{item.get('name')}.json") final_dest = out_dir if not slug_folder else out_dir.joinpath(item.get("slug"))
ExportDatabase._write_json_file(item, out_dir.joinpath(filename)) final_dest.mkdir(exist_ok=True)
filename = sanitize_filename(f"{item.get('slug')}.json")
ExportDatabase._write_json_file(item, final_dest.joinpath(filename))
@staticmethod @staticmethod
def _write_json_file(data: Union[dict, list], out_file: Path): def _write_json_file(data: Union[dict, list], out_file: Path):
@ -121,9 +124,9 @@ def backup_all(
if export_recipes: if export_recipes:
all_recipes = db.recipes.get_all(session) all_recipes = db.recipes.get_all(session)
db_export.export_items(all_recipes, "recipes", export_list=False) db_export.export_recipe_dirs()
db_export.export_items(all_recipes, "recipes", export_list=False, slug_folder=True)
db_export.export_templates(all_recipes) db_export.export_templates(all_recipes)
db_export.export_images()
if export_settings: if export_settings:
all_settings = db.settings.get_all(session) all_settings = db.settings.get_all(session)
@ -148,3 +151,5 @@ def auto_backup_job():
session = create_session() session = create_session()
backup_all(session=session, tag="Auto", templates=templates) backup_all(session=session, tag="Auto", templates=templates)
logger.info("Auto Backup Called") logger.info("Auto Backup Called")
create_backup_event("Automated Backup", "Automated backup created", session)
session.close()

View file

@ -2,7 +2,7 @@ import json
import shutil import shutil
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import Callable, List from typing import Callable
from mealie.core.config import app_dirs from mealie.core.config import app_dirs
from mealie.db.database import db from mealie.db.database import db
@ -49,7 +49,7 @@ class ImportDatabase:
def import_recipes(self): def import_recipes(self):
recipe_dir: Path = self.import_dir.joinpath("recipes") recipe_dir: Path = self.import_dir.joinpath("recipes")
imports = [] imports = []
successful_imports = [] successful_imports = {}
recipes = ImportDatabase.read_models_file( recipes = ImportDatabase.read_models_file(
file_path=recipe_dir, model=Recipe, single_file=False, migrate=ImportDatabase._recipe_migration file_path=recipe_dir, model=Recipe, single_file=False, migrate=ImportDatabase._recipe_migration
@ -68,7 +68,7 @@ class ImportDatabase:
) )
if import_status.status: if import_status.status:
successful_imports.append(recipe.slug) successful_imports.update({recipe.slug: recipe})
imports.append(import_status) imports.append(import_status)
@ -105,15 +105,25 @@ class ImportDatabase:
return recipe_dict return recipe_dict
def _import_images(self, successful_imports: List[str]): def _import_images(self, successful_imports: list[Recipe]):
image_dir = self.import_dir.joinpath("images") image_dir = self.import_dir.joinpath("images")
for image in image_dir.iterdir():
if image.stem in successful_imports: if image_dir.exists(): # Migrate from before v0.5.0
if image.is_dir(): for image in image_dir.iterdir():
dest = app_dirs.IMG_DIR.joinpath(image.stem) item: Recipe = successful_imports.get(image.stem)
shutil.copytree(image, dest, dirs_exist_ok=True)
if image.is_file(): if item:
shutil.copy(image, app_dirs.IMG_DIR) dest_dir = item.image_dir
if image.is_dir():
shutil.copytree(image, dest_dir, dirs_exist_ok=True)
if image.is_file():
shutil.copy(image, dest_dir)
else:
recipe_dir = self.import_dir.joinpath("recipes")
shutil.copytree(recipe_dir, app_dirs.RECIPE_DATA_DIR, dirs_exist_ok=True)
minify.migrate_images() minify.migrate_images()
@ -227,7 +237,7 @@ class ImportDatabase:
return [model(**g) for g in file_data] return [model(**g) for g in file_data]
all_models = [] all_models = []
for file in file_path.glob("*.json"): for file in file_path.glob("**/*.json"):
with open(file, "r") as f: with open(file, "r") as f:
file_data = json.loads(f.read()) file_data = json.loads(f.read())

40
mealie/services/events.py Normal file
View file

@ -0,0 +1,40 @@
from mealie.db.database import db
from mealie.db.db_setup import create_session
from mealie.schema.events import Event, EventCategory
from sqlalchemy.orm.session import Session
def save_event(title, text, category, session: Session):
event = Event(title=title, text=text, category=category)
session = session or create_session()
db.events.create(session, event.dict())
def create_general_event(title, text, session=None):
category = EventCategory.general
save_event(title=title, text=text, category=category, session=session)
def create_recipe_event(title, text, session=None):
category = EventCategory.recipe
save_event(title=title, text=text, category=category, session=session)
def create_backup_event(title, text, session=None):
category = EventCategory.backup
save_event(title=title, text=text, category=category, session=session)
def create_scheduled_event(title, text, session=None):
category = EventCategory.scheduled
save_event(title=title, text=text, category=category, session=session)
def create_migration_event(title, text, session=None):
category = EventCategory.migration
save_event(title=title, text=text, category=category, session=session)
def create_sign_up_event(title, text, session=None):
category = EventCategory.sign_up
save_event(title=title, text=text, category=category, session=session)

View file

@ -4,7 +4,7 @@ from pathlib import Path
import requests import requests
from mealie.core import root_logger from mealie.core import root_logger
from mealie.core.config import app_dirs from mealie.schema.recipe import Recipe
from mealie.services.image import minify from mealie.services.image import minify
logger = root_logger.get_logger() logger = root_logger.get_logger()
@ -20,47 +20,11 @@ class ImageOptions:
IMG_OPTIONS = ImageOptions() IMG_OPTIONS = ImageOptions()
def read_image(recipe_slug: str, image_type: str = "original") -> Path:
"""returns the path to the image file for the recipe base of image_type
Args:
recipe_slug (str): Recipe Slug
image_type (str, optional): Glob Style Matcher "original*" | "min-original* | "tiny-original*"
Returns:
Path: [description]
"""
recipe_slug = recipe_slug.split(".")[0] # Incase of File Name
recipe_image_dir = app_dirs.IMG_DIR.joinpath(recipe_slug)
for file in recipe_image_dir.glob(image_type):
return file
return None
def rename_image(original_slug, new_slug) -> Path:
current_path = app_dirs.IMG_DIR.joinpath(original_slug)
new_path = app_dirs.IMG_DIR.joinpath(new_slug)
try:
new_path = current_path.rename(new_path)
except FileNotFoundError:
logger.error(f"Image Directory {original_slug} Doesn't Exist")
return new_path
def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path: def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path:
try: image_dir = Recipe(slug=recipe_slug).image_dir
delete_image(recipe_slug)
except Exception:
pass
image_dir = Path(app_dirs.IMG_DIR.joinpath(f"{recipe_slug}"))
image_dir.mkdir(exist_ok=True, parents=True)
extension = extension.replace(".", "") extension = extension.replace(".", "")
image_path = image_dir.joinpath(f"original.{extension}") image_path = image_dir.joinpath(f"original.{extension}")
image_path.unlink(missing_ok=True)
if isinstance(file_data, Path): if isinstance(file_data, Path):
shutil.copy2(file_data, image_path) shutil.copy2(file_data, image_path)
@ -77,12 +41,6 @@ def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path:
return image_path return image_path
def delete_image(recipe_slug: str) -> str:
recipe_slug = recipe_slug.split(".")[0]
for file in app_dirs.IMG_DIR.glob(f"{recipe_slug}*"):
return shutil.rmtree(file)
def scrape_image(image_url: str, slug: str) -> Path: def scrape_image(image_url: str, slug: str) -> Path:
if isinstance(image_url, str): # Handles String Types if isinstance(image_url, str): # Handles String Types
image_url = image_url image_url = image_url
@ -96,7 +54,7 @@ def scrape_image(image_url: str, slug: str) -> Path:
image_url = image_url.get("url") image_url = image_url.get("url")
filename = slug + "." + image_url.split(".")[-1] filename = slug + "." + image_url.split(".")[-1]
filename = app_dirs.IMG_DIR.joinpath(filename) filename = Recipe(slug=slug).image_dir.joinpath(filename)
try: try:
r = requests.get(image_url, stream=True) r = requests.get(image_url, stream=True)

View file

@ -4,10 +4,8 @@ from pathlib import Path
from mealie.core import root_logger from mealie.core import root_logger
from mealie.core.config import app_dirs from mealie.core.config import app_dirs
from mealie.db.database import db from mealie.schema.recipe import Recipe
from mealie.db.db_setup import create_session
from PIL import Image from PIL import Image
from sqlalchemy.orm.session import Session
logger = root_logger.get_logger() logger = root_logger.get_logger()
@ -20,11 +18,7 @@ class ImageSizes:
def get_image_sizes(org_img: Path, min_img: Path, tiny_img: Path) -> ImageSizes: def get_image_sizes(org_img: Path, min_img: Path, tiny_img: Path) -> ImageSizes:
return ImageSizes( return ImageSizes(org=sizeof_fmt(org_img), min=sizeof_fmt(min_img), tiny=sizeof_fmt(tiny_img))
org=sizeof_fmt(org_img),
min=sizeof_fmt(min_img),
tiny=sizeof_fmt(tiny_img),
)
def minify_image(image_file: Path) -> ImageSizes: def minify_image(image_file: Path) -> ImageSizes:
@ -110,28 +104,9 @@ def move_all_images():
if new_file.is_file(): if new_file.is_file():
new_file.unlink() new_file.unlink()
image_file.rename(new_file) image_file.rename(new_file)
if image_file.is_dir():
slug = image_file.name
def validate_slugs_in_database(session: Session = None): image_file.rename(Recipe(slug=slug).image_dir)
def check_image_path(image_name: str, slug_path: str) -> bool:
existing_path: Path = app_dirs.IMG_DIR.joinpath(image_name)
slug_path: Path = app_dirs.IMG_DIR.joinpath(slug_path)
if existing_path.is_dir():
slug_path.rename(existing_path)
else:
logger.info("No Image Found")
session = session or create_session()
all_recipes = db.recipes.get_all(session)
slugs_and_images = [(x.slug, x.image) for x in all_recipes]
for slug, image in slugs_and_images:
image_slug = image.split(".")[0] # Remove Extension
if slug != image_slug:
logger.info(f"{slug}, Doesn't Match '{image_slug}'")
check_image_path(image, slug)
def migrate_images(): def migrate_images():
@ -139,7 +114,7 @@ def migrate_images():
move_all_images() move_all_images()
for image in app_dirs.IMG_DIR.glob("*/original.*"): for image in app_dirs.RECIPE_DATA_DIR.glob("**/original.*"):
minify_image(image) minify_image(image)
@ -148,4 +123,3 @@ def migrate_images():
if __name__ == "__main__": if __name__ == "__main__":
migrate_images() migrate_images()
validate_slugs_in_database()

View file

View file

@ -0,0 +1,34 @@
from pathlib import Path
from shutil import copytree, rmtree
from mealie.core.config import app_dirs
from mealie.core.root_logger import get_logger
from mealie.schema.recipe import Recipe
logger = get_logger()
def check_assets(original_slug, recipe: Recipe) -> None:
if original_slug != recipe.slug:
current_dir = app_dirs.RECIPE_DATA_DIR.joinpath(original_slug)
try:
copytree(current_dir, recipe.directory, dirs_exist_ok=True)
except FileNotFoundError:
logger.error(f"Recipe Directory not Found: {original_slug}")
logger.info(f"Renaming Recipe Directory: {original_slug} -> {recipe.slug}")
all_asset_files = [x.file_name for x in recipe.assets]
for file in recipe.asset_dir.iterdir():
file: Path
if file.is_dir():
continue
if file.name not in all_asset_files:
file.unlink()
def delete_assets(recipe_slug):
recipe_dir = Recipe(slug=recipe_slug).directory
rmtree(recipe_dir, ignore_errors=True)
logger.info(f"Recipe Directory Removed: {recipe_slug}")

View file

@ -2,6 +2,7 @@ import requests
from mealie.db.database import db from mealie.db.database import db
from mealie.db.db_setup import create_session from mealie.db.db_setup import create_session
from mealie.schema.user import GroupInDB from mealie.schema.user import GroupInDB
from mealie.services.events import create_scheduled_event
from mealie.services.meal_services import get_todays_meal from mealie.services.meal_services import get_todays_meal
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
@ -21,4 +22,6 @@ def post_webhooks(group: int, session: Session = None):
for url in group_settings.webhook_urls: for url in group_settings.webhook_urls:
requests.post(url, json=todays_recipe.json()) requests.post(url, json=todays_recipe.json())
create_scheduled_event("Meal Plan Webhook", f"Meal plan webhook executed for group '{group}'")
session.close() session.close()