feat: add user recipe export functionality (#845)

* feat(frontend):  add user recipe export functionality

* remove depreciated folders

* change/remove depreciated folders

* add testing variable in config

* add GUID support for group_id

* improve testing feedback on 422 errors

* remove/cleanup files/folders

* initial user export support

* delete unused css

* update backup page UI

* remove depreciated settings

* feat:  export download links

* fix #813

* remove top level statements

* show footer

* add export purger to scheduler

* update purge glob

* fix meal-planner lockout

* feat:  add bulk delete/purge exports

* style(frontend): 💄 update UI for site settings

* feat:  add version checker

* update documentation

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-12-04 14:18:46 -09:00 committed by GitHub
parent 2ce195a0d4
commit c32d7d7486
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
84 changed files with 1329 additions and 667 deletions

1
.gitignore vendored
View File

@ -16,6 +16,7 @@ dev/data/debug/*
dev/data/img/* dev/data/img/*
dev/data/migration/* dev/data/migration/*
dev/data/users/* dev/data/users/*
dev/data/groups/*
.DS_Store .DS_Store
node_modules node_modules

View File

@ -2,35 +2,59 @@
To install Mealie on your server there are a few steps for proper configuration. Let's go through them. To install Mealie on your server there are a few steps for proper configuration. Let's go through them.
!!! tip TLDR
Don't need step by step? Checkout the
- [SQLite docker-compose](./sqlite.md)
- [Postgres docker-compose](./postgres.md)
## Pre-work ## Pre-work
To deploy mealie on your local network it is highly recommended to use docker to deploy the image straight from dockerhub. Using the docker-compose templates provided, you should be able to get a stack up and running easily by changing a few default values and deploying. You can deploy with either SQLite (default) or Postgres. SQLite is sufficient for most use cases. Additionally, with Mealie's automated backup and restore functionality, you can easily move between SQLite and Postgres as you wish. To deploy mealie on your local network it is highly recommended to use docker to deploy the image straight from dockerhub. Using the docker-compose templates provided, you should be able to get a stack up and running easily by changing a few default values and deploying. You can deploy with either SQLite (default) or Postgres. SQLite is sufficient for most use cases. Additionally, with Mealie's automated backup and restore functionality, you can easily move between SQLite and Postgres as you wish.
[Get Docker](https://docs.docker.com/get-docker/) [Get Docker](https://docs.docker.com/get-docker/)
[Get Docker Compose](https://docs.docker.com/compose/install/)
[Mealie on Dockerhub](https://hub.docker.com/r/hkotel/mealie) [Mealie on Dockerhub](https://hub.docker.com/r/hkotel/mealie)
- linux/amd64 - linux/amd64
- linux/arm64 - linux/arm64
!!! warning "32bit Support"
Due to a build dependency limitation, Mealie is not supported on 32bit ARM systems. If you're running into this limitation on a newer Raspberry Pi, please consider upgrading to a 64bit operating system on the Raspberry Pi.
## Step 1: Deciding on Deployment Type ## Step 1: Deciding on Deployment Type
SQLite is a popular, open source, self-contained, zero-configuration database that is the ideal choice for Mealie when you have 1-20 Users. If you need to support many concurrent users, you may want to consider a more robust database such as PostgreSQL. SQLite is a popular, open source, self-contained, zero-configuration database that is the ideal choice for Mealie when you have 1-20 Users and your concurrent write operations will be some-what limited. If you need to support many concurrent users, you may want to consider a more robust database such as PostgreSQL.
You can find the relevant ready to use docker-compose files for supported installations at the links below. You can find the relevant ready to use docker-compose files for supported installations at the links below.
- [SQLite](/mealie/documentation/getting-started/installation/sqlite/) - [SQLite](./sqlite.md)
- [PostgreSQL](/mealie/documentation/getting-started/installation/postgres/) - [PostgreSQL](./postgres.md)
## Step 2: Setting up your files.
The following steps were tested on a Ubuntu 20.04 server, but should work for most other Linux distributions. These steps are not required, but is how I generally will setup services on my server.
1. SSH into your server and navigate to the home directory of the user you want to run Mealie as. If that is your current user, you can use `cd ~` to ensure you're in the right directory.
2. Create a directory called `docker` and navigate into it. `mkdir docker && cd docker`
3. Do the same for mealie `mkdir mealie && cd mealie`
4. Create a docker-compose.yaml file in the mealie directory. `touch docker-compose.yaml`
5. Use the text editor or your choice to edit the file and copy the contents of the docker-compose template for the deployment type you want to use. `nano docker-compose.yaml` or `vi docker-compose.yaml`
## Step 2: Customizing The `docker-compose.yaml` files. ## Step 2: Customizing The `docker-compose.yaml` files.
After you've decided on a database it's important to set a few ENV variables to ensure that you can use all the features of Mealie. I recommend that you verify and check that: After you've decided setup the files it's important to set a few ENV variables to ensure that you can use all the features of Mealie. I recommend that you verify and check that:
- [x] You've configured the relevant ENV variables for your database selection in the `docker-compose.yaml` files. - [x] You've configured the relevant ENV variables for your database selection in the `docker-compose.yaml` files.
- [x] You've configured the [SMTP server settings](/mealie/documentation/getting-started/installation/backend-config/#email) (used for invitations, password resets, etc) - [x] You've configured the [SMTP server settings](./backend-config.md#email) (used for invitations, password resets, etc)
- [x] Verified the port mapped on the `mealie-frontend` container is an open port on your server (Default: 9925) - [x] Verified the port mapped on the `mealie-frontend` container is an open port on your server (Default: 9925)
- [x] You've set the [`BASE_URL`](/mealie/documentation/getting-started/installation/backend-config/#general) variable. - [x] You've set the [`BASE_URL`](./backend-config.md#general) variable.
- [x] You've set the `DEFAULT_EMAIL` and `DEFAULT_GROUP` variable. - [x] You've set the `DEFAULT_EMAIL` and `DEFAULT_GROUP` variable.
- [x] Make any theme changes on the frontend container. [See Frontend Config](/mealie/documentation/getting-started/installation/frontend-config/#themeing) - [x] Make any theme changes on the frontend container. [See Frontend Config](./frontend-config.md#themeing)
## Step 3: Startup ## Step 3: Startup
After you've configured your database, and updated the `docker-compose.yaml` files, you can start Mealie by running the following command in the directory where you've added your `docker-compose.yaml`. After you've configured your database, and updated the `docker-compose.yaml` files, you can start Mealie by running the following command in the directory where you've added your `docker-compose.yaml`.
@ -48,6 +72,6 @@ You should see the containers start up without error. You should now be able to
**Password:** MyPassword **Password:** MyPassword
## Step 4: Backup ## Step 4: Backup
While v1.0.0 is a great step to data-stability and security, it's not a backup. As a core feature, Mealie will run a backup of the entire database every 24 hours. Optionally, you can also run backups whenever you'd like through the UI or the API. While v1.0.0 is a great step to data-stability and security, it's not a backup. As a core feature, Mealie will run a backup every 24 hours. Optionally, you can also run backups whenever you'd like through the UI or the API.
These backups are just plain .zip files that you can download from the UI or access via the mounted volume on your system. For complete data protection you MUST store these backups somewhere safe, and outside of the server where they are deployed. A favorite solution of mine is [autorestic](https://autorestic.vercel.app/) which can be configured via yaml to run an off-site backup on a regular basis. These backups are just plain .zip files that you can download from the UI or access via the mounted volume on your system. For complete data protection you MUST store these backups somewhere safe, and outside of the server where they are deployed. A favorite solution of mine is [autorestic](https://autorestic.vercel.app/) which can be configured via yaml to run an off-site backup on a regular basis.

File diff suppressed because one or more lines are too long

View File

@ -17,6 +17,7 @@ export interface AdminAboutInfo {
dbType: string; dbType: string;
dbUrl: string; dbUrl: string;
defaultGroup: string; defaultGroup: string;
versionLatest: string;
} }
export interface AdminStatistics { export interface AdminStatistics {
@ -31,6 +32,7 @@ export interface CheckAppConfig {
emailReady: boolean; emailReady: boolean;
baseUrlSet: boolean; baseUrlSet: boolean;
isSiteSecure: boolean; isSiteSecure: boolean;
isUpToDate: boolean;
ldapReady: boolean; ldapReady: boolean;
} }

View File

@ -31,10 +31,21 @@ interface BulkActionResponse {
errors: BulkActionError[]; errors: BulkActionError[];
} }
export interface GroupDataExport {
id: string;
groupId: string;
name: string;
filename: string;
path: string;
size: string;
expires: Date;
}
const prefix = "/api"; const prefix = "/api";
const routes = { const routes = {
bulkExport: prefix + "/recipes/bulk-actions/export", bulkExport: prefix + "/recipes/bulk-actions/export",
purgeExports: prefix + "/recipes/bulk-actions/export/purge",
bulkCategorize: prefix + "/recipes/bulk-actions/categorize", bulkCategorize: prefix + "/recipes/bulk-actions/categorize",
bulkTag: prefix + "/recipes/bulk-actions/tag", bulkTag: prefix + "/recipes/bulk-actions/tag",
bulkDelete: prefix + "/recipes/bulk-actions/delete", bulkDelete: prefix + "/recipes/bulk-actions/delete",
@ -56,4 +67,12 @@ export class BulkActionsAPI extends BaseAPI {
async bulkDelete(payload: RecipeBulkDelete) { async bulkDelete(payload: RecipeBulkDelete) {
return await this.requests.post<BulkActionResponse>(routes.bulkDelete, payload); return await this.requests.post<BulkActionResponse>(routes.bulkDelete, payload);
} }
async fetchExports() {
return await this.requests.get<GroupDataExport[]>(routes.bulkExport);
}
async purgeExports() {
return await this.requests.delete(routes.purgeExports);
}
} }

View File

@ -38,18 +38,6 @@ export default {
value: true, value: true,
text: this.$t("general.recipes"), text: this.$t("general.recipes"),
}, },
settings: {
value: true,
text: this.$t("general.settings"),
},
pages: {
value: true,
text: this.$t("settings.pages"),
},
themes: {
value: true,
text: this.$t("general.themes"),
},
users: { users: {
value: true, value: true,
text: this.$t("user.users"), text: this.$t("user.users"),
@ -58,10 +46,6 @@ export default {
value: true, value: true,
text: this.$t("group.groups"), text: this.$t("group.groups"),
}, },
notifications: {
value: true,
text: this.$t("events.notification"),
},
}, },
forceImport: false, forceImport: false,
}; };
@ -73,12 +57,12 @@ export default {
emitValue() { emitValue() {
this.$emit(UPDATE_EVENT, { this.$emit(UPDATE_EVENT, {
recipes: this.options.recipes.value, recipes: this.options.recipes.value,
settings: this.options.settings.value, settings: false,
themes: this.options.themes.value, themes: false,
pages: this.options.pages.value, pages: false,
users: this.options.users.value, users: this.options.users.value,
groups: this.options.groups.value, groups: this.options.groups.value,
notifications: this.options.notifications.value, notifications: false,
forceImport: this.forceImport, forceImport: this.forceImport,
}); });
}, },

View File

@ -0,0 +1,60 @@
<template>
<v-data-table
item-key="id"
:headers="headers"
:items="exports"
:items-per-page="15"
class="elevation-0"
@click:row="downloadData"
>
<template #item.expires="{ item }">
{{ getTimeToExpire(item.expires) }}
</template>
<template #item.actions="{ item }">
<BaseButton download small :download-url="`/api/recipes/bulk-actions/export/download?path=${item.path}`">
</BaseButton>
</template>
</v-data-table>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import { parseISO, formatDistanceToNow } from "date-fns";
import { GroupDataExport } from "~/api/class-interfaces/recipe-bulk-actions";
export default defineComponent({
props: {
exports: {
type: Array as () => GroupDataExport[],
required: true,
},
},
setup() {
const headers = [
{ text: "Export", value: "name" },
{ text: "File Name", value: "filename" },
{ text: "Size", value: "size" },
{ text: "Link Expires", value: "expires" },
{ text: "", value: "actions" },
];
function getTimeToExpire(timeString: string) {
const expiresAt = parseISO(timeString);
return formatDistanceToNow(expiresAt, {
addSuffix: false,
});
}
function downloadData(_: any) {
console.log("Downloading data...");
}
return {
downloadData,
headers,
getTimeToExpire,
};
},
});
</script>

View File

@ -7,6 +7,7 @@
:items="recipes" :items="recipes"
:items-per-page="15" :items-per-page="15"
class="elevation-0" class="elevation-0"
:loading="loading"
@input="setValue(selected)" @input="setValue(selected)"
> >
<template #body.preappend> <template #body.preappend>
@ -22,6 +23,9 @@
<template #item.recipeCategory="{ item }"> <template #item.recipeCategory="{ item }">
<RecipeChip small :items="item.recipeCategory" /> <RecipeChip small :items="item.recipeCategory" />
</template> </template>
<template #item.tools="{ item }">
<RecipeChip small :items="item.tools" />
</template>
<template #item.userId="{ item }"> <template #item.userId="{ item }">
<v-list-item class="justify-start"> <v-list-item class="justify-start">
<v-list-item-avatar> <v-list-item-avatar>
@ -49,6 +53,7 @@ interface ShowHeaders {
owner: Boolean; owner: Boolean;
tags: Boolean; tags: Boolean;
categories: Boolean; categories: Boolean;
tools: Boolean;
recipeYield: Boolean; recipeYield: Boolean;
dateAdded: Boolean; dateAdded: Boolean;
} }
@ -61,6 +66,11 @@ export default defineComponent({
required: false, required: false,
default: () => [], default: () => [],
}, },
loading: {
type: Boolean,
required: false,
default: false,
},
recipes: { recipes: {
type: Array as () => Recipe[], type: Array as () => Recipe[],
default: () => [], default: () => [],
@ -103,12 +113,16 @@ export default defineComponent({
if (show.tags) { if (show.tags) {
hdrs.push({ text: "Tags", value: "tags" }); hdrs.push({ text: "Tags", value: "tags" });
} }
if (show.tools) {
hdrs.push({ text: "Tools", value: "tools" });
}
if (show.recipeYield) { if (show.recipeYield) {
hdrs.push({ text: "Yield", value: "recipeYield" }); hdrs.push({ text: "Yield", value: "recipeYield" });
} }
if (show.dateAdded) { if (show.dateAdded) {
hdrs.push({ text: "Date Added", value: "dateAdded" }); hdrs.push({ text: "Date Added", value: "dateAdded" });
} }
return hdrs; return hdrs;
}); });

View File

@ -1,6 +1,13 @@
<template> <template>
<v-card color="background" flat class="pb-2"> <v-card
color="background"
flat
class="pb-2"
:class="{
'mt-8': section,
}"
>
<v-card-title class="headline pl-0 py-0"> <v-card-title class="headline pl-0 py-0">
<v-icon v-if="icon !== ''" left> <v-icon v-if="icon !== ''" left>
{{ icon }} {{ icon }}
@ -12,7 +19,7 @@
<slot /> <slot />
</p> </p>
</v-card-text> </v-card-text>
<v-divider class="my-3"></v-divider> <v-divider class="mb-3"></v-divider>
</v-card> </v-card>
</template> </template>
@ -27,6 +34,10 @@ export default {
type: String, type: String,
default: "", default: "",
}, },
section: {
type: Boolean,
default: false,
},
}, },
}; };
</script> </script>

View File

@ -2,7 +2,7 @@
<template> <template>
<v-container fluid> <v-container fluid>
<section> <section>
<BaseCardSectionTitle title="Mealie Backups"> </BaseCardSectionTitle> <BaseCardSectionTitle title="Site Backups"> </BaseCardSectionTitle>
<!-- Delete Dialog --> <!-- Delete Dialog -->
<BaseDialog <BaseDialog
@ -25,7 +25,6 @@
:submit-text="$t('general.import')" :submit-text="$t('general.import')"
@submit="importBackup()" @submit="importBackup()"
> >
<!-- <v-card-subtitle v-if="date" class="mb-n3 mt-3"> {{ $d(new Date(date), "medium") }} </v-card-subtitle> -->
<v-divider></v-divider> <v-divider></v-divider>
<v-card-text> <v-card-text>
<AdminBackupImportOptions v-model="selected.options" class="mt-5 mb-2" :import-backup="true" /> <AdminBackupImportOptions v-model="selected.options" class="mt-5 mb-2" :import-backup="true" />
@ -34,73 +33,74 @@
<v-divider></v-divider> <v-divider></v-divider>
</BaseDialog> </BaseDialog>
<v-toolbar flat color="background" class="justify-between"> <v-card outlined>
<BaseButton class="mr-2" @click="createBackup(null)" /> <v-card-title class="py-2"> {{ $t("settings.backup.create-heading") }} </v-card-title>
<!-- Backup Creation Dialog --> <v-divider class="mx-2"></v-divider>
<BaseDialog <v-form @submit.prevent="createBackup()">
v-model="createDialog"
:title="$t('settings.backup.create-heading')"
:icon="$globals.icons.database"
:submit-text="$t('general.create')"
@submit="createBackup"
>
<template #activator="{ open }">
<BaseButton secondary @click="open"> {{ $t("general.custom") }}</BaseButton>
</template>
<v-divider></v-divider>
<v-card-text> <v-card-text>
<v-text-field v-model="backupOptions.tag" :label="$t('settings.backup.backup-tag')"> </v-text-field> Lorem ipsum dolor sit, amet consectetur adipisicing elit. Dolores molestiae alias incidunt fugiat!
<AdminBackupImportOptions v-model="backupOptions.options" class="mt-5 mb-2" /> Recusandae natus numquam iusto voluptates deserunt quia? Sed voluptate rem facilis tempora, perspiciatis
<v-divider class="my-3"></v-divider> corrupti dolore obcaecati laudantium!
<p class="text-uppercase">Templates</p> <div style="max-width: 300px">
<v-checkbox <v-text-field
v-for="(template, index) in backups.templates" v-model="backupOptions.tag"
:key="index" class="mt-4"
v-model="backupOptions.templates" :label="$t('settings.backup.backup-tag') + ' (optional)'"
:value="template" >
:label="template" </v-text-field>
></v-checkbox> <AdminBackupImportOptions v-model="backupOptions.options" class="mt-5 mb-2" />
<v-divider class="my-3"></v-divider>
</div>
<v-card-actions>
<BaseButton type="submit"> </BaseButton>
</v-card-actions>
</v-card-text> </v-card-text>
</BaseDialog> </v-form>
</v-toolbar> </v-card>
<v-data-table <section class="mt-5">
:headers="headers" <BaseCardSectionTitle title="Backups"></BaseCardSectionTitle>
:items="backups.imports || []" <v-data-table
class="elevation-0" :headers="headers"
hide-default-footer :items="backups.imports || []"
disable-pagination class="elevation-0"
:search="search" hide-default-footer
@click:row="setSelected" disable-pagination
> :search="search"
<template #item.date="{ item }"> @click:row="setSelected"
{{ $d(Date.parse(item.date), "medium") }} >
</template> <template #item.date="{ item }">
<template #item.actions="{ item }"> {{ $d(Date.parse(item.date), "medium") }}
<BaseButton </template>
small <template #item.actions="{ item }">
class="mx-1" <v-btn
delete icon
@click.stop=" class="mx-1"
deleteDialog = true; color="error"
deleteTarget = item.name; @click.stop="
" deleteDialog = true;
/> deleteTarget = item.name;
<BaseButton small download :download-url="backupsFileNameDownload(item.name)" @click.stop /> "
</template> >
</v-data-table> <v-icon> {{ $globals.icons.delete }} </v-icon>
<v-divider></v-divider> </v-btn>
<div class="mt-4 d-flex justify-end"> <BaseButton small download :download-url="backupsFileNameDownload(item.name)" @click.stop />
<AppButtonUpload </template>
:text-btn="false" </v-data-table>
class="mr-4" <v-divider></v-divider>
url="/api/backups/upload" <div class="d-flex justify-end mt-6">
accept=".zip" <div>
color="info" <AppButtonUpload
@uploaded="refreshBackups()" :text-btn="false"
/> class="mr-4"
</div> url="/api/backups/upload"
accept=".zip"
color="info"
@uploaded="refreshBackups()"
/>
</div>
</div>
</section>
</section> </section>
</v-container> </v-container>
</template> </template>

View File

@ -10,64 +10,49 @@
<section> <section>
<BaseCardSectionTitle class="pb-0" :icon="$globals.icons.cog" title="General Configuration"> <BaseCardSectionTitle class="pb-0" :icon="$globals.icons.cog" title="General Configuration">
</BaseCardSectionTitle> </BaseCardSectionTitle>
<v-card v-for="(check, idx) in simpleChecks" :key="idx" class="mb-4">
<v-list-item> <v-alert
<v-list-item-avatar> v-for="(check, idx) in simpleChecks"
<v-icon :color="getColor(check.status)"> :key="idx"
{{ check.status ? $globals.icons.checkboxMarkedCircle : $globals.icons.close }} border="left"
</v-icon> colored-border
</v-list-item-avatar> :type="getColor(check.status, check.warning)"
<v-list-item-content> elevation="2"
<v-list-item-title :class="getTextClass(check.status)"> {{ check.text }} </v-list-item-title> >
<v-list-item-subtitle :class="getTextClass(check.status)"> <div class="font-weight-medium">{{ check.text }}</div>
{{ check.status ? check.successText : check.errorText }} <div>
</v-list-item-subtitle> {{ check.status ? check.successText : check.errorText }}
</v-list-item-content> </div>
</v-list-item> </v-alert>
</v-card>
</section> </section>
<section> <section>
<BaseCardSectionTitle class="pt-2" :icon="$globals.icons.email" title="Email Configuration"> <BaseCardSectionTitle class="pt-2" :icon="$globals.icons.email" title="Email Configuration" />
</BaseCardSectionTitle> <v-alert :key="idx" border="left" colored-border :type="getColor(appConfig.emailReady)" elevation="2">
<v-card> <div class="font-weight-medium">Email Configuration Status</div>
<v-card-text> <div>
<v-list-item> {{ appConfig.emailReady ? "Ready" : "Not Ready - Check Environmental Variables" }}
<v-list-item-avatar> </div>
<v-icon :color="getColor(appConfig.emailReady)"> <div>
{{ appConfig.emailReady ? $globals.icons.checkboxMarkedCircle : $globals.icons.close }} <v-text-field v-model="address" class="mr-4" :label="$t('user.email')" :rules="[validators.email]">
</v-icon> </v-text-field>
</v-list-item-avatar> <BaseButton
<v-list-item-content> color="info"
<v-list-item-title :class="getTextClass(appConfig.emailReady)"> :disabled="!appConfig.emailReady || !validEmail"
Email Configuration Status :loading="loading"
</v-list-item-title> @click="testEmail"
<v-list-item-subtitle :class="getTextClass(appConfig.emailReady)"> >
{{ appConfig.emailReady ? "Ready" : "Not Ready - Check Env Variables" }} <template #icon> {{ $globals.icons.email }} </template>
</v-list-item-subtitle> {{ $t("general.test") }}
</v-list-item-content> </BaseButton>
</v-list-item> <template v-if="tested">
<v-card-actions> <v-divider class="my-x"></v-divider>
<v-text-field v-model="address" class="mr-4" :label="$t('user.email')" :rules="[validators.email]"> <v-card-text>
</v-text-field> Email Test Result: {{ success ? "Succeeded" : "Failed" }}
<BaseButton <div>Errors: {{ error }}</div>
color="info" </v-card-text>
:disabled="!appConfig.emailReady || !validEmail" </template>
:loading="loading" </div>
@click="testEmail" </v-alert>
>
<template #icon> {{ $globals.icons.email }} </template>
{{ $t("general.test") }}
</BaseButton>
</v-card-actions>
</v-card-text>
<template v-if="tested">
<v-divider class="my-x"></v-divider>
<v-card-text>
Email Test Result: {{ success ? "Succeeded" : "Failed" }}
<div>Errors: {{ error }}</div>
</v-card-text>
</template>
</v-card>
</section> </section>
<section class="mt-4"> <section class="mt-4">
<BaseCardSectionTitle class="pb-0" :icon="$globals.icons.cog" title="General About"> </BaseCardSectionTitle> <BaseCardSectionTitle class="pb-0" :icon="$globals.icons.cog" title="General About"> </BaseCardSectionTitle>
@ -101,7 +86,7 @@ import {
useAsync, useAsync,
useContext, useContext,
} from "@nuxtjs/composition-api"; } from "@nuxtjs/composition-api";
import { CheckAppConfig } from "~/api/admin/admin-about"; import { AdminAboutInfo, CheckAppConfig } from "~/api/admin/admin-about";
import { useAdminApi, useUserApi } from "~/composables/api"; import { useAdminApi, useUserApi } from "~/composables/api";
import { validators } from "~/composables/use-validators"; import { validators } from "~/composables/use-validators";
import { useAsyncKey } from "~/composables/use-utils"; import { useAsyncKey } from "~/composables/use-utils";
@ -128,6 +113,7 @@ export default defineComponent({
emailReady: false, emailReady: false,
baseUrlSet: false, baseUrlSet: false,
isSiteSecure: false, isSiteSecure: false,
isUpToDate: false,
ldapReady: false, ldapReady: false,
}); });
@ -151,22 +137,34 @@ export default defineComponent({
const simpleChecks = computed<SimpleCheck[]>(() => { const simpleChecks = computed<SimpleCheck[]>(() => {
return [ return [
{ {
status: appConfig.value.baseUrlSet, status: appConfig.value.isUpToDate,
text: "Server Side Base URL", text: "Application Version",
errorText: "`BASE_URL` still default on API Server", errorText: `Your current version (${rawAppInfo.value.version}) does not match the latest release. Considering updating to the latest version (${rawAppInfo.value.versionLatest}).`,
successText: "Server Side URL does not match the default", successText: "Mealie is up to date",
warning: true,
}, },
{ {
status: appConfig.value.isSiteSecure, status: appConfig.value.isSiteSecure,
text: "Secure Site", text: "Secure Site",
errorText: "Serve via localhost or secure with https.", errorText: "Serve via localhost or secure with https. Clipboard and additional browser APIs may not work.",
successText: "Site is accessed by localhost or https", successText: "Site is accessed by localhost or https",
warning: false,
},
{
status: appConfig.value.baseUrlSet,
text: "Server Side Base URL",
errorText:
"`BASE_URL` is still the default value on API Server. This will cause issues with notifications links generated on the server for emails, etc.",
successText: "Server Side URL does not match the default",
warning: false,
}, },
{ {
status: appConfig.value.ldapReady, status: appConfig.value.ldapReady,
text: "LDAP Ready", text: "LDAP Ready",
errorText: "Not all LDAP Values are configured", errorText:
"Not all LDAP Values are configured. This can be ignored if you are not using LDAP Authentication.",
successText: "Required LDAP variables are all set.", successText: "Required LDAP variables are all set.",
warning: true,
}, },
]; ];
}); });
@ -201,23 +199,30 @@ export default defineComponent({
return false; return false;
}); });
function getTextClass(booly: boolean | any) { function getColor(booly: boolean | any, warning = false) {
return booly ? "success--text" : "error--text"; const falsey = warning ? "warning" : "error";
} return booly ? "success" : falsey;
function getColor(booly: boolean | any) {
return booly ? "success" : "error";
} }
// ============================================================ // ============================================================
// General About Info // General About Info
// @ts-ignore // @ts-ignore
const { $globals, i18n } = useContext(); const { $globals, i18n } = useContext();
// @ts-ignore
const rawAppInfo = ref<AdminAboutInfo>({
version: "null",
versionLatest: "null",
});
function getAppInfo() { function getAppInfo() {
const statistics = useAsync(async () => { const statistics = useAsync(async () => {
const { data } = await adminApi.about.about(); const { data } = await adminApi.about.about();
if (data) { if (data) {
rawAppInfo.value = data;
const prettyInfo = [ const prettyInfo = [
{ {
name: i18n.t("about.version"), name: i18n.t("about.version"),
@ -275,7 +280,6 @@ export default defineComponent({
return { return {
simpleChecks, simpleChecks,
getColor, getColor,
getTextClass,
appConfig, appConfig,
validEmail, validEmail,
validators, validators,

View File

@ -382,10 +382,6 @@ export default defineComponent({
</script> </script>
<style lang="css"> <style lang="css">
/* .col-borders {
border-top: 1px solid #e0e0e0;
} */
.left-color-border { .left-color-border {
border-left: 5px solid var(--v-primary-base) !important; border-left: 5px solid var(--v-primary-base) !important;
} }

View File

@ -0,0 +1,392 @@
<template>
<v-container fluid>
<!-- Export Purge Confirmation Dialog -->
<BaseDialog
v-model="purgeExportsDialog"
title="Purge Exports"
color="error"
:icon="$globals.icons.alertCircle"
@confirm="purgeExports()"
>
<v-card-text> Are you sure you want to delete all export data? </v-card-text>
</BaseDialog>
<!-- Base Dialog Object -->
<BaseDialog
ref="domDialog"
v-model="dialog.state"
width="650px"
:icon="dialog.icon"
:title="dialog.title"
submit-text="Submit"
@submit="dialog.callback"
>
<v-card-text v-if="dialog.mode == MODES.tag">
<RecipeCategoryTagSelector v-model="toSetTags" :tag-selector="true" />
</v-card-text>
<v-card-text v-else-if="dialog.mode == MODES.category">
<RecipeCategoryTagSelector v-model="toSetCategories" />
</v-card-text>
<v-card-text v-else-if="dialog.mode == MODES.delete">
<p class="h4">Are you sure you want to delete the following recipes? This action cannot be undone.</p>
<v-card outlined>
<v-virtual-scroll height="400" item-height="25" :items="selected">
<template #default="{ item }">
<v-list-item class="pb-2">
<v-list-item-content>
<v-list-item-title>{{ item.name }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
</template>
</v-virtual-scroll>
</v-card>
</v-card-text>
<v-card-text v-else-if="dialog.mode == MODES.export">
<p class="h4">The following recipes ({{ selected.length }}) will be exported.</p>
<v-card outlined>
<v-virtual-scroll height="400" item-height="25" :items="selected">
<template #default="{ item }">
<v-list-item class="pb-2">
<v-list-item-content>
<v-list-item-title>{{ item.name }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
</template>
</v-virtual-scroll>
</v-card>
</v-card-text>
</BaseDialog>
<BasePageTitle divider>
<template #header>
<v-img max-height="125" max-width="125" :src="require('~/static/svgs/manage-recipes.svg')"></v-img>
</template>
<template #title> Data Management </template>
</BasePageTitle>
<section>
<!-- Recipe Data Table -->
<BaseCardSectionTitle :icon="$globals.icons.primary" section title="Recipe Data">
Use this section to manage the data associated with your recipes. You can perform several bulk actions on your
recipes including exporting, deleting, tagging, and assigning categories.
</BaseCardSectionTitle>
<v-card-actions class="mt-n5 mb-1">
<v-menu offset-y bottom nudge-bottom="6" :close-on-content-click="false">
<template #activator="{ on, attrs }">
<v-btn color="accent" class="mr-1" dark v-bind="attrs" v-on="on">
<v-icon left>
{{ $globals.icons.cog }}
</v-icon>
Columns
</v-btn>
</template>
<v-card>
<v-card-title class="py-2">
<div>Recipe Columns</div>
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text class="mt-n5">
<v-checkbox
v-for="(itemValue, key) in headers"
:key="key"
v-model="headers[key]"
dense
flat
inset
:label="headerLabels[key]"
hide-details
></v-checkbox>
</v-card-text>
</v-card>
</v-menu>
<BaseOverflowButton
:disabled="selected.length < 1"
mode="event"
color="info"
:items="actions"
@export-selected="openDialog(MODES.export)"
@tag-selected="openDialog(MODES.tag)"
@categorize-selected="openDialog(MODES.category)"
@delete-selected="openDialog(MODES.delete)"
>
</BaseOverflowButton>
<p v-if="selected.length > 0" class="text-caption my-auto ml-5">Selected: {{ selected.length }}</p>
</v-card-actions>
<v-card>
<RecipeDataTable v-model="selected" :loading="loading" :recipes="allRecipes" :show-headers="headers" />
<v-card-actions class="justify-end">
<BaseButton color="info">
<template #icon>
{{ $globals.icons.database }}
</template>
Import
</BaseButton>
<BaseButton
color="info"
@click="
selectAll();
openDialog(MODES.export);
"
>
<template #icon>
{{ $globals.icons.database }}
</template>
Export All
</BaseButton>
</v-card-actions>
</v-card>
</section>
<section class="mt-10">
<!-- Downloads Data Table -->
<BaseCardSectionTitle :icon="$globals.icons.database" section title="Data Exports">
This section provides links to available exports that are ready to download. These exports do expire, so be sure
to grab them while they're still available.
</BaseCardSectionTitle>
<v-card-actions class="mt-n5 mb-1">
<BaseButton delete @click="purgeExportsDialog = true"> </BaseButton>
</v-card-actions>
<v-card>
<GroupExportData :exports="groupExports" />
</v-card>
</section>
</v-container>
</template>
<script lang="ts">
import { defineComponent, reactive, ref, useContext, onMounted } from "@nuxtjs/composition-api";
import RecipeDataTable from "~/components/Domain/Recipe/RecipeDataTable.vue";
import RecipeCategoryTagSelector from "~/components/Domain/Recipe/RecipeCategoryTagSelector.vue";
import { useUserApi } from "~/composables/api";
import { useRecipes, allRecipes } from "~/composables/recipes";
import { Recipe } from "~/types/api-types/recipe";
import GroupExportData from "~/components/Domain/Group/GroupExportData.vue";
import { GroupDataExport } from "~/api/class-interfaces/recipe-bulk-actions";
const MODES = {
tag: "tag",
category: "category",
export: "export",
delete: "delete",
};
export default defineComponent({
components: { RecipeDataTable, RecipeCategoryTagSelector, GroupExportData },
scrollToTop: true,
setup() {
const { getAllRecipes, refreshRecipes } = useRecipes(true, true);
// @ts-ignore
const { $globals } = useContext();
const selected = ref<Recipe[]>([]);
function resetAll() {
selected.value = [];
toSetTags.value = [];
toSetCategories.value = [];
loading.value = false;
}
const headers = reactive({
id: false,
owner: false,
tags: true,
tools: "Tools",
categories: true,
recipeYield: false,
dateAdded: false,
});
const headerLabels = {
id: "Id",
owner: "Owner",
tags: "Tags",
categories: "Categories",
tools: "Tools",
recipeYield: "Recipe Yield",
dateAdded: "Date Added",
};
const actions = [
{
icon: $globals.icons.database,
text: "Export",
value: 0,
event: "export-selected",
},
{
icon: $globals.icons.tags,
text: "Tag",
value: 1,
event: "tag-selected",
},
{
icon: $globals.icons.tags,
text: "Categorize",
value: 2,
event: "categorize-selected",
},
{
icon: $globals.icons.delete,
text: "Delete",
value: 3,
event: "delete-selected",
},
];
const api = useUserApi();
const loading = ref(false);
// ===============================================================
// Group Exports
const purgeExportsDialog = ref(false);
async function purgeExports() {
await api.bulk.purgeExports();
refreshExports();
}
const groupExports = ref<GroupDataExport[]>([]);
async function refreshExports() {
const { data } = await api.bulk.fetchExports();
if (data) {
groupExports.value = data;
}
}
onMounted(async () => {
await refreshExports();
});
// ===============================================================
// All Recipes
function selectAll() {
// @ts-ignore
selected.value = allRecipes.value;
}
async function exportSelected() {
loading.value = true;
const { data } = await api.bulk.bulkExport({
recipes: selected.value.map((x: Recipe) => x.slug),
exportType: "json",
});
if (data) {
console.log(data);
}
resetAll();
refreshExports();
}
const toSetTags = ref([]);
async function tagSelected() {
loading.value = true;
const recipes = selected.value.map((x: Recipe) => x.slug);
await api.bulk.bulkTag({ recipes, tags: toSetTags.value });
await refreshRecipes();
resetAll();
}
const toSetCategories = ref([]);
async function categorizeSelected() {
loading.value = true;
const recipes = selected.value.map((x: Recipe) => x.slug);
await api.bulk.bulkCategorize({ recipes, categories: toSetCategories.value });
await refreshRecipes();
resetAll();
}
async function deleteSelected() {
loading.value = true;
const recipes = selected.value.map((x: Recipe) => x.slug);
const { response, data } = await api.bulk.bulkDelete({ recipes });
console.log(response, data);
await refreshRecipes();
resetAll();
}
// ============================================================
// Dialog Management
const dialog = reactive({
state: false,
title: "Tag Recipes",
mode: MODES.tag,
tag: "",
callback: () => {},
icon: $globals.icons.tags,
});
function openDialog(mode: string) {
const titles = {
[MODES.tag]: "Tag Recipes",
[MODES.category]: "Categorize Recipes",
[MODES.export]: "Export Recipes",
[MODES.delete]: "Delete Recipes",
};
const callbacks = {
[MODES.tag]: tagSelected,
[MODES.category]: categorizeSelected,
[MODES.export]: exportSelected,
[MODES.delete]: deleteSelected,
};
const icons = {
[MODES.tag]: $globals.icons.tags,
[MODES.category]: $globals.icons.tags,
[MODES.export]: $globals.icons.database,
[MODES.delete]: $globals.icons.delete,
};
dialog.mode = mode;
dialog.title = titles[mode];
dialog.callback = callbacks[mode];
dialog.icon = icons[mode];
dialog.state = true;
}
return {
selectAll,
loading,
actions,
allRecipes,
categorizeSelected,
deleteSelected,
dialog,
exportSelected,
getAllRecipes,
headerLabels,
headers,
MODES,
openDialog,
selected,
tagSelected,
toSetCategories,
toSetTags,
groupExports,
purgeExportsDialog,
purgeExports,
};
},
head() {
return {
title: "Recipe Data",
};
},
});
</script>

View File

@ -1,274 +0,0 @@
<template>
<v-container fluid>
<!-- Base Dialog Object -->
<BaseDialog
ref="domDialog"
v-model="dialog.state"
width="650px"
:icon="dialog.icon"
:title="dialog.title"
submit-text="Submit"
@submit="dialog.callback"
>
<v-card-text v-if="dialog.mode == MODES.tag">
<RecipeCategoryTagSelector v-model="toSetTags" :tag-selector="true" />
</v-card-text>
<v-card-text v-else-if="dialog.mode == MODES.category">
<RecipeCategoryTagSelector v-model="toSetCategories" />
</v-card-text>
<v-card-text v-else-if="dialog.mode == MODES.delete">
Are you sure you want to delete the following recipes?
<ul class="pt-5">
<li v-for="recipe in selected" :key="recipe.slug">{{ recipe.name }}</li>
</ul>
</v-card-text>
<v-card-text v-else-if="dialog.mode == MODES.export"> TODO: Export Stuff Here </v-card-text>
</BaseDialog>
<BasePageTitle divider>
<template #header>
<v-img max-height="125" max-width="125" :src="require('~/static/svgs/manage-recipes.svg')"></v-img>
</template>
<template #title> Recipe Data Management </template>
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Saepe quidem repudiandae consequatur laboriosam maxime
perferendis nemo asperiores ipsum est, tenetur ratione dolorum sapiente recusandae
</BasePageTitle>
<v-card-actions>
<v-menu offset-y bottom nudge-bottom="6" :close-on-content-click="false">
<template #activator="{ on, attrs }">
<v-btn color="accent" class="mr-1" dark v-bind="attrs" v-on="on">
<v-icon left>
{{ $globals.icons.cog }}
</v-icon>
Columns
</v-btn>
</template>
<v-card>
<v-card-title class="py-2">
<div>Recipe Columns</div>
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text class="mt-n5">
<v-checkbox
v-for="(itemValue, key) in headers"
:key="key"
v-model="headers[key]"
dense
flat
inset
:label="headerLabels[key]"
hide-details
></v-checkbox>
</v-card-text>
</v-card>
</v-menu>
<BaseOverflowButton
:disabled="selected.length < 1"
mode="event"
color="info"
:items="actions"
@export-selected="openDialog(MODES.export)"
@tag-selected="openDialog(MODES.tag)"
@categorize-selected="openDialog(MODES.category)"
@delete-selected="openDialog(MODES.delete)"
>
</BaseOverflowButton>
<p v-if="selected.length > 0" class="text-caption my-auto ml-5">Selected: {{ selected.length }}</p>
</v-card-actions>
<RecipeDataTable v-model="selected" :recipes="allRecipes" :show-headers="headers" />
<v-card-actions class="justify-end">
<BaseButton color="info">
<template #icon>
{{ $globals.icons.database }}
</template>
Import
</BaseButton>
<BaseButton color="info">
<template #icon>
{{ $globals.icons.database }}
</template>
Export All
</BaseButton>
</v-card-actions>
</v-container>
</template>
<script lang="ts">
import { defineComponent, reactive, ref, useContext } from "@nuxtjs/composition-api";
import RecipeDataTable from "~/components/Domain/Recipe/RecipeDataTable.vue";
import RecipeCategoryTagSelector from "~/components/Domain/Recipe/RecipeCategoryTagSelector.vue";
import { useUserApi } from "~/composables/api";
import { useRecipes, allRecipes } from "~/composables/recipes";
import { Recipe } from "~/types/api-types/recipe";
const MODES = {
tag: "tag",
category: "category",
export: "export",
delete: "delete",
};
export default defineComponent({
components: { RecipeDataTable, RecipeCategoryTagSelector },
scrollToTop: true,
setup() {
const { getAllRecipes, refreshRecipes } = useRecipes(true, true);
// @ts-ignore
const { $globals } = useContext();
const selected = ref([]);
function resetAll() {
selected.value = [];
toSetTags.value = [];
toSetCategories.value = [];
}
const headers = reactive({
id: false,
owner: false,
tags: true,
categories: true,
recipeYield: false,
dateAdded: false,
});
const headerLabels = {
id: "Id",
owner: "Owner",
tags: "Tags",
categories: "Categories",
recipeYield: "Recipe Yield",
dateAdded: "Date Added",
};
const actions = [
{
icon: $globals.icons.database,
text: "Export",
value: 0,
event: "export-selected",
},
{
icon: $globals.icons.tags,
text: "Tag",
value: 1,
event: "tag-selected",
},
{
icon: $globals.icons.tags,
text: "Categorize",
value: 2,
event: "categorize-selected",
},
{
icon: $globals.icons.delete,
text: "Delete",
value: 3,
event: "delete-selected",
},
];
const api = useUserApi();
function exportSelected() {
console.log("Export Selected");
}
const toSetTags = ref([]);
async function tagSelected() {
const recipes = selected.value.map((x: Recipe) => x.slug);
await api.bulk.bulkTag({ recipes, tags: toSetTags.value });
await refreshRecipes();
resetAll();
}
const toSetCategories = ref([]);
async function categorizeSelected() {
const recipes = selected.value.map((x: Recipe) => x.slug);
await api.bulk.bulkCategorize({ recipes, categories: toSetCategories.value });
await refreshRecipes();
resetAll();
}
async function deleteSelected() {
const recipes = selected.value.map((x: Recipe) => x.slug);
const { response, data } = await api.bulk.bulkDelete({ recipes });
console.log(response, data);
await refreshRecipes();
resetAll();
}
// ============================================================
// Dialog Management
const dialog = reactive({
state: false,
title: "Tag Recipes",
mode: MODES.tag,
tag: "",
callback: () => {},
icon: $globals.icons.tags,
});
function openDialog(mode: string) {
const titles = {
[MODES.tag]: "Tag Recipes",
[MODES.category]: "Categorize Recipes",
[MODES.export]: "Export Recipes",
[MODES.delete]: "Delete Recipes",
};
const callbacks = {
[MODES.tag]: tagSelected,
[MODES.category]: categorizeSelected,
[MODES.export]: exportSelected,
[MODES.delete]: deleteSelected,
};
const icons = {
[MODES.tag]: $globals.icons.tags,
[MODES.category]: $globals.icons.tags,
[MODES.export]: $globals.icons.database,
[MODES.delete]: $globals.icons.delete,
};
dialog.mode = mode;
dialog.title = titles[mode];
dialog.callback = callbacks[mode];
dialog.icon = icons[mode];
dialog.state = true;
}
return {
toSetTags,
toSetCategories,
openDialog,
dialog,
MODES,
headers,
headerLabels,
exportSelected,
tagSelected,
categorizeSelected,
deleteSelected,
actions,
selected,
allRecipes,
getAllRecipes,
};
},
head() {
return {
title: "Recipe Data",
};
},
});
</script>

View File

@ -110,7 +110,7 @@
</v-col> </v-col>
<v-col v-if="user.advanced" cols="12" sm="12" md="6"> <v-col v-if="user.advanced" cols="12" sm="12" md="6">
<UserProfileLinkCard <UserProfileLinkCard
:link="{ text: 'Manage Recipe Data', to: '/user/group/recipe-data' }" :link="{ text: 'Manage Recipe Data', to: '/user/group/data/recipes' }"
:image="require('~/static/svgs/manage-recipes.svg')" :image="require('~/static/svgs/manage-recipes.svg')"
> >
<template #title> Recipe Data </template> <template #title> Recipe Data </template>

View File

@ -25,6 +25,7 @@ import {
mdiDrag, mdiDrag,
mdiEyeOff, mdiEyeOff,
mdiCalendarMinus, mdiCalendarMinus,
mdiAlertOutline,
mdiCalendar, mdiCalendar,
mdiDiceMultiple, mdiDiceMultiple,
mdiAlertCircle, mdiAlertCircle,
@ -113,6 +114,7 @@ export const icons = {
units: mdiBeakerOutline, units: mdiBeakerOutline,
alert: mdiAlert, alert: mdiAlert,
alertCircle: mdiAlertCircle, alertCircle: mdiAlertCircle,
alertOutline: mdiAlertOutline,
api: mdiApi, api: mdiApi,
arrowLeftBold: mdiArrowLeftBold, arrowLeftBold: mdiArrowLeftBold,
arrowRightBold: mdiArrowRightBold, arrowRightBold: mdiArrowRightBold,

View File

@ -37,6 +37,7 @@ def start_scheduler():
tasks.purge_group_registration, tasks.purge_group_registration,
tasks.auto_backup, tasks.auto_backup,
tasks.purge_password_reset_tokens, tasks.purge_password_reset_tokens,
tasks.purge_group_data_exports,
) )
SchedulerRegistry.register_hourly() SchedulerRegistry.register_hourly()

View File

@ -0,0 +1,41 @@
import datetime
from functools import lru_cache
import requests
_LAST_RESET = None
@lru_cache(maxsize=1)
def get_latest_github_release() -> str:
"""
Gets the latest release from GitHub.
Returns:
str: The latest release from GitHub.
"""
url = "https://api.github.com/repos/hay-kot/mealie/releases/latest"
response = requests.get(url)
response.raise_for_status()
return response.json()["tag_name"]
def get_latest_version() -> str:
"""
Gets the latest release version.
Returns:
str: The latest release version.
"""
MAX_DAYS_OLD = 1 # reset cache after 1 day
global _LAST_RESET
now = datetime.datetime.now()
if not _LAST_RESET or now - _LAST_RESET > datetime.timedelta(days=MAX_DAYS_OLD):
_LAST_RESET = now
get_latest_github_release.cache_clear()
return get_latest_github_release()

View File

@ -2,30 +2,33 @@ from pathlib import Path
class AppDirectories: class AppDirectories:
def __init__(self, data_dir) -> None: def __init__(self, data_dir: Path) -> None:
self.DATA_DIR: Path = data_dir self.DATA_DIR = data_dir
self.IMG_DIR: Path = data_dir.joinpath("img") self.BACKUP_DIR = data_dir.joinpath("backups")
self.BACKUP_DIR: Path = data_dir.joinpath("backups") self.USER_DIR = data_dir.joinpath("users")
self.DEBUG_DIR: Path = data_dir.joinpath("debug") self.RECIPE_DATA_DIR = data_dir.joinpath("recipes")
self.MIGRATION_DIR: Path = data_dir.joinpath("migration") self.TEMPLATE_DIR = data_dir.joinpath("templates")
self.NEXTCLOUD_DIR: Path = self.MIGRATION_DIR.joinpath("nextcloud")
self.CHOWDOWN_DIR: Path = self.MIGRATION_DIR.joinpath("chowdown")
self.TEMPLATE_DIR: Path = data_dir.joinpath("templates")
self.USER_DIR: Path = data_dir.joinpath("users")
self.RECIPE_DATA_DIR: Path = data_dir.joinpath("recipes")
self.TEMP_DIR: Path = data_dir.joinpath(".temp")
self.GROUPS_DIR = self.DATA_DIR.joinpath("groups")
# Deprecated
self._TEMP_DIR = data_dir.joinpath(".temp")
self._IMG_DIR = data_dir.joinpath("img")
self.ensure_directories() self.ensure_directories()
@property
def IMG_DIR(self):
return self._IMG_DIR
@property
def TEMP_DIR(self):
return self._TEMP_DIR
def ensure_directories(self): def ensure_directories(self):
required_dirs = [ required_dirs = [
self.IMG_DIR, self.GROUPS_DIR,
self.BACKUP_DIR, self.BACKUP_DIR,
self.DEBUG_DIR,
self.MIGRATION_DIR,
self.TEMPLATE_DIR, self.TEMPLATE_DIR,
self.NEXTCLOUD_DIR,
self.CHOWDOWN_DIR,
self.RECIPE_DATA_DIR, self.RECIPE_DATA_DIR,
self.USER_DIR, self.USER_DIR,
] ]

View File

@ -102,6 +102,11 @@ class AppSettings(BaseSettings):
not_none = None not in required not_none = None not in required
return self.LDAP_AUTH_ENABLED and not_none return self.LDAP_AUTH_ENABLED and not_none
# ===============================================
# Testing Config
TESTING: bool = False
class Config: class Config:
arbitrary_types_allowed = True arbitrary_types_allowed = True

View File

@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
from mealie.db.models.event import Event, EventNotification from mealie.db.models.event import Event, EventNotification
from mealie.db.models.group import Group, GroupMealPlan, ReportEntryModel, ReportModel from mealie.db.models.group import Group, GroupMealPlan, ReportEntryModel, ReportModel
from mealie.db.models.group.cookbook import CookBook from mealie.db.models.group.cookbook import CookBook
from mealie.db.models.group.exports import GroupDataExportsModel
from mealie.db.models.group.invite_tokens import GroupInviteToken from mealie.db.models.group.invite_tokens import GroupInviteToken
from mealie.db.models.group.preferences import GroupPreferencesModel from mealie.db.models.group.preferences import GroupPreferencesModel
from mealie.db.models.group.webhooks import GroupWebhooksModel from mealie.db.models.group.webhooks import GroupWebhooksModel
@ -21,6 +22,7 @@ from mealie.db.models.users.password_reset import PasswordResetModel
from mealie.schema.cookbook.cookbook import ReadCookBook from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.events import Event as EventSchema from mealie.schema.events import Event as EventSchema
from mealie.schema.events import EventNotificationIn from mealie.schema.events import EventNotificationIn
from mealie.schema.group.group_exports import GroupDataExport
from mealie.schema.group.group_preferences import ReadGroupPreferences from mealie.schema.group.group_preferences import ReadGroupPreferences
from mealie.schema.group.invite_token import ReadInviteToken from mealie.schema.group.invite_token import ReadInviteToken
from mealie.schema.group.webhook import ReadWebhook from mealie.schema.group.webhook import ReadWebhook
@ -42,6 +44,7 @@ from .user_access_model import UserDataAccessModel
pk_id = "id" pk_id = "id"
pk_slug = "slug" pk_slug = "slug"
pk_token = "token" pk_token = "token"
pk_group_id = "group_id"
class CategoryDataAccessModel(AccessModel): class CategoryDataAccessModel(AccessModel):
@ -143,7 +146,11 @@ class Database:
@cached_property @cached_property
def group_preferences(self) -> AccessModel[ReadGroupPreferences, GroupPreferencesModel]: def group_preferences(self) -> AccessModel[ReadGroupPreferences, GroupPreferencesModel]:
return AccessModel(self.session, "group_id", GroupPreferencesModel, ReadGroupPreferences) return AccessModel(self.session, pk_group_id, GroupPreferencesModel, ReadGroupPreferences)
@cached_property
def group_exports(self) -> AccessModel[GroupDataExport, GroupDataExportsModel]:
return AccessModel(self.session, pk_id, GroupDataExportsModel, GroupDataExport)
@cached_property @cached_property
def meals(self) -> MealDataAccessModel: def meals(self) -> MealDataAccessModel:

View File

@ -1,4 +1,5 @@
from datetime import date from datetime import date
from uuid import UUID
from mealie.db.models.group import GroupMealPlan from mealie.db.models.group import GroupMealPlan
from mealie.schema.meal_plan.new_meal import ReadPlanEntry from mealie.schema.meal_plan.new_meal import ReadPlanEntry
@ -7,7 +8,7 @@ from ._access_model import AccessModel
class MealDataAccessModel(AccessModel[ReadPlanEntry, GroupMealPlan]): class MealDataAccessModel(AccessModel[ReadPlanEntry, GroupMealPlan]):
def get_slice(self, start: date, end: date, group_id: int) -> list[ReadPlanEntry]: def get_slice(self, start: date, end: date, group_id: UUID) -> list[ReadPlanEntry]:
start = start.strftime("%Y-%m-%d") start = start.strftime("%Y-%m-%d")
end = end.strftime("%Y-%m-%d") end = end.strftime("%Y-%m-%d")
qry = self.session.query(GroupMealPlan).filter( qry = self.session.query(GroupMealPlan).filter(
@ -17,7 +18,7 @@ class MealDataAccessModel(AccessModel[ReadPlanEntry, GroupMealPlan]):
return [self.schema.from_orm(x) for x in qry.all()] return [self.schema.from_orm(x) for x in qry.all()]
def get_today(self, group_id: int) -> list[ReadPlanEntry]: def get_today(self, group_id: UUID) -> list[ReadPlanEntry]:
today = date.today() today = date.today()
qry = self.session.query(GroupMealPlan).filter(GroupMealPlan.date == today, GroupMealPlan.group_id == group_id) qry = self.session.query(GroupMealPlan).filter(GroupMealPlan.date == today, GroupMealPlan.group_id == group_id)

View File

@ -62,14 +62,19 @@ class RecipeDataAccessModel(AccessModel[Recipe, RecipeModel]):
override_schema=override_schema, override_schema=override_schema,
) )
def summary(self, group_id, start=0, limit=99999) -> Any: def summary(self, group_id, start=0, limit=99999, load_foods=False) -> Any:
args = [
joinedload(RecipeModel.recipe_category),
joinedload(RecipeModel.tags),
joinedload(RecipeModel.tools),
]
if load_foods:
args.append(joinedload(RecipeModel.recipe_ingredient).options(joinedload(RecipeIngredient.food)))
return ( return (
self.session.query(RecipeModel) self.session.query(RecipeModel)
.options( .options(*args)
joinedload(RecipeModel.recipe_category),
joinedload(RecipeModel.tags),
joinedload(RecipeModel.recipe_ingredient).options(joinedload(RecipeIngredient.food)),
)
.filter(RecipeModel.group_id == group_id) .filter(RecipeModel.group_id == group_id)
.offset(start) .offset(start)
.limit(limit) .limit(limit)

View File

@ -1 +1,2 @@
from .auto_init import auto_init from .auto_init import auto_init
from .guid import GUID

View File

@ -1,4 +1,5 @@
from .cookbook import * from .cookbook import *
from .exports import *
from .group import * from .group import *
from .invite_tokens import * from .invite_tokens import *
from .mealplan import * from .mealplan import *

View File

@ -1,7 +1,7 @@
from sqlalchemy import Column, ForeignKey, Integer, String, orm from sqlalchemy import Column, ForeignKey, Integer, String, orm
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init from .._model_utils import auto_init, guid
from ..recipe.category import Category, cookbooks_to_categories from ..recipe.category import Category, cookbooks_to_categories
@ -14,7 +14,7 @@ class CookBook(SqlAlchemyBase, BaseMixins):
slug = Column(String, nullable=False) slug = Column(String, nullable=False)
categories = orm.relationship(Category, secondary=cookbooks_to_categories, single_parent=True) categories = orm.relationship(Category, secondary=cookbooks_to_categories, single_parent=True)
group_id = Column(Integer, ForeignKey("groups.id")) group_id = Column(guid.GUID, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="cookbooks") group = orm.relationship("Group", back_populates="cookbooks")
@auto_init() @auto_init()

View File

@ -0,0 +1,24 @@
from uuid import uuid4
from sqlalchemy import Column, ForeignKey, String, orm
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import GUID, auto_init
class GroupDataExportsModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "group_data_exports"
id = Column(GUID, primary_key=True, default=uuid4)
group = orm.relationship("Group", back_populates="data_exports", single_parent=True)
group_id = Column(GUID, ForeignKey("groups.id"), index=True)
name = Column(String, nullable=False)
filename = Column(String, nullable=False)
path = Column(String, nullable=False)
size = Column(String, nullable=False)
expires = Column(String, nullable=False)
@auto_init()
def __init__(self, **_) -> None:
pass

View File

@ -1,15 +1,17 @@
import uuid
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.orm as orm import sqlalchemy.orm as orm
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from mealie.core.config import get_app_settings from mealie.core.config import get_app_settings
from mealie.db.models.group.invite_tokens import GroupInviteToken
from mealie.db.models.server.task import ServerTaskModel
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init from .._model_utils import GUID, auto_init
from ..group.invite_tokens import GroupInviteToken
from ..group.webhooks import GroupWebhooksModel from ..group.webhooks import GroupWebhooksModel
from ..recipe.category import Category, group2categories from ..recipe.category import Category, group2categories
from ..server.task import ServerTaskModel
from .cookbook import CookBook from .cookbook import CookBook
from .mealplan import GroupMealPlan from .mealplan import GroupMealPlan
from .preferences import GroupPreferencesModel from .preferences import GroupPreferencesModel
@ -19,6 +21,7 @@ settings = get_app_settings()
class Group(SqlAlchemyBase, BaseMixins): class Group(SqlAlchemyBase, BaseMixins):
__tablename__ = "groups" __tablename__ = "groups"
id = sa.Column(GUID, primary_key=True, default=uuid.uuid4)
name = sa.Column(sa.String, index=True, nullable=False, unique=True) name = sa.Column(sa.String, index=True, nullable=False, unique=True)
users = orm.relationship("User", back_populates="group") users = orm.relationship("User", back_populates="group")
categories = orm.relationship(Category, secondary=group2categories, single_parent=True, uselist=True) categories = orm.relationship(Category, secondary=group2categories, single_parent=True, uselist=True)
@ -48,11 +51,21 @@ class Group(SqlAlchemyBase, BaseMixins):
webhooks = orm.relationship(GroupWebhooksModel, **common_args) webhooks = orm.relationship(GroupWebhooksModel, **common_args)
cookbooks = orm.relationship(CookBook, **common_args) cookbooks = orm.relationship(CookBook, **common_args)
server_tasks = orm.relationship(ServerTaskModel, **common_args) server_tasks = orm.relationship(ServerTaskModel, **common_args)
data_exports = orm.relationship("GroupDataExportsModel", **common_args)
shopping_lists = orm.relationship("ShoppingList", **common_args) shopping_lists = orm.relationship("ShoppingList", **common_args)
group_reports = orm.relationship("ReportModel", **common_args) group_reports = orm.relationship("ReportModel", **common_args)
class Config: class Config:
exclude = {"users", "webhooks", "shopping_lists", "cookbooks", "preferences", "invite_tokens", "mealplans"} exclude = {
"users",
"webhooks",
"shopping_lists",
"cookbooks",
"preferences",
"invite_tokens",
"mealplans",
"data_exports",
}
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:

View File

@ -1,7 +1,7 @@
from sqlalchemy import Column, ForeignKey, Integer, String, orm from sqlalchemy import Column, ForeignKey, Integer, String, orm
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init from .._model_utils import auto_init, guid
class GroupInviteToken(SqlAlchemyBase, BaseMixins): class GroupInviteToken(SqlAlchemyBase, BaseMixins):
@ -9,7 +9,7 @@ class GroupInviteToken(SqlAlchemyBase, BaseMixins):
token = Column(String, index=True, nullable=False, unique=True) token = Column(String, index=True, nullable=False, unique=True)
uses_left = Column(Integer, nullable=False, default=1) uses_left = Column(Integer, nullable=False, default=1)
group_id = Column(Integer, ForeignKey("groups.id")) group_id = Column(guid.GUID, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="invite_tokens") group = orm.relationship("Group", back_populates="invite_tokens")
@auto_init() @auto_init()

View File

@ -2,7 +2,7 @@ from sqlalchemy import Column, Date, ForeignKey, String, orm
from sqlalchemy.sql.sqltypes import Integer from sqlalchemy.sql.sqltypes import Integer
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init from .._model_utils import GUID, auto_init
class GroupMealPlan(SqlAlchemyBase, BaseMixins): class GroupMealPlan(SqlAlchemyBase, BaseMixins):
@ -13,7 +13,7 @@ class GroupMealPlan(SqlAlchemyBase, BaseMixins):
title = Column(String, index=True, nullable=False) title = Column(String, index=True, nullable=False)
text = Column(String, nullable=False) text = Column(String, nullable=False)
group_id = Column(Integer, ForeignKey("groups.id"), index=True) group_id = Column(GUID, ForeignKey("groups.id"), index=True)
group = orm.relationship("Group", back_populates="mealplans") group = orm.relationship("Group", back_populates="mealplans")
recipe_id = Column(Integer, ForeignKey("recipes.id")) recipe_id = Column(Integer, ForeignKey("recipes.id"))

View File

@ -1,13 +1,15 @@
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.orm as orm import sqlalchemy.orm as orm
from mealie.db.models._model_utils.guid import GUID
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init from .._model_utils import auto_init
class GroupPreferencesModel(SqlAlchemyBase, BaseMixins): class GroupPreferencesModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "group_preferences" __tablename__ = "group_preferences"
group_id = sa.Column(sa.Integer, sa.ForeignKey("groups.id")) group_id = sa.Column(GUID, sa.ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="preferences") group = orm.relationship("Group", back_populates="preferences")
private_group: bool = sa.Column(sa.Boolean, default=True) private_group: bool = sa.Column(sa.Boolean, default=True)

View File

@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import Column, ForeignKey, Integer, orm from sqlalchemy import Column, ForeignKey, orm
from sqlalchemy.sql.sqltypes import Boolean, DateTime, String from sqlalchemy.sql.sqltypes import Boolean, DateTime, String
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
@ -12,14 +12,14 @@ from .._model_utils.guid import GUID
class ReportEntryModel(SqlAlchemyBase, BaseMixins): class ReportEntryModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "report_entries" __tablename__ = "report_entries"
id = Column(GUID(), primary_key=True, default=uuid4) id = Column(GUID, primary_key=True, default=uuid4)
success = Column(Boolean, default=False) success = Column(Boolean, default=False)
message = Column(String, nullable=True) message = Column(String, nullable=True)
exception = Column(String, nullable=True) exception = Column(String, nullable=True)
timestamp = Column(DateTime, nullable=False, default=datetime.utcnow) timestamp = Column(DateTime, nullable=False, default=datetime.utcnow)
report_id = Column(GUID(), ForeignKey("group_reports.id"), nullable=False) report_id = Column(GUID, ForeignKey("group_reports.id"), nullable=False)
report = orm.relationship("ReportModel", back_populates="entries") report = orm.relationship("ReportModel", back_populates="entries")
@auto_init() @auto_init()
@ -29,7 +29,7 @@ class ReportEntryModel(SqlAlchemyBase, BaseMixins):
class ReportModel(SqlAlchemyBase, BaseMixins): class ReportModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "group_reports" __tablename__ = "group_reports"
id = Column(GUID(), primary_key=True, default=uuid4) id = Column(GUID, primary_key=True, default=uuid4)
name = Column(String, nullable=False) name = Column(String, nullable=False)
status = Column(String, nullable=False) status = Column(String, nullable=False)
@ -39,7 +39,7 @@ class ReportModel(SqlAlchemyBase, BaseMixins):
entries = orm.relationship(ReportEntryModel, back_populates="report", cascade="all, delete-orphan") entries = orm.relationship(ReportEntryModel, back_populates="report", cascade="all, delete-orphan")
# Relationships # Relationships
group_id = Column(Integer, ForeignKey("groups.id")) group_id = Column(GUID, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="group_reports", single_parent=True) group = orm.relationship("Group", back_populates="group_reports", single_parent=True)
class Config: class Config:

View File

@ -4,6 +4,7 @@ from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
from sqlalchemy.ext.orderinglist import ordering_list from sqlalchemy.ext.orderinglist import ordering_list
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils.guid import GUID
from .group import Group from .group import Group
@ -29,7 +30,7 @@ class ShoppingList(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_lists" __tablename__ = "shopping_lists"
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
group_id = Column(Integer, ForeignKey("groups.id")) group_id = Column(GUID, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="shopping_lists") group = orm.relationship("Group", back_populates="shopping_lists")
name = Column(String) name = Column(String)

View File

@ -1,8 +1,7 @@
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import GUID, auto_init
from .._model_utils import auto_init
class GroupWebhooksModel(SqlAlchemyBase, BaseMixins): class GroupWebhooksModel(SqlAlchemyBase, BaseMixins):
@ -10,7 +9,7 @@ class GroupWebhooksModel(SqlAlchemyBase, BaseMixins):
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
group = orm.relationship("Group", back_populates="webhooks", single_parent=True) group = orm.relationship("Group", back_populates="webhooks", single_parent=True)
group_id = Column(Integer, ForeignKey("groups.id"), index=True) group_id = Column(GUID, ForeignKey("groups.id"), index=True)
enabled = Column(Boolean, default=False) enabled = Column(Boolean, default=False)
name = Column(String) name = Column(String)

View File

@ -4,7 +4,9 @@ from slugify import slugify
from sqlalchemy.orm import validates from sqlalchemy.orm import validates
from mealie.core import root_logger from mealie.core import root_logger
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils.guid import GUID
logger = root_logger.get_logger() logger = root_logger.get_logger()
@ -12,7 +14,7 @@ logger = root_logger.get_logger()
group2categories = sa.Table( group2categories = sa.Table(
"group2categories", "group2categories",
SqlAlchemyBase.metadata, SqlAlchemyBase.metadata,
sa.Column("group_id", sa.Integer, sa.ForeignKey("groups.id")), sa.Column("group_id", GUID, sa.ForeignKey("groups.id")),
sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id")), sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id")),
) )

View File

@ -9,7 +9,7 @@ from mealie.db.models._model_utils.guid import GUID
class RecipeComment(SqlAlchemyBase, BaseMixins): class RecipeComment(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_comments" __tablename__ = "recipe_comments"
id = Column(GUID(), primary_key=True, default=uuid4) id = Column(GUID, primary_key=True, default=uuid4)
text = Column(String) text = Column(String)
# Recipe Link # Recipe Link

View File

@ -49,7 +49,7 @@ class RecipeIngredient(SqlAlchemyBase, BaseMixins):
food = orm.relationship(IngredientFoodModel, uselist=False) food = orm.relationship(IngredientFoodModel, uselist=False)
quantity = Column(Integer) quantity = Column(Integer)
reference_id = Column(GUID()) # Reference Links reference_id = Column(GUID) # Reference Links
# Extras # Extras

View File

@ -10,7 +10,7 @@ from .._model_utils.guid import GUID
class RecipeIngredientRefLink(SqlAlchemyBase, BaseMixins): class RecipeIngredientRefLink(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_ingredient_ref_link" __tablename__ = "recipe_ingredient_ref_link"
instruction_id = Column(Integer, ForeignKey("recipe_instructions.id")) instruction_id = Column(Integer, ForeignKey("recipe_instructions.id"))
reference_id = Column(GUID()) reference_id = Column(GUID)
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:
@ -19,7 +19,7 @@ class RecipeIngredientRefLink(SqlAlchemyBase, BaseMixins):
class RecipeInstruction(SqlAlchemyBase): class RecipeInstruction(SqlAlchemyBase):
__tablename__ = "recipe_instructions" __tablename__ = "recipe_instructions"
id = Column(GUID(), primary_key=True, default=uuid4) id = Column(GUID, primary_key=True, default=uuid4)
parent_id = Column(Integer, ForeignKey("recipes.id")) parent_id = Column(Integer, ForeignKey("recipes.id"))
position = Column(Integer) position = Column(Integer)
type = Column(String, default="") type = Column(String, default="")

View File

@ -6,6 +6,8 @@ import sqlalchemy.orm as orm
from sqlalchemy.ext.orderinglist import ordering_list from sqlalchemy.ext.orderinglist import ordering_list
from sqlalchemy.orm import validates from sqlalchemy.orm import validates
from mealie.db.models._model_utils.guid import GUID
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init from .._model_utils import auto_init
from ..users import users_to_favorites from ..users import users_to_favorites
@ -43,13 +45,13 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
slug = sa.Column(sa.String, index=True) slug = sa.Column(sa.String, index=True)
# ID Relationships # ID Relationships
group_id = sa.Column(sa.Integer, sa.ForeignKey("groups.id")) group_id = sa.Column(GUID, sa.ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="recipes", foreign_keys=[group_id]) group = orm.relationship("Group", back_populates="recipes", foreign_keys=[group_id])
user_id = sa.Column(sa.Integer, sa.ForeignKey("users.id")) user_id = sa.Column(sa.Integer, sa.ForeignKey("users.id"))
user = orm.relationship("User", uselist=False, foreign_keys=[user_id]) user = orm.relationship("User", uselist=False, foreign_keys=[user_id])
meal_entries = orm.relationship("GroupMealPlan", back_populates="recipe") meal_entries = orm.relationship("GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan")
favorited_by = orm.relationship("User", secondary=users_to_favorites, back_populates="favorite_recipes") favorited_by = orm.relationship("User", secondary=users_to_favorites, back_populates="favorite_recipes")

View File

@ -1,6 +1,7 @@
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, orm from sqlalchemy import Column, DateTime, ForeignKey, String, orm
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID
from .._model_utils import auto_init from .._model_utils import auto_init
@ -12,7 +13,7 @@ class ServerTaskModel(SqlAlchemyBase, BaseMixins):
status = Column(String, nullable=False) status = Column(String, nullable=False)
log = Column(String, nullable=True) log = Column(String, nullable=True)
group_id = Column(Integer, ForeignKey("groups.id")) group_id = Column(GUID, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="server_tasks") group = orm.relationship("Group", back_populates="server_tasks")
@auto_init() @auto_init()

View File

@ -1,14 +1,13 @@
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
from mealie.core.config import get_app_settings from mealie.core.config import get_app_settings
from mealie.db.models._model_utils.guid import GUID
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init from .._model_utils import auto_init
from ..group import Group from ..group import Group
from .user_to_favorite import users_to_favorites from .user_to_favorite import users_to_favorites
settings = get_app_settings()
class LongLiveToken(SqlAlchemyBase, BaseMixins): class LongLiveToken(SqlAlchemyBase, BaseMixins):
__tablename__ = "long_live_tokens" __tablename__ = "long_live_tokens"
@ -32,7 +31,7 @@ class User(SqlAlchemyBase, BaseMixins):
admin = Column(Boolean, default=False) admin = Column(Boolean, default=False)
advanced = Column(Boolean, default=False) advanced = Column(Boolean, default=False)
group_id = Column(Integer, ForeignKey("groups.id")) group_id = Column(GUID, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="users") group = orm.relationship("Group", back_populates="users")
# Group Permissions # Group Permissions
@ -40,17 +39,15 @@ class User(SqlAlchemyBase, BaseMixins):
can_invite = Column(Boolean, default=False) can_invite = Column(Boolean, default=False)
can_organize = Column(Boolean, default=False) can_organize = Column(Boolean, default=False)
tokens: list[LongLiveToken] = orm.relationship( sp_args = {
LongLiveToken, back_populates="user", cascade="all, delete, delete-orphan", single_parent=True "back_populates": "user",
) "cascade": "all, delete, delete-orphan",
"single_parent": True,
}
comments: list = orm.relationship( tokens = orm.relationship(LongLiveToken, **sp_args)
"RecipeComment", back_populates="user", cascade="all, delete, delete-orphan", single_parent=True comments = orm.relationship("RecipeComment", **sp_args)
) password_reset_tokens = orm.relationship("PasswordResetModel", **sp_args)
password_reset_tokens = orm.relationship(
"PasswordResetModel", back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
)
owned_recipes_id = Column(Integer, ForeignKey("recipes.id")) owned_recipes_id = Column(Integer, ForeignKey("recipes.id"))
owned_recipes = orm.relationship("RecipeModel", single_parent=True, foreign_keys=[owned_recipes_id]) owned_recipes = orm.relationship("RecipeModel", single_parent=True, foreign_keys=[owned_recipes_id])
@ -65,11 +62,14 @@ class User(SqlAlchemyBase, BaseMixins):
"can_invite", "can_invite",
"can_organize", "can_organize",
"group", "group",
"username",
} }
@auto_init() @auto_init()
def __init__(self, session, full_name, password, group: str = settings.DEFAULT_GROUP, **kwargs) -> None: def __init__(self, session, full_name, password, group: str = None, **kwargs) -> None:
if group is None:
settings = get_app_settings()
group = settings.DEFAULT_GROUP
self.group = Group.get_ref(session, group) self.group = Group.get_ref(session, group)
self.favorite_recipes = [] self.favorite_recipes = []

View File

@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from mealie.core.config import get_app_settings from mealie.core.config import get_app_settings
from mealie.core.release_checker import get_latest_version
from mealie.core.settings.static import APP_VERSION from mealie.core.settings.static import APP_VERSION
from mealie.db.database import get_database from mealie.db.database import get_database
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
@ -18,6 +19,7 @@ async def get_app_info():
return AdminAboutInfo( return AdminAboutInfo(
production=settings.PRODUCTION, production=settings.PRODUCTION,
version=APP_VERSION, version=APP_VERSION,
versionLatest=get_latest_version(),
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,
@ -49,4 +51,5 @@ async def check_app_config():
email_ready=settings.SMTP_ENABLE, email_ready=settings.SMTP_ENABLE,
ldap_ready=settings.LDAP_ENABLED, ldap_ready=settings.LDAP_ENABLED,
base_url_set=url_set, base_url_set=url_set,
is_up_to_date=get_latest_version() == APP_VERSION,
) )

View File

@ -1,70 +0,0 @@
from fastapi import BackgroundTasks, Depends, HTTPException, status
from sqlalchemy.orm.session import Session
from mealie.core.dependencies import get_current_user
from mealie.db.database import get_database
from mealie.db.db_setup import generate_session
from mealie.routes.routers import AdminAPIRouter
from mealie.schema.user import GroupBase, GroupInDB, PrivateUser, UpdateGroup
from mealie.services.events import create_group_event
router = AdminAPIRouter(prefix="/groups")
@router.get("", response_model=list[GroupInDB])
async def get_all_groups(session: Session = Depends(generate_session)):
"""Returns a list of all groups in the database"""
db = get_database(session)
return db.groups.get_all()
@router.post("", status_code=status.HTTP_201_CREATED, response_model=GroupInDB)
async def create_group(
background_tasks: BackgroundTasks,
group_data: GroupBase,
session: Session = Depends(generate_session),
):
"""Creates a Group in the Database"""
db = get_database(session)
try:
new_group = db.groups.create(group_data.dict())
background_tasks.add_task(create_group_event, "Group Created", f"'{group_data.name}' created", session)
return new_group
except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST)
@router.put("/{id}")
async def update_group_data(id: int, group_data: UpdateGroup, session: Session = Depends(generate_session)):
"""Updates a User Group"""
db = get_database(session)
db.groups.update(id, group_data.dict())
@router.delete("/{id}")
async def delete_user_group(
background_tasks: BackgroundTasks,
id: int,
current_user: PrivateUser = Depends(get_current_user),
session: Session = Depends(generate_session),
):
"""Removes a user group from the database"""
db = get_database(session)
if id == 1:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="DEFAULT_GROUP")
group: GroupInDB = db.groups.get(id)
if not group:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="GROUP_NOT_FOUND")
if group.users != []:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="GROUP_WITH_USERS")
background_tasks.add_task(
create_group_event, "Group Deleted", f"'{group.name}' deleted by {current_user.full_name}", session
)
db.groups.delete(id)

View File

@ -18,7 +18,7 @@ def log_wrapper(request: Request, e):
def register_debug_handler(app: FastAPI): def register_debug_handler(app: FastAPI):
settings = get_app_settings() settings = get_app_settings()
if settings.PRODUCTION: if settings.PRODUCTION and not settings.TESTING:
return return
@app.exception_handler(RequestValidationError) @app.exception_handler(RequestValidationError)

View File

@ -13,3 +13,4 @@ router.include_router(recipe_crud_routes.user_router, prefix=prefix, tags=["Reci
router.include_router(image_and_assets.user_router, prefix=prefix, tags=["Recipe: Images and Assets"]) router.include_router(image_and_assets.user_router, prefix=prefix, tags=["Recipe: Images and Assets"])
router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"]) router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"])
router.include_router(bulk_actions.router, prefix=prefix, tags=["Recipe: Bulk Actions"]) router.include_router(bulk_actions.router, prefix=prefix, tags=["Recipe: Bulk Actions"])
router.include_router(bulk_actions.export_router, prefix=prefix, tags=["Recipe: Bulk Exports"])

View File

@ -1,7 +1,10 @@
from pathlib import Path
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from fastapi.responses import FileResponse
from mealie.core.dependencies.dependencies import temporary_zip_path from mealie.core.dependencies.dependencies import temporary_zip_path
from mealie.core.security import create_file_token
from mealie.schema.group.group_exports import GroupDataExport
from mealie.schema.recipe.recipe_bulk_actions import ( from mealie.schema.recipe.recipe_bulk_actions import (
AssignCategories, AssignCategories,
AssignTags, AssignTags,
@ -38,7 +41,10 @@ def bulk_delete_recipes(
bulk_service.delete_recipes(delete_recipes.recipes) bulk_service.delete_recipes(delete_recipes.recipes)
@router.post("/export", response_class=FileResponse) export_router = APIRouter(prefix="/bulk-actions")
@export_router.post("/export")
def bulk_export_recipes( def bulk_export_recipes(
export_recipes: ExportRecipes, export_recipes: ExportRecipes,
temp_path=Depends(temporary_zip_path), temp_path=Depends(temporary_zip_path),
@ -46,4 +52,26 @@ def bulk_export_recipes(
): ):
bulk_service.export_recipes(temp_path, export_recipes.recipes) bulk_service.export_recipes(temp_path, export_recipes.recipes)
return FileResponse(temp_path, filename="recipes.zip") # return FileResponse(temp_path, filename="recipes.zip")
@export_router.get("/export/download")
def get_exported_data_token(path: Path, _: RecipeBulkActions = Depends(RecipeBulkActions.private)):
# return FileResponse(temp_path, filename="recipes.zip")
"""Returns a token to download a file"""
return {"fileToken": create_file_token(path)}
@export_router.get("/export", response_model=list[GroupDataExport])
def get_exported_data(bulk_service: RecipeBulkActions = Depends(RecipeBulkActions.private)):
return bulk_service.get_exports()
# return FileResponse(temp_path, filename="recipes.zip")
@export_router.delete("/export/purge")
def purge_export_data(bulk_service: RecipeBulkActions = Depends(RecipeBulkActions.private)):
"""Remove all exports data, including items on disk without database entry"""
amountDelete = bulk_service.purge_exports()
return {"message": f"{amountDelete} exports deleted"}

View File

@ -21,8 +21,13 @@ logger = get_logger()
@user_router.get("", response_model=list[RecipeSummary]) @user_router.get("", response_model=list[RecipeSummary])
async def get_all(start=0, limit=None, service: RecipeService = Depends(RecipeService.private)): async def get_all(
json_compatible_item_data = jsonable_encoder(service.get_all(start, limit)) start: int = 0,
limit: int = None,
load_foods: bool = False,
service: RecipeService = Depends(RecipeService.private),
):
json_compatible_item_data = jsonable_encoder(service.get_all(start, limit, load_foods))
return JSONResponse(content=json_compatible_item_data) return JSONResponse(content=json_compatible_item_data)

View File

@ -18,6 +18,7 @@ class AppInfo(CamelModel):
class AdminAboutInfo(AppInfo): class AdminAboutInfo(AppInfo):
versionLatest: str
api_port: int api_port: int
api_docs: bool api_docs: bool
db_type: str db_type: str

View File

@ -1,3 +1,5 @@
from uuid import UUID
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
from pydantic import validator from pydantic import validator
from slugify import slugify from slugify import slugify
@ -28,11 +30,11 @@ class UpdateCookBook(CreateCookBook):
class SaveCookBook(CreateCookBook): class SaveCookBook(CreateCookBook):
group_id: int group_id: UUID
class ReadCookBook(UpdateCookBook): class ReadCookBook(UpdateCookBook):
group_id: int group_id: UUID
categories: list[CategoryBase] = [] categories: list[CategoryBase] = []
class Config: class Config:
@ -40,7 +42,7 @@ class ReadCookBook(UpdateCookBook):
class RecipeCookBook(ReadCookBook): class RecipeCookBook(ReadCookBook):
group_id: int group_id: UUID
categories: list[RecipeCategoryResponse] categories: list[RecipeCategoryResponse]
class Config: class Config:

View File

@ -1,9 +1,10 @@
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
from pydantic import UUID4
from .group_preferences import UpdateGroupPreferences from .group_preferences import UpdateGroupPreferences
class GroupAdminUpdate(CamelModel): class GroupAdminUpdate(CamelModel):
id: int id: UUID4
name: str name: str
preferences: UpdateGroupPreferences preferences: UpdateGroupPreferences

View File

@ -0,0 +1,17 @@
from datetime import datetime
from fastapi_camelcase import CamelModel
from pydantic import UUID4
class GroupDataExport(CamelModel):
id: UUID4
group_id: UUID4
name: str
filename: str
path: str
size: str
expires: datetime
class Config:
orm_mode = True

View File

@ -1,3 +1,5 @@
from uuid import UUID
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
@ -15,7 +17,7 @@ class UpdateGroupPreferences(CamelModel):
class CreateGroupPreferences(UpdateGroupPreferences): class CreateGroupPreferences(UpdateGroupPreferences):
group_id: int group_id: UUID
class ReadGroupPreferences(CreateGroupPreferences): class ReadGroupPreferences(CreateGroupPreferences):

View File

@ -1,3 +1,5 @@
from uuid import UUID
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
@ -7,14 +9,14 @@ class CreateInviteToken(CamelModel):
class SaveInviteToken(CamelModel): class SaveInviteToken(CamelModel):
uses_left: int uses_left: int
group_id: int group_id: UUID
token: str token: str
class ReadInviteToken(CamelModel): class ReadInviteToken(CamelModel):
token: str token: str
uses_left: int uses_left: int
group_id: int group_id: UUID
class Config: class Config:
orm_mode = True orm_mode = True

View File

@ -1,3 +1,5 @@
from uuid import UUID
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
@ -9,7 +11,7 @@ class CreateWebhook(CamelModel):
class SaveWebhook(CreateWebhook): class SaveWebhook(CreateWebhook):
group_id: int group_id: UUID
class ReadWebhook(SaveWebhook): class ReadWebhook(SaveWebhook):

View File

@ -1,6 +1,7 @@
from datetime import date from datetime import date
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional
from uuid import UUID
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
from pydantic import validator from pydantic import validator
@ -33,11 +34,11 @@ class CreatePlanEntry(CamelModel):
class UpdatePlanEntry(CreatePlanEntry): class UpdatePlanEntry(CreatePlanEntry):
id: int id: int
group_id: int group_id: UUID
class SavePlanEntry(CreatePlanEntry): class SavePlanEntry(CreatePlanEntry):
group_id: int group_id: UUID
class Config: class Config:
orm_mode = True orm_mode = True

View File

@ -1,6 +1,7 @@
import datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from uuid import UUID, uuid4
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
from pydantic import BaseModel, Field, validator from pydantic import BaseModel, Field, validator
@ -59,7 +60,7 @@ class RecipeSummary(CamelModel):
id: Optional[int] id: Optional[int]
user_id: int = 0 user_id: int = 0
group_id: int = 0 group_id: UUID = Field(default_factory=uuid4)
name: Optional[str] name: Optional[str]
slug: str = "" slug: str = ""
@ -74,6 +75,7 @@ class RecipeSummary(CamelModel):
description: Optional[str] = "" description: Optional[str] = ""
recipe_category: Optional[list[RecipeTag]] = [] recipe_category: Optional[list[RecipeTag]] = []
tags: Optional[list[RecipeTag]] = [] tags: Optional[list[RecipeTag]] = []
tools: list[RecipeTool] = []
rating: Optional[int] rating: Optional[int]
org_url: Optional[str] = Field(None, alias="orgURL") org_url: Optional[str] = Field(None, alias="orgURL")
@ -86,23 +88,28 @@ class RecipeSummary(CamelModel):
orm_mode = True orm_mode = True
@validator("tags", always=True, pre=True) @validator("tags", always=True, pre=True)
def validate_tags(cats: list[Any], values): def validate_tags(cats: list[Any]):
if isinstance(cats, list) and cats and isinstance(cats[0], str): if isinstance(cats, list) and cats and isinstance(cats[0], str):
return [RecipeTag(name=c, slug=slugify(c)) for c in cats] return [RecipeTag(name=c, slug=slugify(c)) for c in cats]
return cats return cats
@validator("recipe_category", always=True, pre=True) @validator("recipe_category", always=True, pre=True)
def validate_categories(cats: list[Any], values): def validate_categories(cats: list[Any]):
if isinstance(cats, list) and cats and isinstance(cats[0], str): if isinstance(cats, list) and cats and isinstance(cats[0], str):
return [RecipeCategory(name=c, slug=slugify(c)) for c in cats] return [RecipeCategory(name=c, slug=slugify(c)) for c in cats]
return cats return cats
@validator("group_id", always=True, pre=True)
def validate_group_id(group_id: list[Any]):
if isinstance(group_id, int):
return uuid4()
return group_id
class Recipe(RecipeSummary): class Recipe(RecipeSummary):
recipe_ingredient: Optional[list[RecipeIngredient]] = [] recipe_ingredient: Optional[list[RecipeIngredient]] = []
recipe_instructions: Optional[list[RecipeStep]] = [] recipe_instructions: Optional[list[RecipeStep]] = []
nutrition: Optional[Nutrition] nutrition: Optional[Nutrition]
tools: list[RecipeTool] = []
# Mealie Specific # Mealie Specific
settings: Optional[RecipeSettings] = RecipeSettings() settings: Optional[RecipeSettings] = RecipeSettings()

View File

@ -37,7 +37,7 @@ class ReportEntryOut(ReportEntryCreate):
class ReportCreate(CamelModel): class ReportCreate(CamelModel):
timestamp: datetime.datetime = Field(default_factory=datetime.datetime.utcnow) timestamp: datetime.datetime = Field(default_factory=datetime.datetime.utcnow)
category: ReportCategory category: ReportCategory
group_id: int group_id: UUID4
name: str name: str
status: ReportSummaryStatus = ReportSummaryStatus.in_progress status: ReportSummaryStatus = ReportSummaryStatus.in_progress

View File

@ -1,5 +1,6 @@
import datetime import datetime
import enum import enum
from uuid import UUID
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
from pydantic import Field from pydantic import Field
@ -18,7 +19,7 @@ class ServerTaskStatus(str, enum.Enum):
class ServerTaskCreate(CamelModel): class ServerTaskCreate(CamelModel):
group_id: int group_id: UUID
name: ServerTaskNames = ServerTaskNames.default name: ServerTaskNames = ServerTaskNames.default
created_at: datetime.datetime = Field(default_factory=datetime.datetime.now) created_at: datetime.datetime = Field(default_factory=datetime.datetime.now)
status: ServerTaskStatus = ServerTaskStatus.running status: ServerTaskStatus = ServerTaskStatus.running

View File

@ -1,11 +1,14 @@
from datetime import datetime from datetime import datetime
from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from uuid import UUID
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
from pydantic import UUID4
from pydantic.types import constr from pydantic.types import constr
from pydantic.utils import GetterDict from pydantic.utils import GetterDict
from mealie.core.config import get_app_settings from mealie.core.config import get_app_dirs, get_app_settings
from mealie.db.models.users import User from mealie.db.models.users import User
from mealie.schema.group.group_preferences import ReadGroupPreferences from mealie.schema.group.group_preferences import ReadGroupPreferences
from mealie.schema.recipe import RecipeSummary from mealie.schema.recipe import RecipeSummary
@ -87,7 +90,7 @@ class UserIn(UserBase):
class UserOut(UserBase): class UserOut(UserBase):
id: int id: int
group: str group: str
group_id: int group_id: UUID4
tokens: Optional[list[LongLiveTokenOut]] tokens: Optional[list[LongLiveTokenOut]]
favorite_recipes: Optional[list[str]] = [] favorite_recipes: Optional[list[str]] = []
@ -119,14 +122,14 @@ class UserFavorites(UserBase):
class PrivateUser(UserOut): class PrivateUser(UserOut):
password: str password: str
group_id: int group_id: UUID4
class Config: class Config:
orm_mode = True orm_mode = True
class UpdateGroup(GroupBase): class UpdateGroup(GroupBase):
id: int id: UUID4
name: str name: str
categories: Optional[list[CategoryBase]] = [] categories: Optional[list[CategoryBase]] = []
@ -141,6 +144,26 @@ class GroupInDB(UpdateGroup):
class Config: class Config:
orm_mode = True orm_mode = True
@staticmethod
def get_directory(id: UUID4) -> Path:
dir = get_app_dirs().GROUPS_DIR / str(id)
dir.mkdir(parents=True, exist_ok=True)
return dir
@staticmethod
def get_export_directory(id: UUID) -> Path:
dir = GroupInDB.get_directory(id) / "export"
dir.mkdir(parents=True, exist_ok=True)
return dir
@property
def directory(self) -> Path:
return GroupInDB.get_directory(self.id)
@property
def exports(self) -> Path:
return GroupInDB.get_export_directory(self.id)
class LongLiveTokenInDB(CreateToken): class LongLiveTokenInDB(CreateToken):
id: int id: int

View File

@ -3,6 +3,7 @@ from __future__ import annotations
from functools import cached_property from functools import cached_property
from fastapi import HTTPException, status from fastapi import HTTPException, status
from pydantic import UUID4
from mealie.schema.group.group import GroupAdminUpdate from mealie.schema.group.group import GroupAdminUpdate
from mealie.schema.mapper import mapper from mealie.schema.mapper import mapper
@ -16,7 +17,7 @@ from mealie.services.group_services.group_utils import create_new_group
class AdminGroupService( class AdminGroupService(
CrudHttpMixins[GroupBase, GroupInDB, GroupAdminUpdate], CrudHttpMixins[GroupBase, GroupInDB, GroupAdminUpdate],
AdminHttpService[int, GroupInDB], AdminHttpService[UUID4, GroupInDB],
): ):
event_func = create_group_event event_func = create_group_event
_schema = GroupInDB _schema = GroupInDB
@ -25,7 +26,7 @@ class AdminGroupService(
def dal(self): def dal(self):
return self.db.groups return self.db.groups
def populate_item(self, id: int) -> GroupInDB: def populate_item(self, id: UUID4) -> GroupInDB:
self.item = self.dal.get_one(id) self.item = self.dal.get_one(id)
return self.item return self.item
@ -35,13 +36,13 @@ class AdminGroupService(
def create_one(self, data: GroupBase) -> GroupInDB: def create_one(self, data: GroupBase) -> GroupInDB:
return create_new_group(self.db, data) return create_new_group(self.db, data)
def update_one(self, data: GroupAdminUpdate, item_id: int = None) -> GroupInDB: def update_one(self, data: GroupAdminUpdate, item_id: UUID4 = None) -> GroupInDB:
target_id = item_id or data.id target_id = item_id or data.id
if data.preferences: if data.preferences:
preferences = self.db.group_preferences.get_one(value=target_id, key="group_id") preferences = self.db.group_preferences.get_one(value=target_id, key="group_id")
preferences = mapper(data.preferences, preferences) preferences = mapper(data.preferences, preferences)
self.item.preferences = self.db.group_preferences.update(preferences.id, preferences) self.item.preferences = self.db.group_preferences.update(target_id, preferences)
if data.name not in ["", self.item.name]: if data.name not in ["", self.item.name]:
self.item.name = data.name self.item.name = data.name
@ -49,11 +50,13 @@ class AdminGroupService(
return self.item return self.item
def delete_one(self, id: int = None) -> GroupInDB: def delete_one(self, id: UUID4 = None) -> GroupInDB:
target_id = id or self.item.id
if len(self.item.users) > 0: if len(self.item.users) > 0:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorResponse(message="Cannot delete group with users").dict(), detail=ErrorResponse(message="Cannot delete group with users").dict(),
) )
return self._delete_one(id) return self._delete_one(target_id)

View File

@ -0,0 +1,2 @@
from .exporter import *
from .recipe_exporter import *

View File

@ -0,0 +1,91 @@
import zipfile
from abc import abstractmethod, abstractproperty
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterator, Optional
from uuid import UUID
from pydantic import BaseModel
from mealie.core.root_logger import get_logger
from mealie.db.database import Database
from mealie.schema.reports.reports import ReportEntryCreate
from .._base_service import BaseService
@dataclass
class ExportedItem:
"""
Exported items are the items provided by items() call in an concrete exporter class
Where the items are used to write data to the zip file. Models should derive from the
BaseModel class OR provide a .json method that returns a json string.
"""
model: BaseModel
name: str
class ABCExporter(BaseService):
write_dir_to_zip: Callable[[Path, str, Optional[list[str]]], None]
def __init__(self, db: Database, group_id: UUID) -> None:
self.logger = get_logger()
self.db = db
self.group_id = group_id
super().__init__()
@abstractproperty
def destination_dir(self) -> str:
...
@abstractmethod
def items(self) -> Iterator[ExportedItem]:
...
def _post_export_hook(self, _: BaseModel) -> None:
pass
@abstractmethod
def export(self, zip: zipfile.ZipFile) -> list[ReportEntryCreate]:
"""
Export takes in a zip file and exports the recipes to it. Note that the zip
file open/close is NOT handled by this method. You must handle it yourself.
Args:
zip (zipfile.ZipFile): Zip file destination
Returns:
list[ReportEntryCreate]: [description] ???!?!
"""
self.write_dir_to_zip = self.write_dir_to_zip_func(zip)
for item in self.items():
if item is None:
self.logger.error("Failed to export item. no item found")
continue
zip.writestr(f"{self.destination_dir}/{item.name}/{item.name}.json", item.model.json())
self._post_export_hook(item.model)
self.write_dir_to_zip = None
def write_dir_to_zip_func(self, zip: zipfile.ZipFile):
"""Returns a recursive function that writes a directory to a zip file.
Args:
zip (zipfile.ZipFile):
"""
def func(source_dir: Path, dest_dir: str, ignore_ext: set[str] = None) -> None:
ignore_ext = ignore_ext or set()
for source_file in source_dir.iterdir():
if source_file.is_dir():
func(source_file, f"{dest_dir}/{source_file.name}")
elif source_file.suffix not in ignore_ext:
zip.write(source_file, f"{dest_dir}/{source_file.name}")
return func

View File

@ -0,0 +1,51 @@
import datetime
import shutil
import zipfile
from pathlib import Path
from uuid import UUID, uuid4
from mealie.db.database import Database
from mealie.schema.group.group_exports import GroupDataExport
from mealie.schema.user import GroupInDB
from mealie.utils.fs_stats import pretty_size
from .._base_service import BaseService
from ._abc_exporter import ABCExporter
class Exporter(BaseService):
def __init__(self, group_id: UUID, temp_zip: Path, exporters: list[ABCExporter]) -> None:
super().__init__()
self.group_id = group_id
self.temp_path = temp_zip
self.exporters = exporters
def run(self, db: Database) -> GroupDataExport:
# Create Zip File
self.temp_path.touch()
# Open Zip File
with zipfile.ZipFile(self.temp_path, "w") as zip:
for exporter in self.exporters:
exporter.export(zip)
export_id = uuid4()
export_path = GroupInDB.get_export_directory(self.group_id) / f"{export_id}.zip"
shutil.copy(self.temp_path, export_path)
group_data_export = GroupDataExport(
id=export_id,
group_id=self.group_id,
path=str(export_path),
name="Data Export",
size=pretty_size(export_path.stat().st_size),
filename=export_path.name,
expires=datetime.datetime.now() + datetime.timedelta(days=1),
)
db.group_exports.create(group_data_export)
return group_data_export

View File

@ -0,0 +1,41 @@
from typing import Iterator
from uuid import UUID
from mealie.db.database import Database
from mealie.schema.recipe import Recipe
from ._abc_exporter import ABCExporter, ExportedItem
class RecipeExporter(ABCExporter):
def __init__(self, db: Database, group_id: UUID, recipes: list[str]) -> None:
"""
RecipeExporter is used to export a list of recipes to a zip file. The zip
file is then saved to a temporary directory and then available for a one-time
download.
Args:
db (Database):
group_id (int):
recipes (list[str]): Recipe Slugs
"""
super().__init__(db, group_id)
self.recipes = recipes
@property
def destination_dir(self) -> str:
return "recipes"
def items(self) -> Iterator[ExportedItem]:
for slug in self.recipes:
yield ExportedItem(
name=slug,
model=self.db.recipes.multi_query({"slug": slug, "group_id": self.group_id}, limit=1)[0],
)
def _post_export_hook(self, item: Recipe) -> None:
"""Copy recipe directory contents into the zip folder"""
recipe_dir = item.directory
if recipe_dir.exists():
self.write_dir_to_zip(recipe_dir, f"{self.destination_dir}/{item.slug}", {".json"})

View File

@ -1,3 +1,5 @@
from uuid import uuid4
from mealie.db.data_access_layer.access_model_factory import Database from mealie.db.data_access_layer.access_model_factory import Database
from mealie.schema.group.group_preferences import CreateGroupPreferences from mealie.schema.group.group_preferences import CreateGroupPreferences
from mealie.schema.user.user import GroupBase, GroupInDB from mealie.schema.user.user import GroupBase, GroupInDB
@ -6,7 +8,8 @@ from mealie.schema.user.user import GroupBase, GroupInDB
def create_new_group(db: Database, g_base: GroupBase, g_preferences: CreateGroupPreferences = None) -> GroupInDB: def create_new_group(db: Database, g_base: GroupBase, g_preferences: CreateGroupPreferences = None) -> GroupInDB:
created_group = db.groups.create(g_base) created_group = db.groups.create(g_base)
g_preferences = g_preferences or CreateGroupPreferences(group_id=0) # Assign Temporary ID before group is created # Assign Temporary ID before group is created
g_preferences = g_preferences or CreateGroupPreferences(group_id=uuid4())
g_preferences.group_id = created_group.id g_preferences.group_id = created_group.id

View File

@ -99,6 +99,9 @@ def sizeof_fmt(file_path: Path, decimal_places=2):
def move_all_images(): def move_all_images():
if not app_dirs.IMG_DIR.exists():
return
for image_file in app_dirs.IMG_DIR.iterdir(): for image_file in app_dirs.IMG_DIR.iterdir():
if image_file.is_file(): if image_file.is_file():
if image_file.name == ".DS_Store": if image_file.name == ".DS_Store":

View File

@ -1,5 +1,6 @@
from pathlib import Path from pathlib import Path
from typing import Tuple from typing import Tuple
from uuid import UUID
from mealie.core import root_logger from mealie.core import root_logger
from mealie.db.database import Database from mealie.db.database import Database
@ -25,7 +26,7 @@ class BaseMigrator(BaseService):
report_id: int report_id: int
report: ReportOut report: ReportOut
def __init__(self, archive: Path, db: Database, session, user_id: int, group_id: int): def __init__(self, archive: Path, db: Database, session, user_id: int, group_id: UUID):
self.archive = archive self.archive = archive
self.db = db self.db = db
self.session = session self.session = session

View File

@ -1,6 +1,7 @@
import tempfile import tempfile
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from uuid import UUID
from mealie.db.database import Database from mealie.db.database import Database
@ -10,7 +11,7 @@ from .utils.migration_helpers import MigrationReaders, import_image, split_by_co
class ChowdownMigrator(BaseMigrator): class ChowdownMigrator(BaseMigrator):
def __init__(self, archive: Path, db: Database, session, user_id: int, group_id: int): def __init__(self, archive: Path, db: Database, session, user_id: int, group_id: UUID):
super().__init__(archive, db, session, user_id, group_id) super().__init__(archive, db, session, user_id, group_id)
self.key_aliases = [ self.key_aliases = [

View File

@ -3,6 +3,7 @@ import zipfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from uuid import UUID
from slugify import slugify from slugify import slugify
@ -39,7 +40,7 @@ class NextcloudDir:
class NextcloudMigrator(BaseMigrator): class NextcloudMigrator(BaseMigrator):
def __init__(self, archive: Path, db: Database, session, user_id: int, group_id: int): def __init__(self, archive: Path, db: Database, session, user_id: int, group_id: UUID):
super().__init__(archive, db, session, user_id, group_id) super().__init__(archive, db, session, user_id, group_id)
self.key_aliases = [ self.key_aliases = [

View File

@ -3,10 +3,12 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from mealie.core.root_logger import get_logger from mealie.core.root_logger import get_logger
from mealie.schema.group.group_exports import GroupDataExport
from mealie.schema.recipe import CategoryBase, Recipe from mealie.schema.recipe import CategoryBase, Recipe
from mealie.schema.recipe.recipe_category import TagBase from mealie.schema.recipe.recipe_category import TagBase
from mealie.services._base_http_service.http_services import UserHttpService from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event from mealie.services.events import create_recipe_event
from mealie.services.exporter import Exporter, RecipeExporter
logger = get_logger(__name__) logger = get_logger(__name__)
@ -18,8 +20,36 @@ class RecipeBulkActions(UserHttpService[int, Recipe]):
def populate_item(self, _: int) -> Recipe: def populate_item(self, _: int) -> Recipe:
return return
def export_recipes(self, temp_path: Path, recipes: list[str]) -> None: def export_recipes(self, temp_path: Path, slugs: list[str]) -> None:
return recipe_exporter = RecipeExporter(self.db, self.group_id, slugs)
exporter = Exporter(self.group_id, temp_path, [recipe_exporter])
exporter.run(self.db)
def get_exports(self) -> list[GroupDataExport]:
return self.db.group_exports.multi_query({"group_id": self.group_id})
def purge_exports(self) -> int:
all_exports = self.get_exports()
exports_deleted = 0
for export in all_exports:
try:
Path(export.path).unlink(missing_ok=True)
self.db.group_exports.delete(export.id)
exports_deleted += 1
except Exception as e:
logger.error(f"Failed to delete export {export.id}")
logger.error(e)
group = self.db.groups.get_one(self.group_id)
for match in group.directory.glob("**/export/*zip"):
if match.is_file():
match.unlink()
exports_deleted += 1
return exports_deleted
def assign_tags(self, recipes: list[str], tags: list[TagBase]) -> None: def assign_tags(self, recipes: list[str], tags: list[TagBase]) -> None:
for slug in recipes: for slug in recipes:

View File

@ -59,14 +59,18 @@ class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpServic
if not self.item.settings.public and not self.user: if not self.item.settings.public and not self.user:
raise HTTPException(status.HTTP_403_FORBIDDEN) raise HTTPException(status.HTTP_403_FORBIDDEN)
def get_all(self, start=0, limit=None): def get_all(self, start=0, limit=None, load_foods=False) -> list[RecipeSummary]:
items = self.db.recipes.summary(self.user.group_id, start=start, limit=limit) items = self.db.recipes.summary(self.user.group_id, start=start, limit=limit, load_foods=load_foods)
new_items = [] new_items = []
for item in items: for item in items:
# Pydantic/FastAPI can't seem to serialize the ingredient field on thier own. # Pydantic/FastAPI can't seem to serialize the ingredient field on thier own.
new_item = item.__dict__ new_item = item.__dict__
new_item["recipe_ingredient"] = [x.__dict__ for x in item.recipe_ingredient]
if load_foods:
new_item["recipe_ingredient"] = [x.__dict__ for x in item.recipe_ingredient]
new_items.append(new_item) new_items.append(new_item)
return [RecipeSummary.construct(**x) for x in new_items] return [RecipeSummary.construct(**x) for x in new_items]

View File

@ -15,7 +15,7 @@ CWD = Path(__file__).parent
app_dirs = get_app_dirs() app_dirs = get_app_dirs()
TEMP_DATA = app_dirs.DATA_DIR / ".temp" TEMP_DATA = app_dirs.DATA_DIR / ".temp"
SCHEDULER_DB = TEMP_DATA / "scheduler.db" SCHEDULER_DB = CWD / ".scheduler.db"
SCHEDULER_DATABASE = f"sqlite:///{SCHEDULER_DB}" SCHEDULER_DATABASE = f"sqlite:///{SCHEDULER_DB}"
MINUTES_DAY = 1440 MINUTES_DAY = 1440

View File

@ -1,5 +1,6 @@
from .auto_backup import * from .auto_backup import *
from .purge_events import * from .purge_events import *
from .purge_group_exports import *
from .purge_password_reset import * from .purge_password_reset import *
from .purge_registration import * from .purge_registration import *
from .webhooks import * from .webhooks import *

View File

@ -0,0 +1,46 @@
import datetime
from pathlib import Path
from mealie.core import root_logger
from mealie.core.config import get_app_dirs
from mealie.db.db_setup import create_session
from mealie.db.models.group.exports import GroupDataExportsModel
ONE_DAY_AS_MINUTES = 1440
def purge_group_data_exports(max_minutes_old=ONE_DAY_AS_MINUTES):
"""Purges all group exports after x days"""
logger = root_logger.get_logger()
logger.info("purging group data exports")
limit = datetime.datetime.now() - datetime.timedelta(minutes=max_minutes_old)
session = create_session()
results = session.query(GroupDataExportsModel).filter(GroupDataExportsModel.expires <= limit)
total_removed = 0
for result in results:
session.delete(result)
Path(result.path).unlink(missing_ok=True)
total_removed += 1
session.commit()
session.close()
logger.info(f"finished purging group data exports. {total_removed} exports removed from group data")
def purge_excess_files() -> None:
"""Purges all files in the uploads directory that are older than 2 days"""
directories = get_app_dirs()
logger = root_logger.get_logger()
limit = datetime.datetime.now() - datetime.timedelta(minutes=ONE_DAY_AS_MINUTES * 2)
for file in directories.GROUPS_DIR.glob("**/export/*.zip"):
if file.stat().st_mtime < limit:
file.unlink()
logger.info(f"excess group file removed '{file}'")
logger.info("finished purging excess files")

View File

@ -1,3 +1,5 @@
from uuid import uuid4
from fastapi import HTTPException, status from fastapi import HTTPException, status
from mealie.core.root_logger import get_logger from mealie.core.root_logger import get_logger
@ -69,7 +71,7 @@ class RegistrationService(PublicHttpService[int, str]):
group_data = GroupBase(name=self.registration.group) group_data = GroupBase(name=self.registration.group)
group_preferences = CreateGroupPreferences( group_preferences = CreateGroupPreferences(
group_id=0, group_id=uuid4(),
private_group=self.registration.private, private_group=self.registration.private,
first_day_of_week=0, first_day_of_week=0,
recipe_public=not self.registration.private, recipe_public=not self.registration.private,

View File

@ -31,7 +31,7 @@ def admin_user(api_client: TestClient, api_routes: utils.AppRoutes):
try: try:
yield utils.TestUser( yield utils.TestUser(
group_id=user_data.get("groupId"), _group_id=user_data.get("groupId"),
user_id=user_data.get("id"), user_id=user_data.get("id"),
email=user_data.get("email"), email=user_data.get("email"),
token=token, token=token,

View File

@ -38,7 +38,7 @@ def g2_user(admin_token, api_client: requests, api_routes: utils.AppRoutes):
group_id = json.loads(self_response.text).get("groupId") group_id = json.loads(self_response.text).get("groupId")
try: try:
yield utils.TestUser(user_id=user_id, group_id=group_id, token=token, email=create_data["email"]) yield utils.TestUser(user_id=user_id, _group_id=group_id, token=token, email=create_data["email"])
finally: finally:
# TODO: Delete User after test # TODO: Delete User after test
pass pass
@ -59,7 +59,7 @@ def unique_user(api_client: TestClient, api_routes: utils.AppRoutes):
try: try:
yield utils.TestUser( yield utils.TestUser(
group_id=user_data.get("groupId"), _group_id=user_data.get("groupId"),
user_id=user_data.get("id"), user_id=user_data.get("id"),
email=user_data.get("email"), email=user_data.get("email"),
token=token, token=token,

View File

@ -16,21 +16,21 @@ class Routes:
def test_home_group_not_deletable(api_client: TestClient, admin_user: TestUser): def test_home_group_not_deletable(api_client: TestClient, admin_user: TestUser):
response = api_client.delete(Routes.item(1), headers=admin_user.token) response = api_client.delete(Routes.item(admin_user.group_id), headers=admin_user.token)
assert response.status_code == 400 assert response.status_code == 400
def test_admin_group_routes_are_restricted(api_client: TestClient, unique_user: TestUser): def test_admin_group_routes_are_restricted(api_client: TestClient, unique_user: TestUser, admin_user: TestUser):
response = api_client.get(Routes.base, headers=unique_user.token) response = api_client.get(Routes.base, headers=unique_user.token)
assert response.status_code == 403 assert response.status_code == 403
response = api_client.post(Routes.base, json={}, headers=unique_user.token) response = api_client.post(Routes.base, json={}, headers=unique_user.token)
assert response.status_code == 403 assert response.status_code == 403
response = api_client.get(Routes.item(1), headers=unique_user.token) response = api_client.get(Routes.item(admin_user.group_id), headers=unique_user.token)
assert response.status_code == 403 assert response.status_code == 403
response = api_client.get(Routes.user(1), headers=unique_user.token) response = api_client.get(Routes.user(admin_user.group_id), headers=unique_user.token)
assert response.status_code == 403 assert response.status_code == 403
@ -75,5 +75,5 @@ def test_admin_delete_group(api_client: TestClient, admin_user: TestUser, unique
assert response.status_code == 200 assert response.status_code == 200
# Ensure Group is Deleted # Ensure Group is Deleted
response = api_client.get(Routes.item(unique_user.user_id), headers=admin_user.token) response = api_client.get(Routes.item(unique_user.group_id), headers=admin_user.token)
assert response.status_code == 404 assert response.status_code == 404

View File

@ -1,6 +1,10 @@
import pytest from uuid import UUID
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from tests.utils.assertion_helpers import assert_ignore_keys
from tests.utils.fixture_schemas import TestUser
class Routes: class Routes:
base = "/api/groups/cookbooks" base = "/api/groups/cookbooks"
@ -9,38 +13,45 @@ class Routes:
return f"{Routes.base}/{item_id}" return f"{Routes.base}/{item_id}"
@pytest.fixture() def get_page_data(group_id: UUID):
def page_data(): return {
return {"name": "My New Page", "description": "", "position": 0, "categories": [], "groupId": 1} "name": "My New Page",
"slug": "my-new-page",
"description": "",
"position": 0,
"categories": [],
"group_id": group_id,
}
def test_create_cookbook(api_client: TestClient, admin_token, page_data): def test_create_cookbook(api_client: TestClient, unique_user: TestUser):
response = api_client.post(Routes.base, json=page_data, headers=admin_token) page_data = get_page_data(unique_user.group_id)
response = api_client.post(Routes.base, json=page_data, headers=unique_user.token)
assert response.status_code == 201 assert response.status_code == 201
def test_read_cookbook(api_client: TestClient, page_data, admin_token): def test_read_cookbook(api_client: TestClient, unique_user: TestUser):
response = api_client.get(Routes.item(1), headers=admin_token) page_data = get_page_data(unique_user.group_id)
page_data["id"] = 1 response = api_client.get(Routes.item(1), headers=unique_user.token)
page_data["slug"] = "my-new-page" assert_ignore_keys(response.json(), page_data)
assert response.json() == page_data
def test_update_cookbook(api_client: TestClient, page_data, admin_token): def test_update_cookbook(api_client: TestClient, unique_user: TestUser):
page_data = get_page_data(unique_user.group_id)
page_data["id"] = 1 page_data["id"] = 1
page_data["name"] = "My New Name" page_data["name"] = "My New Name"
response = api_client.put(Routes.item(1), json=page_data, headers=admin_token)
response = api_client.put(Routes.item(1), json=page_data, headers=unique_user.token)
assert response.status_code == 200 assert response.status_code == 200
def test_delete_cookbook(api_client: TestClient, admin_token): def test_delete_cookbook(api_client: TestClient, unique_user: TestUser):
response = api_client.delete(Routes.item(1), headers=admin_token) response = api_client.delete(Routes.item(1), headers=unique_user.token)
assert response.status_code == 200 assert response.status_code == 200
response = api_client.get(Routes.item(1), headers=admin_token) response = api_client.get(Routes.item(1), headers=unique_user.token)
assert response.status_code == 404 assert response.status_code == 404

View File

@ -9,20 +9,15 @@ class Routes:
user = "/api/users/self" user = "/api/users/self"
GROUP_ID = 1 def test_ownership_on_new_with_admin(api_client: TestClient, admin_user: TestUser):
ADMIN_ID = 1
USER_ID = 2
def test_ownership_on_new_with_admin(api_client: TestClient, admin_token):
recipe_name = random_string() recipe_name = random_string()
response = api_client.post(Routes.base, json={"name": recipe_name}, headers=admin_token) response = api_client.post(Routes.base, json={"name": recipe_name}, headers=admin_user.token)
assert response.status_code == 201 assert response.status_code == 201
recipe = api_client.get(Routes.base + f"/{recipe_name}", headers=admin_token).json() recipe = api_client.get(Routes.base + f"/{recipe_name}", headers=admin_user.token).json()
assert recipe["userId"] == ADMIN_ID assert recipe["userId"] == admin_user.user_id
assert recipe["groupId"] == GROUP_ID assert recipe["groupId"] == admin_user.group_id
def test_ownership_on_new_with_user(api_client: TestClient, g2_user: TestUser): def test_ownership_on_new_with_user(api_client: TestClient, g2_user: TestUser):

View File

@ -4,6 +4,7 @@ from mealie.core.config import get_app_dirs, get_app_settings
from mealie.core.settings.db_providers import SQLiteProvider from mealie.core.settings.db_providers import SQLiteProvider
os.environ["PRODUCTION"] = "True" os.environ["PRODUCTION"] = "True"
os.environ["TESTING"] = "True"
settings = get_app_settings() settings = get_app_settings()
app_dirs = get_app_dirs() app_dirs = get_app_dirs()

View File

@ -1,10 +1,15 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from uuid import UUID
@dataclass @dataclass
class TestUser: class TestUser:
email: str email: str
user_id: int user_id: int
group_id: int _group_id: UUID
token: Any token: Any
@property
def group_id(self) -> str:
return str(self._group_id)