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
uri strip_suffix /
handle_path /api/recipes/image/* {
root * /app/data/img/
handle_path /api/recipes/media/* {
root * /app/data/recipes/
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
- 🔍 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
- 💪 Powerful bulk Category/Tag assignment
- 📱 Beautiful Mobile Views
- 📆 Create Meal Plans
- 🛒 Generate shopping lists
- 🐳 Easy setup with Docker
- 🎨 Customize your interface with color themes layouts
- 💾 Export all your data in any format with Jinja2 Templates, with easy data restoration from the user interface.
- 🎨 Customize your interface with color themes
- 💾 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
- Plus tons more!
- Flexible API

View file

@ -11,6 +11,9 @@
#### Database
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
- Fixed #332 - Language settings are saved for one browser
- 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 { groupAPI } from "./groups";
import { siteSettingsAPI } from "./siteSettings";
import { aboutAPI } from "./about";
/**
* The main object namespace for interacting with the backend database
@ -30,4 +31,5 @@ export const api = {
users: userAPI,
signUps: signupAPI,
groups: groupAPI,
about: aboutAPI,
};

View file

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

View file

@ -14,9 +14,9 @@ const recipeURLs = {
recipe: slug => prefix + slug,
update: slug => prefix + slug,
delete: slug => prefix + slug,
createAsset: slug => `${prefix}media/${slug}/assets`,
recipeImage: slug => `${prefix}${slug}/image`,
updateImage: slug => `${prefix}${slug}/image`,
createAsset: slug => `${prefix}${slug}/asset`,
};
export const recipeAPI = {
@ -84,7 +84,7 @@ export const recipeAPI = {
fd.append("extension", fileObject.name.split(".").pop());
fd.append("name", name);
fd.append("icon", icon);
let response = apiReq.post(recipeURLs.createAsset(recipeSlug), fd);
const response = apiReq.post(recipeURLs.createAsset(recipeSlug), fd);
return response;
},
@ -135,14 +135,14 @@ export const recipeAPI = {
},
recipeImage(recipeSlug) {
return `/api/recipes/image/${recipeSlug}/original.webp`;
return `/api/recipes/media/${recipeSlug}/image/original.webp`;
},
recipeSmallImage(recipeSlug) {
return `/api/recipes/image/${recipeSlug}/min-original.webp`;
return `/api/recipes/media/${recipeSlug}/image/min-original.webp`;
},
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"
color="primary"
icon
:href="`/api/recipes/${slug}/asset?file_name=${item.fileName}`"
:href="`/api/recipes/media/${slug}/assets/${item.fileName}`"
target="_blank"
top
>
<v-icon> mdi-download</v-icon>
</v-btn>
<v-btn v-else color="error" icon @click="deleteAsset(i)" top>
<div v-else>
<v-btn color="error" icon @click="deleteAsset(i)" top>
<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>
</v-list>
@ -107,6 +112,11 @@ export default {
],
};
},
computed: {
baseURL() {
return window.location.origin;
},
},
methods: {
setFileObject(obj) {
this.fileObject = obj;
@ -124,6 +134,13 @@ export default {
deleteAsset(index) {
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>

View file

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

View file

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

View file

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

View file

@ -1,10 +1,12 @@
<template>
<v-form ref="file">
<input ref="uploader" class="d-none" type="file" @change="onFileChanged" />
<slot v-bind="{ isSelecting, onButtonClick }">
<v-btn :loading="isSelecting" @click="onButtonClick" color="accent" :text="textBtn">
<v-icon left> {{ icon }}</v-icon>
{{ text ? text : defaultText }}
</v-btn>
</slot>
</v-form>
</template>

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() {
return [
{
icon: "mdi-view-dashboard",
to: "/admin/dashboard",
title: this.$t("general.dashboard"),
},
{
icon: "mdi-cog",
to: "/admin/settings",

View file

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

View file

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

View file

@ -19,7 +19,7 @@
<div class="text-truncate">
<strong>{{ backup.name }}</strong>
</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-row>
</v-card-text>

View file

@ -15,7 +15,7 @@
</v-toolbar-items>
</v-toolbar>
<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-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: {
updateClipboard(newClip) {
navigator.clipboard.writeText(newClip).then(
function() {
() => {
console.log("Copied", newClip);
},
function() {
() => {
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-slider></v-tabs-slider>
<v-tab>
<v-tab href="#users">
{{ $t("user.users") }}
<v-icon>mdi-account</v-icon>
</v-tab>
<v-tab>
<v-tab href="#sign-ups">
{{ $t("signup.sign-up-links") }}
<v-icon>mdi-account-plus-outline</v-icon>
</v-tab>
<v-tab>
<v-tab href="#groups">
{{ $t("group.groups") }}
<v-icon>mdi-account-group</v-icon>
</v-tab>
</v-tabs>
<v-tabs-items v-model="tab">
<v-tab-item>
<v-tab-item value="users">
<TheUserTable />
</v-tab-item>
<v-tab-item>
<v-tab-item value="sign-ups">
<TheSignUpTable />
</v-tab-item>
<v-tab-item>
<v-tab-item value="groups">
<GroupDashboard />
</v-tab-item>
</v-tabs-items>
@ -42,9 +42,17 @@ import TheSignUpTable from "./TheSignUpTable";
export default {
components: { TheUserTable, GroupDashboard, TheSignUpTable },
data() {
return {
tab: 0,
};
return {};
},
computed: {
tab: {
set(tab) {
this.$router.replace({ query: { ...this.$route.query, tab } });
},
get() {
return this.$route.query.tab;
},
},
},
mounted() {
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-slider></v-tabs-slider>
<v-tab>
<v-tab href="#category-editor">
{{ $t("recipe.categories") }}
<v-icon>mdi-tag-multiple-outline</v-icon>
</v-tab>
<v-tab>
<v-tab href="#tag-editor">
{{ $t("tag.tags") }}
<v-icon>mdi-tag-multiple-outline</v-icon>
</v-tab>
<v-tab href="#organize">
Organize
<v-icon>mdi-broom</v-icon>
</v-tab>
</v-tabs>
<v-tabs-items v-model="tab">
<v-tab-item><CategoryTagEditor :is-tags="false"/></v-tab-item>
<v-tab-item><CategoryTagEditor :is-tags="true" /> </v-tab-item>
<v-tab-item value="category-editor"> <CategoryTagEditor :is-tags="false"/></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-card>
</div>
@ -25,14 +30,24 @@
<script>
import CategoryTagEditor from "./CategoryTagEditor";
import RecipeOrganizer from "./RecipeOrganizer";
export default {
components: {
CategoryTagEditor,
RecipeOrganizer,
},
computed: {
tab: {
set(tab) {
this.$router.replace({ query: { ...this.$route.query, tab } });
},
get() {
return this.$route.query.tab;
},
},
},
data() {
return {
tab: 0,
};
return {};
},
};
</script>

View file

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

View file

@ -25,22 +25,7 @@
class="sticky"
/>
<RecipeViewer
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"
/>
<RecipeViewer v-if="!form" :recipe="recipeDetails" />
<VJsoneditor
@error="logError()"
class="mt-10"

View file

@ -8,6 +8,7 @@ import ManageUsers from "@/pages/Admin/ManageUsers";
import Settings from "@/pages/Admin/Settings";
import About from "@/pages/Admin/About";
import ToolBox from "@/pages/Admin/ToolBox";
import Dashboard from "@/pages/Admin/Dashboard";
import { store } from "../store";
export const adminRoutes = {
@ -87,5 +88,12 @@ export const adminRoutes = {
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.config import APP_VERSION, settings
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.mealplans import mealplans
from mealie.routes.recipe import router as recipe_router
from mealie.routes.site_settings import all_settings
from mealie.routes.users import users
from mealie.services.events import create_general_event
logger = root_logger.get_logger()
@ -31,6 +33,7 @@ def api_routers():
app.include_router(groups.router)
# Recipes
app.include_router(recipe_router)
app.include_router(about_router)
# Meal Routes
app.include_router(mealplans.router)
# Settings Routes
@ -53,6 +56,7 @@ def system_startup():
logger.info("-----SYSTEM STARTUP----- \n")
logger.info("------APP SETTINGS------")
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():

View file

@ -4,8 +4,8 @@ import sys
from mealie.core.config import DATA_DIR
LOGGER_FILE = DATA_DIR.joinpath("mealie.log")
LOGGER_FORMAT = "%(levelname)s: \t%(message)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")
@ -30,6 +30,9 @@ def logger_init() -> logging.Logger:
return logger
root_logger = logger_init()
def get_logger(module=None) -> logging.Logger:
""" Returns a child logger for mealie """
global root_logger
@ -38,6 +41,3 @@ def get_logger(module=None) -> logging.Logger:
return root_logger
return root_logger.getChild(module)
root_logger = logger_init()

View file

@ -1,6 +1,7 @@
from logging import getLogger
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.mealplan import MealPlanModel
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.users import User
from mealie.schema.category import RecipeCategoryResponse, RecipeTagResponse
from mealie.schema.events import Event as EventSchema
from mealie.schema.meal import MealPlanInDB
from mealie.schema.recipe import Recipe
from mealie.schema.settings import CustomPageOut
@ -18,7 +20,6 @@ from mealie.schema.theme import SiteTheme
from mealie.schema.user import GroupInDB, UserInDB
from sqlalchemy.orm.session import Session
logger = getLogger()
@ -35,6 +36,26 @@ class _Recipes(BaseDocument):
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):
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()
# Potentially not needed? column is sorted by SqlAlchemy based on startDate
# return sorted(group.mealplans, key=lambda mealplan: mealplan.startDate)
return group.mealplans
@ -129,6 +148,13 @@ class _CustomPages(BaseDocument):
self.schema = CustomPageOut
class _Events(BaseDocument):
def __init__(self) -> None:
self.primary_key = "id"
self.sql_model = Event
self.schema = EventSchema
class Database:
def __init__(self) -> None:
self.recipes = _Recipes()
@ -141,6 +167,7 @@ class Database:
self.sign_ups = _SignUps()
self.groups = _Groups()
self.custom_pages = _CustomPages()
self.events = _Events()
db = Database()

View file

@ -23,6 +23,14 @@ class BaseDocument:
) -> List[dict]:
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()]
def get_all_limit_columns(self, session: Session, fields: List[str], limit: int = None) -> List[SqlAlchemyBase]:
@ -154,3 +162,14 @@ class BaseDocument:
session.commit()
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.schema.settings import SiteSettings
from mealie.schema.theme import SiteTheme
from mealie.services.events import create_general_event
from sqlalchemy.orm import Session
logger = root_logger.get_logger("init_db")
@ -58,6 +59,7 @@ def main():
else:
print("Database Doesn't Exists, Initializing...")
init_db()
create_general_event("Initialize Database", "Initialize database with default values", session)
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.recipe.recipe import *
from mealie.db.models.settings import *
from mealie.db.models.sign_up import *
from mealie.db.models.theme 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:
def _pass_on_me():
pass
def update(self, *args, **kwarg):
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 shutil
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
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.db.db_setup import generate_session
from mealie.routes.deps import get_current_user
from mealie.schema.backup import BackupJob, ImportJob, Imports, LocalBackup
from mealie.services.backups import imports
from mealie.services.backups.exports import backup_all
from mealie.services.events import create_backup_event
from sqlalchemy.orm.session import Session
router = APIRouter(prefix="/api/backups", tags=["Backups"], dependencies=[Depends(get_current_user)])
logger = get_logger()
@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_groups=data.options.groups,
)
create_backup_event("Database Backup", f"Manual Backup Created '{Path(export_path).name}'", session)
return {"export_path": export_path}
except Exception:
except Exception as e:
logger.error(e)
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)):
""" Import a database backup file generated from Mealie. """
return imports.import_database(
db_import = imports.import_database(
session=session,
archive=import_data.name,
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,
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)

View file

@ -2,8 +2,11 @@ from fastapi import APIRouter, Depends
from mealie.core.config import APP_VERSION, app_dirs, settings
from mealie.core.root_logger import LOGGER_FILE
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.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"])
@ -18,11 +21,23 @@ async def get_debug_info(current_user=Depends(get_current_user)):
demo_status=settings.IS_DEMO,
api_port=settings.API_PORT,
api_docs=settings.API_DOCS,
db_type=settings.DB_ENGINE,
db_url=settings.DB_URL,
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")
async def get_mealie_version():
""" 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)
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:
raise HTTPException(status.HTTP_404_NOT_FOUND)
if recipe_image:

View file

@ -1,10 +1,10 @@
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.include_router(all_recipe_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(tag_routes.router)

View file

@ -8,7 +8,7 @@ from sqlalchemy.orm.session import Session
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(
start=0,
limit=9999,
@ -29,6 +29,16 @@ async def get_recipe_summary(
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")
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 """

View file

@ -4,7 +4,9 @@ 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.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 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"""
recipe: Recipe = db.recipes.create(session, data.dict())
create_recipe_event("Recipe Created", f"Recipe '{recipe.name}' created", session=session)
return recipe.slug
@ -34,6 +38,7 @@ def parse_recipe_url(
recipe = create_from_url(url.url)
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
@ -57,8 +62,7 @@ def update_recipe(
recipe: Recipe = db.recipes.update(session, recipe_slug, data.dict())
print(recipe.assets)
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
@ -75,8 +79,8 @@ def patch_recipe(
recipe: Recipe = db.recipes.patch(
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
@ -90,10 +94,10 @@ def delete_recipe(
""" Deletes a recipe by slug """
try:
delete_data = db.recipes.delete(session, recipe_slug)
delete_image(recipe_slug)
return delete_data
recipe: Recipe = db.recipes.delete(session, 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 recipe
except Exception:
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.datastructures import UploadFile
from mealie.core.config import app_dirs
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user
@ -12,7 +11,7 @@ from slugify import slugify
from sqlalchemy.orm.session import Session
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):
@ -21,25 +20,30 @@ class ImageType(str, Enum):
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):
"""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"""
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:
return FileResponse(recipe_image)
else:
raise HTTPException(status.HTTP_404_NOT_FOUND)
@router.get("/{recipe_slug}/asset")
async def get_recipe_asset(recipe_slug, file_name: str):
@router.get("/{recipe_slug}/assets/{file_name}")
async def get_recipe_asset(recipe_slug: str, file_name: str):
""" 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)
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(
recipe_slug: str,
name: str = Form(...),
@ -52,8 +56,7 @@ def upload_recipe_asset(
""" Upload a file to store as a recipe asset """
file_name = slugify(name) + "." + extension
asset_in = RecipeAsset(name=name, icon=icon, file_name=file_name)
dest = app_dirs.RECIPE_DATA_DIR.joinpath(recipe_slug, file_name)
dest.parent.mkdir(exist_ok=True, parents=True)
dest = Recipe(slug=recipe_slug).asset_dir.joinpath(file_name)
with dest.open("wb") as buffer:
shutil.copyfileobj(file.file, buffer)

View file

@ -1,6 +1,6 @@
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 mealie.core import security
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.routes.deps import get_current_user
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
router = APIRouter(prefix="/api/users", tags=["Users"])
@ -22,7 +23,7 @@ async def create_user(
):
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())

View file

@ -1,14 +1,14 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from mealie.core.security import get_password_hash
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from fastapi import APIRouter, Depends
from mealie.routes.deps import get_current_user
from mealie.schema.sign_up import SignUpIn, SignUpOut, SignUpToken
from mealie.schema.user import UserIn, UserInDB
from mealie.services.events import create_sign_up_event
from sqlalchemy.orm.session import Session
from fastapi import HTTPException, status
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 """
all_sign_ups = db.sign_ups.get_all(session)
return all_sign_ups
return db.sign_ups.get_all(session)
@router.post("", response_model=SignUpToken)
@ -41,6 +39,7 @@ async def create_user_sign_up_key(
"name": key_data.name,
"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)
@ -63,6 +62,7 @@ async def create_user_with_token(
db.users.create(session, new_user.dict())
# DeleteToken
create_sign_up_event("Sign-up Token Used", f"New User {new_user.full_name}", session=session)
db.sign_ups.delete(session, token)

View file

@ -1,7 +1,16 @@
from pathlib import Path
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):
production: bool
version: str
@ -11,5 +20,6 @@ class AppInfo(CamelModel):
class DebugInfo(AppInfo):
api_port: int
api_docs: bool
db_type: str
db_url: Path
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
from pathlib import Path
from typing import Any, Optional
from fastapi_camelcase import CamelModel
from mealie.core.config import app_dirs
from mealie.db.models.recipe.recipe import RecipeModel
from pydantic import BaseModel, Field, validator
from pydantic.utils import GetterDict
@ -58,8 +60,8 @@ class Nutrition(CamelModel):
class RecipeSummary(CamelModel):
id: Optional[int]
name: str
slug: Optional[str] = ""
name: Optional[str]
slug: str = ""
image: Optional[Any]
description: Optional[str]
@ -98,6 +100,28 @@ class Recipe(RecipeSummary):
org_url: Optional[str] = Field(None, alias="orgURL")
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:
orm_mode = True
@ -140,6 +164,8 @@ class Recipe(RecipeSummary):
@validator("slug", always=True, pre=True)
def validate_slug(slug: str, values):
if not values["name"]:
return slug
name: str = values["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.db.database import db
from mealie.db.db_setup import create_session
from mealie.services.events import create_backup_event
from pathvalidate import sanitize_filename
from pydantic.main import BaseModel
@ -32,7 +33,7 @@ class ExportDatabase:
export_tag = datetime.now().strftime("%Y-%b-%d")
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")
try:
@ -43,7 +44,7 @@ class ExportDatabase:
required_dirs = [
self.main_dir,
self.img_dir,
self.recipes,
self.templates_dir,
]
@ -67,10 +68,10 @@ class ExportDatabase:
with open(out_file, "w") as f:
f.write(content)
def export_images(self):
shutil.copytree(app_dirs.IMG_DIR, self.img_dir, dirs_exist_ok=True)
def export_recipe_dirs(self):
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]
out_dir = self.main_dir.joinpath(folder_name)
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"))
else:
for item in items:
filename = sanitize_filename(f"{item.get('name')}.json")
ExportDatabase._write_json_file(item, out_dir.joinpath(filename))
final_dest = out_dir if not slug_folder else out_dir.joinpath(item.get("slug"))
final_dest.mkdir(exist_ok=True)
filename = sanitize_filename(f"{item.get('slug')}.json")
ExportDatabase._write_json_file(item, final_dest.joinpath(filename))
@staticmethod
def _write_json_file(data: Union[dict, list], out_file: Path):
@ -121,9 +124,9 @@ def backup_all(
if export_recipes:
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_images()
if export_settings:
all_settings = db.settings.get_all(session)
@ -148,3 +151,5 @@ def auto_backup_job():
session = create_session()
backup_all(session=session, tag="Auto", templates=templates)
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 zipfile
from pathlib import Path
from typing import Callable, List
from typing import Callable
from mealie.core.config import app_dirs
from mealie.db.database import db
@ -49,7 +49,7 @@ class ImportDatabase:
def import_recipes(self):
recipe_dir: Path = self.import_dir.joinpath("recipes")
imports = []
successful_imports = []
successful_imports = {}
recipes = ImportDatabase.read_models_file(
file_path=recipe_dir, model=Recipe, single_file=False, migrate=ImportDatabase._recipe_migration
@ -68,7 +68,7 @@ class ImportDatabase:
)
if import_status.status:
successful_imports.append(recipe.slug)
successful_imports.update({recipe.slug: recipe})
imports.append(import_status)
@ -105,15 +105,25 @@ class ImportDatabase:
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")
if image_dir.exists(): # Migrate from before v0.5.0
for image in image_dir.iterdir():
if image.stem in successful_imports:
item: Recipe = successful_imports.get(image.stem)
if item:
dest_dir = item.image_dir
if image.is_dir():
dest = app_dirs.IMG_DIR.joinpath(image.stem)
shutil.copytree(image, dest, dirs_exist_ok=True)
shutil.copytree(image, dest_dir, dirs_exist_ok=True)
if image.is_file():
shutil.copy(image, app_dirs.IMG_DIR)
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()
@ -227,7 +237,7 @@ class ImportDatabase:
return [model(**g) for g in file_data]
all_models = []
for file in file_path.glob("*.json"):
for file in file_path.glob("**/*.json"):
with open(file, "r") as f:
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
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
logger = root_logger.get_logger()
@ -20,47 +20,11 @@ class 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:
try:
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)
image_dir = Recipe(slug=recipe_slug).image_dir
extension = extension.replace(".", "")
image_path = image_dir.joinpath(f"original.{extension}")
image_path.unlink(missing_ok=True)
if isinstance(file_data, 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
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:
if isinstance(image_url, str): # Handles String Types
image_url = image_url
@ -96,7 +54,7 @@ def scrape_image(image_url: str, slug: str) -> Path:
image_url = image_url.get("url")
filename = slug + "." + image_url.split(".")[-1]
filename = app_dirs.IMG_DIR.joinpath(filename)
filename = Recipe(slug=slug).image_dir.joinpath(filename)
try:
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.config import app_dirs
from mealie.db.database import db
from mealie.db.db_setup import create_session
from mealie.schema.recipe import Recipe
from PIL import Image
from sqlalchemy.orm.session import Session
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:
return ImageSizes(
org=sizeof_fmt(org_img),
min=sizeof_fmt(min_img),
tiny=sizeof_fmt(tiny_img),
)
return ImageSizes(org=sizeof_fmt(org_img), min=sizeof_fmt(min_img), tiny=sizeof_fmt(tiny_img))
def minify_image(image_file: Path) -> ImageSizes:
@ -110,28 +104,9 @@ def move_all_images():
if new_file.is_file():
new_file.unlink()
image_file.rename(new_file)
def validate_slugs_in_database(session: Session = None):
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)
if image_file.is_dir():
slug = image_file.name
image_file.rename(Recipe(slug=slug).image_dir)
def migrate_images():
@ -139,7 +114,7 @@ def migrate_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)
@ -148,4 +123,3 @@ def migrate_images():
if __name__ == "__main__":
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.db_setup import create_session
from mealie.schema.user import GroupInDB
from mealie.services.events import create_scheduled_event
from mealie.services.meal_services import get_todays_meal
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:
requests.post(url, json=todays_recipe.json())
create_scheduled_event("Meal Plan Webhook", f"Meal plan webhook executed for group '{group}'")
session.close()