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:
parent
2ce195a0d4
commit
c32d7d7486
84 changed files with 1329 additions and 667 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -16,6 +16,7 @@ dev/data/debug/*
|
|||
dev/data/img/*
|
||||
dev/data/migration/*
|
||||
dev/data/users/*
|
||||
dev/data/groups/*
|
||||
|
||||
.DS_Store
|
||||
node_modules
|
||||
|
|
|
@ -2,35 +2,59 @@
|
|||
|
||||
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
|
||||
|
||||
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 Compose](https://docs.docker.com/compose/install/)
|
||||
|
||||
[Mealie on Dockerhub](https://hub.docker.com/r/hkotel/mealie)
|
||||
|
||||
- linux/amd64
|
||||
- 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
|
||||
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.
|
||||
|
||||
- [SQLite](/mealie/documentation/getting-started/installation/sqlite/)
|
||||
- [PostgreSQL](/mealie/documentation/getting-started/installation/postgres/)
|
||||
- [SQLite](./sqlite.md)
|
||||
- [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.
|
||||
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 [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] 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] 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
|
||||
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
|
||||
|
||||
## 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.
|
File diff suppressed because one or more lines are too long
|
@ -17,6 +17,7 @@ export interface AdminAboutInfo {
|
|||
dbType: string;
|
||||
dbUrl: string;
|
||||
defaultGroup: string;
|
||||
versionLatest: string;
|
||||
}
|
||||
|
||||
export interface AdminStatistics {
|
||||
|
@ -31,6 +32,7 @@ export interface CheckAppConfig {
|
|||
emailReady: boolean;
|
||||
baseUrlSet: boolean;
|
||||
isSiteSecure: boolean;
|
||||
isUpToDate: boolean;
|
||||
ldapReady: boolean;
|
||||
}
|
||||
|
||||
|
|
|
@ -31,10 +31,21 @@ interface BulkActionResponse {
|
|||
errors: BulkActionError[];
|
||||
}
|
||||
|
||||
export interface GroupDataExport {
|
||||
id: string;
|
||||
groupId: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
path: string;
|
||||
size: string;
|
||||
expires: Date;
|
||||
}
|
||||
|
||||
const prefix = "/api";
|
||||
|
||||
const routes = {
|
||||
bulkExport: prefix + "/recipes/bulk-actions/export",
|
||||
purgeExports: prefix + "/recipes/bulk-actions/export/purge",
|
||||
bulkCategorize: prefix + "/recipes/bulk-actions/categorize",
|
||||
bulkTag: prefix + "/recipes/bulk-actions/tag",
|
||||
bulkDelete: prefix + "/recipes/bulk-actions/delete",
|
||||
|
@ -56,4 +67,12 @@ export class BulkActionsAPI extends BaseAPI {
|
|||
async bulkDelete(payload: RecipeBulkDelete) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,18 +38,6 @@ export default {
|
|||
value: true,
|
||||
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: {
|
||||
value: true,
|
||||
text: this.$t("user.users"),
|
||||
|
@ -58,10 +46,6 @@ export default {
|
|||
value: true,
|
||||
text: this.$t("group.groups"),
|
||||
},
|
||||
notifications: {
|
||||
value: true,
|
||||
text: this.$t("events.notification"),
|
||||
},
|
||||
},
|
||||
forceImport: false,
|
||||
};
|
||||
|
@ -73,12 +57,12 @@ export default {
|
|||
emitValue() {
|
||||
this.$emit(UPDATE_EVENT, {
|
||||
recipes: this.options.recipes.value,
|
||||
settings: this.options.settings.value,
|
||||
themes: this.options.themes.value,
|
||||
pages: this.options.pages.value,
|
||||
settings: false,
|
||||
themes: false,
|
||||
pages: false,
|
||||
users: this.options.users.value,
|
||||
groups: this.options.groups.value,
|
||||
notifications: this.options.notifications.value,
|
||||
notifications: false,
|
||||
forceImport: this.forceImport,
|
||||
});
|
||||
},
|
||||
|
|
60
frontend/components/Domain/Group/GroupExportData.vue
Normal file
60
frontend/components/Domain/Group/GroupExportData.vue
Normal 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>
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
:items="recipes"
|
||||
:items-per-page="15"
|
||||
class="elevation-0"
|
||||
:loading="loading"
|
||||
@input="setValue(selected)"
|
||||
>
|
||||
<template #body.preappend>
|
||||
|
@ -22,6 +23,9 @@
|
|||
<template #item.recipeCategory="{ item }">
|
||||
<RecipeChip small :items="item.recipeCategory" />
|
||||
</template>
|
||||
<template #item.tools="{ item }">
|
||||
<RecipeChip small :items="item.tools" />
|
||||
</template>
|
||||
<template #item.userId="{ item }">
|
||||
<v-list-item class="justify-start">
|
||||
<v-list-item-avatar>
|
||||
|
@ -49,6 +53,7 @@ interface ShowHeaders {
|
|||
owner: Boolean;
|
||||
tags: Boolean;
|
||||
categories: Boolean;
|
||||
tools: Boolean;
|
||||
recipeYield: Boolean;
|
||||
dateAdded: Boolean;
|
||||
}
|
||||
|
@ -61,6 +66,11 @@ export default defineComponent({
|
|||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
recipes: {
|
||||
type: Array as () => Recipe[],
|
||||
default: () => [],
|
||||
|
@ -103,12 +113,16 @@ export default defineComponent({
|
|||
if (show.tags) {
|
||||
hdrs.push({ text: "Tags", value: "tags" });
|
||||
}
|
||||
if (show.tools) {
|
||||
hdrs.push({ text: "Tools", value: "tools" });
|
||||
}
|
||||
if (show.recipeYield) {
|
||||
hdrs.push({ text: "Yield", value: "recipeYield" });
|
||||
}
|
||||
if (show.dateAdded) {
|
||||
hdrs.push({ text: "Date Added", value: "dateAdded" });
|
||||
}
|
||||
|
||||
return hdrs;
|
||||
});
|
||||
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
|
||||
<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-icon v-if="icon !== ''" left>
|
||||
{{ icon }}
|
||||
|
@ -12,7 +19,7 @@
|
|||
<slot />
|
||||
</p>
|
||||
</v-card-text>
|
||||
<v-divider class="my-3"></v-divider>
|
||||
<v-divider class="mb-3"></v-divider>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
|
@ -27,6 +34,10 @@ export default {
|
|||
type: String,
|
||||
default: "",
|
||||
},
|
||||
section: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<template>
|
||||
<v-container fluid>
|
||||
<section>
|
||||
<BaseCardSectionTitle title="Mealie Backups"> </BaseCardSectionTitle>
|
||||
<BaseCardSectionTitle title="Site Backups"> </BaseCardSectionTitle>
|
||||
|
||||
<!-- Delete Dialog -->
|
||||
<BaseDialog
|
||||
|
@ -25,7 +25,6 @@
|
|||
:submit-text="$t('general.import')"
|
||||
@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-card-text>
|
||||
<AdminBackupImportOptions v-model="selected.options" class="mt-5 mb-2" :import-backup="true" />
|
||||
|
@ -34,73 +33,74 @@
|
|||
<v-divider></v-divider>
|
||||
</BaseDialog>
|
||||
|
||||
<v-toolbar flat color="background" class="justify-between">
|
||||
<BaseButton class="mr-2" @click="createBackup(null)" />
|
||||
<!-- Backup Creation Dialog -->
|
||||
<BaseDialog
|
||||
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 outlined>
|
||||
<v-card-title class="py-2"> {{ $t("settings.backup.create-heading") }} </v-card-title>
|
||||
<v-divider class="mx-2"></v-divider>
|
||||
<v-form @submit.prevent="createBackup()">
|
||||
<v-card-text>
|
||||
<v-text-field v-model="backupOptions.tag" :label="$t('settings.backup.backup-tag')"> </v-text-field>
|
||||
<AdminBackupImportOptions v-model="backupOptions.options" class="mt-5 mb-2" />
|
||||
<v-divider class="my-3"></v-divider>
|
||||
<p class="text-uppercase">Templates</p>
|
||||
<v-checkbox
|
||||
v-for="(template, index) in backups.templates"
|
||||
:key="index"
|
||||
v-model="backupOptions.templates"
|
||||
:value="template"
|
||||
:label="template"
|
||||
></v-checkbox>
|
||||
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Dolores molestiae alias incidunt fugiat!
|
||||
Recusandae natus numquam iusto voluptates deserunt quia? Sed voluptate rem facilis tempora, perspiciatis
|
||||
corrupti dolore obcaecati laudantium!
|
||||
<div style="max-width: 300px">
|
||||
<v-text-field
|
||||
v-model="backupOptions.tag"
|
||||
class="mt-4"
|
||||
:label="$t('settings.backup.backup-tag') + ' (optional)'"
|
||||
>
|
||||
</v-text-field>
|
||||
<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>
|
||||
</BaseDialog>
|
||||
</v-toolbar>
|
||||
</v-form>
|
||||
</v-card>
|
||||
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="backups.imports || []"
|
||||
class="elevation-0"
|
||||
hide-default-footer
|
||||
disable-pagination
|
||||
:search="search"
|
||||
@click:row="setSelected"
|
||||
>
|
||||
<template #item.date="{ item }">
|
||||
{{ $d(Date.parse(item.date), "medium") }}
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<BaseButton
|
||||
small
|
||||
class="mx-1"
|
||||
delete
|
||||
@click.stop="
|
||||
deleteDialog = true;
|
||||
deleteTarget = item.name;
|
||||
"
|
||||
/>
|
||||
<BaseButton small download :download-url="backupsFileNameDownload(item.name)" @click.stop />
|
||||
</template>
|
||||
</v-data-table>
|
||||
<v-divider></v-divider>
|
||||
<div class="mt-4 d-flex justify-end">
|
||||
<AppButtonUpload
|
||||
:text-btn="false"
|
||||
class="mr-4"
|
||||
url="/api/backups/upload"
|
||||
accept=".zip"
|
||||
color="info"
|
||||
@uploaded="refreshBackups()"
|
||||
/>
|
||||
</div>
|
||||
<section class="mt-5">
|
||||
<BaseCardSectionTitle title="Backups"></BaseCardSectionTitle>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="backups.imports || []"
|
||||
class="elevation-0"
|
||||
hide-default-footer
|
||||
disable-pagination
|
||||
:search="search"
|
||||
@click:row="setSelected"
|
||||
>
|
||||
<template #item.date="{ item }">
|
||||
{{ $d(Date.parse(item.date), "medium") }}
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<v-btn
|
||||
icon
|
||||
class="mx-1"
|
||||
color="error"
|
||||
@click.stop="
|
||||
deleteDialog = true;
|
||||
deleteTarget = item.name;
|
||||
"
|
||||
>
|
||||
<v-icon> {{ $globals.icons.delete }} </v-icon>
|
||||
</v-btn>
|
||||
<BaseButton small download :download-url="backupsFileNameDownload(item.name)" @click.stop />
|
||||
</template>
|
||||
</v-data-table>
|
||||
<v-divider></v-divider>
|
||||
<div class="d-flex justify-end mt-6">
|
||||
<div>
|
||||
<AppButtonUpload
|
||||
:text-btn="false"
|
||||
class="mr-4"
|
||||
url="/api/backups/upload"
|
||||
accept=".zip"
|
||||
color="info"
|
||||
@uploaded="refreshBackups()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</v-container>
|
||||
</template>
|
||||
|
|
|
@ -10,64 +10,49 @@
|
|||
<section>
|
||||
<BaseCardSectionTitle class="pb-0" :icon="$globals.icons.cog" title="General Configuration">
|
||||
</BaseCardSectionTitle>
|
||||
<v-card v-for="(check, idx) in simpleChecks" :key="idx" class="mb-4">
|
||||
<v-list-item>
|
||||
<v-list-item-avatar>
|
||||
<v-icon :color="getColor(check.status)">
|
||||
{{ check.status ? $globals.icons.checkboxMarkedCircle : $globals.icons.close }}
|
||||
</v-icon>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title :class="getTextClass(check.status)"> {{ check.text }} </v-list-item-title>
|
||||
<v-list-item-subtitle :class="getTextClass(check.status)">
|
||||
{{ check.status ? check.successText : check.errorText }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-card>
|
||||
|
||||
<v-alert
|
||||
v-for="(check, idx) in simpleChecks"
|
||||
:key="idx"
|
||||
border="left"
|
||||
colored-border
|
||||
:type="getColor(check.status, check.warning)"
|
||||
elevation="2"
|
||||
>
|
||||
<div class="font-weight-medium">{{ check.text }}</div>
|
||||
<div>
|
||||
{{ check.status ? check.successText : check.errorText }}
|
||||
</div>
|
||||
</v-alert>
|
||||
</section>
|
||||
<section>
|
||||
<BaseCardSectionTitle class="pt-2" :icon="$globals.icons.email" title="Email Configuration">
|
||||
</BaseCardSectionTitle>
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<v-list-item>
|
||||
<v-list-item-avatar>
|
||||
<v-icon :color="getColor(appConfig.emailReady)">
|
||||
{{ appConfig.emailReady ? $globals.icons.checkboxMarkedCircle : $globals.icons.close }}
|
||||
</v-icon>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title :class="getTextClass(appConfig.emailReady)">
|
||||
Email Configuration Status
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle :class="getTextClass(appConfig.emailReady)">
|
||||
{{ appConfig.emailReady ? "Ready" : "Not Ready - Check Env Variables" }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-card-actions>
|
||||
<v-text-field v-model="address" class="mr-4" :label="$t('user.email')" :rules="[validators.email]">
|
||||
</v-text-field>
|
||||
<BaseButton
|
||||
color="info"
|
||||
:disabled="!appConfig.emailReady || !validEmail"
|
||||
:loading="loading"
|
||||
@click="testEmail"
|
||||
>
|
||||
<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>
|
||||
<BaseCardSectionTitle class="pt-2" :icon="$globals.icons.email" title="Email Configuration" />
|
||||
<v-alert :key="idx" border="left" colored-border :type="getColor(appConfig.emailReady)" elevation="2">
|
||||
<div class="font-weight-medium">Email Configuration Status</div>
|
||||
<div>
|
||||
{{ appConfig.emailReady ? "Ready" : "Not Ready - Check Environmental Variables" }}
|
||||
</div>
|
||||
<div>
|
||||
<v-text-field v-model="address" class="mr-4" :label="$t('user.email')" :rules="[validators.email]">
|
||||
</v-text-field>
|
||||
<BaseButton
|
||||
color="info"
|
||||
:disabled="!appConfig.emailReady || !validEmail"
|
||||
:loading="loading"
|
||||
@click="testEmail"
|
||||
>
|
||||
<template #icon> {{ $globals.icons.email }} </template>
|
||||
{{ $t("general.test") }}
|
||||
</BaseButton>
|
||||
<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>
|
||||
</div>
|
||||
</v-alert>
|
||||
</section>
|
||||
<section class="mt-4">
|
||||
<BaseCardSectionTitle class="pb-0" :icon="$globals.icons.cog" title="General About"> </BaseCardSectionTitle>
|
||||
|
@ -101,7 +86,7 @@ import {
|
|||
useAsync,
|
||||
useContext,
|
||||
} 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 { validators } from "~/composables/use-validators";
|
||||
import { useAsyncKey } from "~/composables/use-utils";
|
||||
|
@ -128,6 +113,7 @@ export default defineComponent({
|
|||
emailReady: false,
|
||||
baseUrlSet: false,
|
||||
isSiteSecure: false,
|
||||
isUpToDate: false,
|
||||
ldapReady: false,
|
||||
});
|
||||
|
||||
|
@ -151,22 +137,34 @@ export default defineComponent({
|
|||
const simpleChecks = computed<SimpleCheck[]>(() => {
|
||||
return [
|
||||
{
|
||||
status: appConfig.value.baseUrlSet,
|
||||
text: "Server Side Base URL",
|
||||
errorText: "`BASE_URL` still default on API Server",
|
||||
successText: "Server Side URL does not match the default",
|
||||
status: appConfig.value.isUpToDate,
|
||||
text: "Application Version",
|
||||
errorText: `Your current version (${rawAppInfo.value.version}) does not match the latest release. Considering updating to the latest version (${rawAppInfo.value.versionLatest}).`,
|
||||
successText: "Mealie is up to date",
|
||||
warning: true,
|
||||
},
|
||||
{
|
||||
status: appConfig.value.isSiteSecure,
|
||||
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",
|
||||
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,
|
||||
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.",
|
||||
warning: true,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
@ -201,23 +199,30 @@ export default defineComponent({
|
|||
return false;
|
||||
});
|
||||
|
||||
function getTextClass(booly: boolean | any) {
|
||||
return booly ? "success--text" : "error--text";
|
||||
}
|
||||
function getColor(booly: boolean | any) {
|
||||
return booly ? "success" : "error";
|
||||
function getColor(booly: boolean | any, warning = false) {
|
||||
const falsey = warning ? "warning" : "error";
|
||||
return booly ? "success" : falsey;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// General About Info
|
||||
|
||||
// @ts-ignore
|
||||
const { $globals, i18n } = useContext();
|
||||
|
||||
// @ts-ignore
|
||||
const rawAppInfo = ref<AdminAboutInfo>({
|
||||
version: "null",
|
||||
versionLatest: "null",
|
||||
});
|
||||
|
||||
function getAppInfo() {
|
||||
const statistics = useAsync(async () => {
|
||||
const { data } = await adminApi.about.about();
|
||||
|
||||
if (data) {
|
||||
rawAppInfo.value = data;
|
||||
|
||||
const prettyInfo = [
|
||||
{
|
||||
name: i18n.t("about.version"),
|
||||
|
@ -275,7 +280,6 @@ export default defineComponent({
|
|||
return {
|
||||
simpleChecks,
|
||||
getColor,
|
||||
getTextClass,
|
||||
appConfig,
|
||||
validEmail,
|
||||
validators,
|
||||
|
|
|
@ -382,10 +382,6 @@ export default defineComponent({
|
|||
</script>
|
||||
|
||||
<style lang="css">
|
||||
/* .col-borders {
|
||||
border-top: 1px solid #e0e0e0;
|
||||
} */
|
||||
|
||||
.left-color-border {
|
||||
border-left: 5px solid var(--v-primary-base) !important;
|
||||
}
|
||||
|
|
392
frontend/pages/user/group/data/recipes.vue
Normal file
392
frontend/pages/user/group/data/recipes.vue
Normal 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>
|
|
@ -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>
|
|
@ -110,7 +110,7 @@
|
|||
</v-col>
|
||||
<v-col v-if="user.advanced" cols="12" sm="12" md="6">
|
||||
<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')"
|
||||
>
|
||||
<template #title> Recipe Data </template>
|
||||
|
|
|
@ -25,6 +25,7 @@ import {
|
|||
mdiDrag,
|
||||
mdiEyeOff,
|
||||
mdiCalendarMinus,
|
||||
mdiAlertOutline,
|
||||
mdiCalendar,
|
||||
mdiDiceMultiple,
|
||||
mdiAlertCircle,
|
||||
|
@ -113,6 +114,7 @@ export const icons = {
|
|||
units: mdiBeakerOutline,
|
||||
alert: mdiAlert,
|
||||
alertCircle: mdiAlertCircle,
|
||||
alertOutline: mdiAlertOutline,
|
||||
api: mdiApi,
|
||||
arrowLeftBold: mdiArrowLeftBold,
|
||||
arrowRightBold: mdiArrowRightBold,
|
||||
|
|
|
@ -37,6 +37,7 @@ def start_scheduler():
|
|||
tasks.purge_group_registration,
|
||||
tasks.auto_backup,
|
||||
tasks.purge_password_reset_tokens,
|
||||
tasks.purge_group_data_exports,
|
||||
)
|
||||
|
||||
SchedulerRegistry.register_hourly()
|
||||
|
|
41
mealie/core/release_checker.py
Normal file
41
mealie/core/release_checker.py
Normal 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()
|
|
@ -2,30 +2,33 @@ from pathlib import Path
|
|||
|
||||
|
||||
class AppDirectories:
|
||||
def __init__(self, data_dir) -> None:
|
||||
self.DATA_DIR: Path = data_dir
|
||||
self.IMG_DIR: Path = data_dir.joinpath("img")
|
||||
self.BACKUP_DIR: Path = data_dir.joinpath("backups")
|
||||
self.DEBUG_DIR: Path = data_dir.joinpath("debug")
|
||||
self.MIGRATION_DIR: Path = data_dir.joinpath("migration")
|
||||
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")
|
||||
def __init__(self, data_dir: Path) -> None:
|
||||
self.DATA_DIR = data_dir
|
||||
self.BACKUP_DIR = data_dir.joinpath("backups")
|
||||
self.USER_DIR = data_dir.joinpath("users")
|
||||
self.RECIPE_DATA_DIR = data_dir.joinpath("recipes")
|
||||
self.TEMPLATE_DIR = data_dir.joinpath("templates")
|
||||
|
||||
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()
|
||||
|
||||
@property
|
||||
def IMG_DIR(self):
|
||||
return self._IMG_DIR
|
||||
|
||||
@property
|
||||
def TEMP_DIR(self):
|
||||
return self._TEMP_DIR
|
||||
|
||||
def ensure_directories(self):
|
||||
required_dirs = [
|
||||
self.IMG_DIR,
|
||||
self.GROUPS_DIR,
|
||||
self.BACKUP_DIR,
|
||||
self.DEBUG_DIR,
|
||||
self.MIGRATION_DIR,
|
||||
self.TEMPLATE_DIR,
|
||||
self.NEXTCLOUD_DIR,
|
||||
self.CHOWDOWN_DIR,
|
||||
self.RECIPE_DATA_DIR,
|
||||
self.USER_DIR,
|
||||
]
|
||||
|
|
|
@ -102,6 +102,11 @@ class AppSettings(BaseSettings):
|
|||
not_none = None not in required
|
||||
return self.LDAP_AUTH_ENABLED and not_none
|
||||
|
||||
# ===============================================
|
||||
# Testing Config
|
||||
|
||||
TESTING: bool = False
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
|
|||
from mealie.db.models.event import Event, EventNotification
|
||||
from mealie.db.models.group import Group, GroupMealPlan, ReportEntryModel, ReportModel
|
||||
from mealie.db.models.group.cookbook import CookBook
|
||||
from mealie.db.models.group.exports import GroupDataExportsModel
|
||||
from mealie.db.models.group.invite_tokens import GroupInviteToken
|
||||
from mealie.db.models.group.preferences import GroupPreferencesModel
|
||||
from mealie.db.models.group.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.events import Event as EventSchema
|
||||
from mealie.schema.events import EventNotificationIn
|
||||
from mealie.schema.group.group_exports import GroupDataExport
|
||||
from mealie.schema.group.group_preferences import ReadGroupPreferences
|
||||
from mealie.schema.group.invite_token import ReadInviteToken
|
||||
from mealie.schema.group.webhook import ReadWebhook
|
||||
|
@ -42,6 +44,7 @@ from .user_access_model import UserDataAccessModel
|
|||
pk_id = "id"
|
||||
pk_slug = "slug"
|
||||
pk_token = "token"
|
||||
pk_group_id = "group_id"
|
||||
|
||||
|
||||
class CategoryDataAccessModel(AccessModel):
|
||||
|
@ -143,7 +146,11 @@ class Database:
|
|||
|
||||
@cached_property
|
||||
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
|
||||
def meals(self) -> MealDataAccessModel:
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from datetime import date
|
||||
from uuid import UUID
|
||||
|
||||
from mealie.db.models.group import GroupMealPlan
|
||||
from mealie.schema.meal_plan.new_meal import ReadPlanEntry
|
||||
|
@ -7,7 +8,7 @@ from ._access_model import AccessModel
|
|||
|
||||
|
||||
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")
|
||||
end = end.strftime("%Y-%m-%d")
|
||||
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()]
|
||||
|
||||
def get_today(self, group_id: int) -> list[ReadPlanEntry]:
|
||||
def get_today(self, group_id: UUID) -> list[ReadPlanEntry]:
|
||||
today = date.today()
|
||||
qry = self.session.query(GroupMealPlan).filter(GroupMealPlan.date == today, GroupMealPlan.group_id == group_id)
|
||||
|
||||
|
|
|
@ -62,14 +62,19 @@ class RecipeDataAccessModel(AccessModel[Recipe, RecipeModel]):
|
|||
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 (
|
||||
self.session.query(RecipeModel)
|
||||
.options(
|
||||
joinedload(RecipeModel.recipe_category),
|
||||
joinedload(RecipeModel.tags),
|
||||
joinedload(RecipeModel.recipe_ingredient).options(joinedload(RecipeIngredient.food)),
|
||||
)
|
||||
.options(*args)
|
||||
.filter(RecipeModel.group_id == group_id)
|
||||
.offset(start)
|
||||
.limit(limit)
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
from .auto_init import auto_init
|
||||
from .guid import GUID
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from .cookbook import *
|
||||
from .exports import *
|
||||
from .group import *
|
||||
from .invite_tokens import *
|
||||
from .mealplan import *
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from sqlalchemy import Column, ForeignKey, Integer, String, orm
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
@ -14,7 +14,7 @@ class CookBook(SqlAlchemyBase, BaseMixins):
|
|||
slug = Column(String, nullable=False)
|
||||
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")
|
||||
|
||||
@auto_init()
|
||||
|
|
24
mealie/db/models/group/exports.py
Normal file
24
mealie/db/models/group/exports.py
Normal 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
|
|
@ -1,15 +1,17 @@
|
|||
import uuid
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlalchemy.orm as orm
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
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_utils import auto_init
|
||||
from .._model_utils import GUID, auto_init
|
||||
from ..group.invite_tokens import GroupInviteToken
|
||||
from ..group.webhooks import GroupWebhooksModel
|
||||
from ..recipe.category import Category, group2categories
|
||||
from ..server.task import ServerTaskModel
|
||||
from .cookbook import CookBook
|
||||
from .mealplan import GroupMealPlan
|
||||
from .preferences import GroupPreferencesModel
|
||||
|
@ -19,6 +21,7 @@ settings = get_app_settings()
|
|||
|
||||
class Group(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "groups"
|
||||
id = sa.Column(GUID, primary_key=True, default=uuid.uuid4)
|
||||
name = sa.Column(sa.String, index=True, nullable=False, unique=True)
|
||||
users = orm.relationship("User", back_populates="group")
|
||||
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)
|
||||
cookbooks = orm.relationship(CookBook, **common_args)
|
||||
server_tasks = orm.relationship(ServerTaskModel, **common_args)
|
||||
data_exports = orm.relationship("GroupDataExportsModel", **common_args)
|
||||
shopping_lists = orm.relationship("ShoppingList", **common_args)
|
||||
group_reports = orm.relationship("ReportModel", **common_args)
|
||||
|
||||
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()
|
||||
def __init__(self, **_) -> None:
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from sqlalchemy import Column, ForeignKey, Integer, String, orm
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
from .._model_utils import auto_init, guid
|
||||
|
||||
|
||||
class GroupInviteToken(SqlAlchemyBase, BaseMixins):
|
||||
|
@ -9,7 +9,7 @@ class GroupInviteToken(SqlAlchemyBase, BaseMixins):
|
|||
token = Column(String, index=True, nullable=False, unique=True)
|
||||
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")
|
||||
|
||||
@auto_init()
|
||||
|
|
|
@ -2,7 +2,7 @@ from sqlalchemy import Column, Date, ForeignKey, String, orm
|
|||
from sqlalchemy.sql.sqltypes import Integer
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
from .._model_utils import GUID, auto_init
|
||||
|
||||
|
||||
class GroupMealPlan(SqlAlchemyBase, BaseMixins):
|
||||
|
@ -13,7 +13,7 @@ class GroupMealPlan(SqlAlchemyBase, BaseMixins):
|
|||
title = Column(String, index=True, 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")
|
||||
|
||||
recipe_id = Column(Integer, ForeignKey("recipes.id"))
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import sqlalchemy as sa
|
||||
import sqlalchemy.orm as orm
|
||||
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
|
||||
|
||||
class GroupPreferencesModel(SqlAlchemyBase, BaseMixins):
|
||||
__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")
|
||||
|
||||
private_group: bool = sa.Column(sa.Boolean, default=True)
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from datetime import datetime
|
||||
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 mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
||||
|
@ -12,14 +12,14 @@ from .._model_utils.guid import GUID
|
|||
|
||||
class ReportEntryModel(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "report_entries"
|
||||
id = Column(GUID(), primary_key=True, default=uuid4)
|
||||
id = Column(GUID, primary_key=True, default=uuid4)
|
||||
|
||||
success = Column(Boolean, default=False)
|
||||
message = Column(String, nullable=True)
|
||||
exception = Column(String, nullable=True)
|
||||
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")
|
||||
|
||||
@auto_init()
|
||||
|
@ -29,7 +29,7 @@ class ReportEntryModel(SqlAlchemyBase, BaseMixins):
|
|||
|
||||
class ReportModel(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "group_reports"
|
||||
id = Column(GUID(), primary_key=True, default=uuid4)
|
||||
id = Column(GUID, primary_key=True, default=uuid4)
|
||||
|
||||
name = 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")
|
||||
|
||||
# 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)
|
||||
|
||||
class Config:
|
||||
|
|
|
@ -4,6 +4,7 @@ from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
|
|||
from sqlalchemy.ext.orderinglist import ordering_list
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils.guid import GUID
|
||||
from .group import Group
|
||||
|
||||
|
||||
|
@ -29,7 +30,7 @@ class ShoppingList(SqlAlchemyBase, BaseMixins):
|
|||
__tablename__ = "shopping_lists"
|
||||
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")
|
||||
|
||||
name = Column(String)
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
|
||||
|
||||
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
||||
|
||||
from .._model_utils import auto_init
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import GUID, auto_init
|
||||
|
||||
|
||||
class GroupWebhooksModel(SqlAlchemyBase, BaseMixins):
|
||||
|
@ -10,7 +9,7 @@ class GroupWebhooksModel(SqlAlchemyBase, BaseMixins):
|
|||
id = Column(Integer, primary_key=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)
|
||||
name = Column(String)
|
||||
|
|
|
@ -4,7 +4,9 @@ from slugify import slugify
|
|||
from sqlalchemy.orm import validates
|
||||
|
||||
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()
|
||||
|
||||
|
@ -12,7 +14,7 @@ logger = root_logger.get_logger()
|
|||
group2categories = sa.Table(
|
||||
"group2categories",
|
||||
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")),
|
||||
)
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ from mealie.db.models._model_utils.guid import GUID
|
|||
|
||||
class RecipeComment(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "recipe_comments"
|
||||
id = Column(GUID(), primary_key=True, default=uuid4)
|
||||
id = Column(GUID, primary_key=True, default=uuid4)
|
||||
text = Column(String)
|
||||
|
||||
# Recipe Link
|
||||
|
|
|
@ -49,7 +49,7 @@ class RecipeIngredient(SqlAlchemyBase, BaseMixins):
|
|||
food = orm.relationship(IngredientFoodModel, uselist=False)
|
||||
quantity = Column(Integer)
|
||||
|
||||
reference_id = Column(GUID()) # Reference Links
|
||||
reference_id = Column(GUID) # Reference Links
|
||||
|
||||
# Extras
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ from .._model_utils.guid import GUID
|
|||
class RecipeIngredientRefLink(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "recipe_ingredient_ref_link"
|
||||
instruction_id = Column(Integer, ForeignKey("recipe_instructions.id"))
|
||||
reference_id = Column(GUID())
|
||||
reference_id = Column(GUID)
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
|
@ -19,7 +19,7 @@ class RecipeIngredientRefLink(SqlAlchemyBase, BaseMixins):
|
|||
|
||||
class RecipeInstruction(SqlAlchemyBase):
|
||||
__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"))
|
||||
position = Column(Integer)
|
||||
type = Column(String, default="")
|
||||
|
|
|
@ -6,6 +6,8 @@ import sqlalchemy.orm as orm
|
|||
from sqlalchemy.ext.orderinglist import ordering_list
|
||||
from sqlalchemy.orm import validates
|
||||
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
from ..users import users_to_favorites
|
||||
|
@ -43,13 +45,13 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
|||
slug = sa.Column(sa.String, index=True)
|
||||
|
||||
# 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])
|
||||
|
||||
user_id = sa.Column(sa.Integer, sa.ForeignKey("users.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")
|
||||
|
||||
|
|
|
@ -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_utils.guid import GUID
|
||||
|
||||
from .._model_utils import auto_init
|
||||
|
||||
|
@ -12,7 +13,7 @@ class ServerTaskModel(SqlAlchemyBase, BaseMixins):
|
|||
status = Column(String, nullable=False)
|
||||
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")
|
||||
|
||||
@auto_init()
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
|
||||
|
||||
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_utils import auto_init
|
||||
from ..group import Group
|
||||
from .user_to_favorite import users_to_favorites
|
||||
|
||||
settings = get_app_settings()
|
||||
|
||||
|
||||
class LongLiveToken(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "long_live_tokens"
|
||||
|
@ -32,7 +31,7 @@ class User(SqlAlchemyBase, BaseMixins):
|
|||
admin = 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 Permissions
|
||||
|
@ -40,17 +39,15 @@ class User(SqlAlchemyBase, BaseMixins):
|
|||
can_invite = Column(Boolean, default=False)
|
||||
can_organize = Column(Boolean, default=False)
|
||||
|
||||
tokens: list[LongLiveToken] = orm.relationship(
|
||||
LongLiveToken, back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
|
||||
)
|
||||
sp_args = {
|
||||
"back_populates": "user",
|
||||
"cascade": "all, delete, delete-orphan",
|
||||
"single_parent": True,
|
||||
}
|
||||
|
||||
comments: list = orm.relationship(
|
||||
"RecipeComment", back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
|
||||
)
|
||||
|
||||
password_reset_tokens = orm.relationship(
|
||||
"PasswordResetModel", back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
|
||||
)
|
||||
tokens = orm.relationship(LongLiveToken, **sp_args)
|
||||
comments = orm.relationship("RecipeComment", **sp_args)
|
||||
password_reset_tokens = orm.relationship("PasswordResetModel", **sp_args)
|
||||
|
||||
owned_recipes_id = Column(Integer, ForeignKey("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_organize",
|
||||
"group",
|
||||
"username",
|
||||
}
|
||||
|
||||
@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.favorite_recipes = []
|
||||
|
|
|
@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends
|
|||
from sqlalchemy.orm.session import Session
|
||||
|
||||
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.db.database import get_database
|
||||
from mealie.db.db_setup import generate_session
|
||||
|
@ -18,6 +19,7 @@ async def get_app_info():
|
|||
return AdminAboutInfo(
|
||||
production=settings.PRODUCTION,
|
||||
version=APP_VERSION,
|
||||
versionLatest=get_latest_version(),
|
||||
demo_status=settings.IS_DEMO,
|
||||
api_port=settings.API_PORT,
|
||||
api_docs=settings.API_DOCS,
|
||||
|
@ -49,4 +51,5 @@ async def check_app_config():
|
|||
email_ready=settings.SMTP_ENABLE,
|
||||
ldap_ready=settings.LDAP_ENABLED,
|
||||
base_url_set=url_set,
|
||||
is_up_to_date=get_latest_version() == APP_VERSION,
|
||||
)
|
||||
|
|
|
@ -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)
|
|
@ -18,7 +18,7 @@ def log_wrapper(request: Request, e):
|
|||
def register_debug_handler(app: FastAPI):
|
||||
settings = get_app_settings()
|
||||
|
||||
if settings.PRODUCTION:
|
||||
if settings.PRODUCTION and not settings.TESTING:
|
||||
return
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
|
|
|
@ -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(comments.router, prefix=prefix, tags=["Recipe: Comments"])
|
||||
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"])
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
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 (
|
||||
AssignCategories,
|
||||
AssignTags,
|
||||
|
@ -38,7 +41,10 @@ def bulk_delete_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(
|
||||
export_recipes: ExportRecipes,
|
||||
temp_path=Depends(temporary_zip_path),
|
||||
|
@ -46,4 +52,26 @@ def bulk_export_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"}
|
||||
|
|
|
@ -21,8 +21,13 @@ logger = get_logger()
|
|||
|
||||
|
||||
@user_router.get("", response_model=list[RecipeSummary])
|
||||
async def get_all(start=0, limit=None, service: RecipeService = Depends(RecipeService.private)):
|
||||
json_compatible_item_data = jsonable_encoder(service.get_all(start, limit))
|
||||
async def get_all(
|
||||
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)
|
||||
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ class AppInfo(CamelModel):
|
|||
|
||||
|
||||
class AdminAboutInfo(AppInfo):
|
||||
versionLatest: str
|
||||
api_port: int
|
||||
api_docs: bool
|
||||
db_type: str
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
from uuid import UUID
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic import validator
|
||||
from slugify import slugify
|
||||
|
@ -28,11 +30,11 @@ class UpdateCookBook(CreateCookBook):
|
|||
|
||||
|
||||
class SaveCookBook(CreateCookBook):
|
||||
group_id: int
|
||||
group_id: UUID
|
||||
|
||||
|
||||
class ReadCookBook(UpdateCookBook):
|
||||
group_id: int
|
||||
group_id: UUID
|
||||
categories: list[CategoryBase] = []
|
||||
|
||||
class Config:
|
||||
|
@ -40,7 +42,7 @@ class ReadCookBook(UpdateCookBook):
|
|||
|
||||
|
||||
class RecipeCookBook(ReadCookBook):
|
||||
group_id: int
|
||||
group_id: UUID
|
||||
categories: list[RecipeCategoryResponse]
|
||||
|
||||
class Config:
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
from fastapi_camelcase import CamelModel
|
||||
from pydantic import UUID4
|
||||
|
||||
from .group_preferences import UpdateGroupPreferences
|
||||
|
||||
|
||||
class GroupAdminUpdate(CamelModel):
|
||||
id: int
|
||||
id: UUID4
|
||||
name: str
|
||||
preferences: UpdateGroupPreferences
|
||||
|
|
17
mealie/schema/group/group_exports.py
Normal file
17
mealie/schema/group/group_exports.py
Normal 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
|
|
@ -1,3 +1,5 @@
|
|||
from uuid import UUID
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
|
||||
|
||||
|
@ -15,7 +17,7 @@ class UpdateGroupPreferences(CamelModel):
|
|||
|
||||
|
||||
class CreateGroupPreferences(UpdateGroupPreferences):
|
||||
group_id: int
|
||||
group_id: UUID
|
||||
|
||||
|
||||
class ReadGroupPreferences(CreateGroupPreferences):
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
from uuid import UUID
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
|
||||
|
||||
|
@ -7,14 +9,14 @@ class CreateInviteToken(CamelModel):
|
|||
|
||||
class SaveInviteToken(CamelModel):
|
||||
uses_left: int
|
||||
group_id: int
|
||||
group_id: UUID
|
||||
token: str
|
||||
|
||||
|
||||
class ReadInviteToken(CamelModel):
|
||||
token: str
|
||||
uses_left: int
|
||||
group_id: int
|
||||
group_id: UUID
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
from uuid import UUID
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
|
||||
|
||||
|
@ -9,7 +11,7 @@ class CreateWebhook(CamelModel):
|
|||
|
||||
|
||||
class SaveWebhook(CreateWebhook):
|
||||
group_id: int
|
||||
group_id: UUID
|
||||
|
||||
|
||||
class ReadWebhook(SaveWebhook):
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from datetime import date
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic import validator
|
||||
|
@ -33,11 +34,11 @@ class CreatePlanEntry(CamelModel):
|
|||
|
||||
class UpdatePlanEntry(CreatePlanEntry):
|
||||
id: int
|
||||
group_id: int
|
||||
group_id: UUID
|
||||
|
||||
|
||||
class SavePlanEntry(CreatePlanEntry):
|
||||
group_id: int
|
||||
group_id: UUID
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
@ -59,7 +60,7 @@ class RecipeSummary(CamelModel):
|
|||
id: Optional[int]
|
||||
|
||||
user_id: int = 0
|
||||
group_id: int = 0
|
||||
group_id: UUID = Field(default_factory=uuid4)
|
||||
|
||||
name: Optional[str]
|
||||
slug: str = ""
|
||||
|
@ -74,6 +75,7 @@ class RecipeSummary(CamelModel):
|
|||
description: Optional[str] = ""
|
||||
recipe_category: Optional[list[RecipeTag]] = []
|
||||
tags: Optional[list[RecipeTag]] = []
|
||||
tools: list[RecipeTool] = []
|
||||
rating: Optional[int]
|
||||
org_url: Optional[str] = Field(None, alias="orgURL")
|
||||
|
||||
|
@ -86,23 +88,28 @@ class RecipeSummary(CamelModel):
|
|||
orm_mode = 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):
|
||||
return [RecipeTag(name=c, slug=slugify(c)) for c in cats]
|
||||
return cats
|
||||
|
||||
@validator("recipe_category", always=True, pre=True)
|
||||
def validate_categories(cats: list[Any], values):
|
||||
def validate_categories(cats: list[Any]):
|
||||
if isinstance(cats, list) and cats and isinstance(cats[0], str):
|
||||
return [RecipeCategory(name=c, slug=slugify(c)) for c in cats]
|
||||
return cats
|
||||
|
||||
@validator("group_id", always=True, pre=True)
|
||||
def validate_group_id(group_id: list[Any]):
|
||||
if isinstance(group_id, int):
|
||||
return uuid4()
|
||||
return group_id
|
||||
|
||||
|
||||
class Recipe(RecipeSummary):
|
||||
recipe_ingredient: Optional[list[RecipeIngredient]] = []
|
||||
recipe_instructions: Optional[list[RecipeStep]] = []
|
||||
nutrition: Optional[Nutrition]
|
||||
tools: list[RecipeTool] = []
|
||||
|
||||
# Mealie Specific
|
||||
settings: Optional[RecipeSettings] = RecipeSettings()
|
||||
|
|
|
@ -37,7 +37,7 @@ class ReportEntryOut(ReportEntryCreate):
|
|||
class ReportCreate(CamelModel):
|
||||
timestamp: datetime.datetime = Field(default_factory=datetime.datetime.utcnow)
|
||||
category: ReportCategory
|
||||
group_id: int
|
||||
group_id: UUID4
|
||||
name: str
|
||||
status: ReportSummaryStatus = ReportSummaryStatus.in_progress
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import datetime
|
||||
import enum
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic import Field
|
||||
|
@ -18,7 +19,7 @@ class ServerTaskStatus(str, enum.Enum):
|
|||
|
||||
|
||||
class ServerTaskCreate(CamelModel):
|
||||
group_id: int
|
||||
group_id: UUID
|
||||
name: ServerTaskNames = ServerTaskNames.default
|
||||
created_at: datetime.datetime = Field(default_factory=datetime.datetime.now)
|
||||
status: ServerTaskStatus = ServerTaskStatus.running
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic import UUID4
|
||||
from pydantic.types import constr
|
||||
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.schema.group.group_preferences import ReadGroupPreferences
|
||||
from mealie.schema.recipe import RecipeSummary
|
||||
|
@ -87,7 +90,7 @@ class UserIn(UserBase):
|
|||
class UserOut(UserBase):
|
||||
id: int
|
||||
group: str
|
||||
group_id: int
|
||||
group_id: UUID4
|
||||
tokens: Optional[list[LongLiveTokenOut]]
|
||||
favorite_recipes: Optional[list[str]] = []
|
||||
|
||||
|
@ -119,14 +122,14 @@ class UserFavorites(UserBase):
|
|||
|
||||
class PrivateUser(UserOut):
|
||||
password: str
|
||||
group_id: int
|
||||
group_id: UUID4
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class UpdateGroup(GroupBase):
|
||||
id: int
|
||||
id: UUID4
|
||||
name: str
|
||||
categories: Optional[list[CategoryBase]] = []
|
||||
|
||||
|
@ -141,6 +144,26 @@ class GroupInDB(UpdateGroup):
|
|||
class Config:
|
||||
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):
|
||||
id: int
|
||||
|
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
from functools import cached_property
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.schema.group.group import GroupAdminUpdate
|
||||
from mealie.schema.mapper import mapper
|
||||
|
@ -16,7 +17,7 @@ from mealie.services.group_services.group_utils import create_new_group
|
|||
|
||||
class AdminGroupService(
|
||||
CrudHttpMixins[GroupBase, GroupInDB, GroupAdminUpdate],
|
||||
AdminHttpService[int, GroupInDB],
|
||||
AdminHttpService[UUID4, GroupInDB],
|
||||
):
|
||||
event_func = create_group_event
|
||||
_schema = GroupInDB
|
||||
|
@ -25,7 +26,7 @@ class AdminGroupService(
|
|||
def dal(self):
|
||||
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)
|
||||
return self.item
|
||||
|
||||
|
@ -35,13 +36,13 @@ class AdminGroupService(
|
|||
def create_one(self, data: GroupBase) -> GroupInDB:
|
||||
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
|
||||
|
||||
if data.preferences:
|
||||
preferences = self.db.group_preferences.get_one(value=target_id, key="group_id")
|
||||
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]:
|
||||
self.item.name = data.name
|
||||
|
@ -49,11 +50,13 @@ class AdminGroupService(
|
|||
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ErrorResponse(message="Cannot delete group with users").dict(),
|
||||
)
|
||||
|
||||
return self._delete_one(id)
|
||||
return self._delete_one(target_id)
|
||||
|
|
2
mealie/services/exporter/__init__.py
Normal file
2
mealie/services/exporter/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
from .exporter import *
|
||||
from .recipe_exporter import *
|
91
mealie/services/exporter/_abc_exporter.py
Normal file
91
mealie/services/exporter/_abc_exporter.py
Normal 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
|
51
mealie/services/exporter/exporter.py
Normal file
51
mealie/services/exporter/exporter.py
Normal 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
|
41
mealie/services/exporter/recipe_exporter.py
Normal file
41
mealie/services/exporter/recipe_exporter.py
Normal 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"})
|
|
@ -1,3 +1,5 @@
|
|||
from uuid import uuid4
|
||||
|
||||
from mealie.db.data_access_layer.access_model_factory import Database
|
||||
from mealie.schema.group.group_preferences import CreateGroupPreferences
|
||||
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:
|
||||
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
|
||||
|
||||
|
|
|
@ -99,6 +99,9 @@ def sizeof_fmt(file_path: Path, decimal_places=2):
|
|||
|
||||
|
||||
def move_all_images():
|
||||
if not app_dirs.IMG_DIR.exists():
|
||||
return
|
||||
|
||||
for image_file in app_dirs.IMG_DIR.iterdir():
|
||||
if image_file.is_file():
|
||||
if image_file.name == ".DS_Store":
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from mealie.core import root_logger
|
||||
from mealie.db.database import Database
|
||||
|
@ -25,7 +26,7 @@ class BaseMigrator(BaseService):
|
|||
report_id: int
|
||||
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.db = db
|
||||
self.session = session
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from mealie.db.database import Database
|
||||
|
||||
|
@ -10,7 +11,7 @@ from .utils.migration_helpers import MigrationReaders, import_image, split_by_co
|
|||
|
||||
|
||||
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)
|
||||
|
||||
self.key_aliases = [
|
||||
|
|
|
@ -3,6 +3,7 @@ import zipfile
|
|||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from slugify import slugify
|
||||
|
||||
|
@ -39,7 +40,7 @@ class NextcloudDir:
|
|||
|
||||
|
||||
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)
|
||||
|
||||
self.key_aliases = [
|
||||
|
|
|
@ -3,10 +3,12 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
|
||||
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.recipe_category import TagBase
|
||||
from mealie.services._base_http_service.http_services import UserHttpService
|
||||
from mealie.services.events import create_recipe_event
|
||||
from mealie.services.exporter import Exporter, RecipeExporter
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
@ -18,8 +20,36 @@ class RecipeBulkActions(UserHttpService[int, Recipe]):
|
|||
def populate_item(self, _: int) -> Recipe:
|
||||
return
|
||||
|
||||
def export_recipes(self, temp_path: Path, recipes: list[str]) -> None:
|
||||
return
|
||||
def export_recipes(self, temp_path: Path, slugs: list[str]) -> None:
|
||||
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:
|
||||
for slug in recipes:
|
||||
|
|
|
@ -59,14 +59,18 @@ class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpServic
|
|||
if not self.item.settings.public and not self.user:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def get_all(self, start=0, limit=None):
|
||||
items = self.db.recipes.summary(self.user.group_id, start=start, limit=limit)
|
||||
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, load_foods=load_foods)
|
||||
|
||||
new_items = []
|
||||
|
||||
for item in items:
|
||||
# Pydantic/FastAPI can't seem to serialize the ingredient field on thier own.
|
||||
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)
|
||||
|
||||
return [RecipeSummary.construct(**x) for x in new_items]
|
||||
|
|
|
@ -15,7 +15,7 @@ CWD = Path(__file__).parent
|
|||
|
||||
app_dirs = get_app_dirs()
|
||||
TEMP_DATA = app_dirs.DATA_DIR / ".temp"
|
||||
SCHEDULER_DB = TEMP_DATA / "scheduler.db"
|
||||
SCHEDULER_DB = CWD / ".scheduler.db"
|
||||
SCHEDULER_DATABASE = f"sqlite:///{SCHEDULER_DB}"
|
||||
|
||||
MINUTES_DAY = 1440
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from .auto_backup import *
|
||||
from .purge_events import *
|
||||
from .purge_group_exports import *
|
||||
from .purge_password_reset import *
|
||||
from .purge_registration import *
|
||||
from .webhooks import *
|
||||
|
|
46
mealie/services/scheduler/tasks/purge_group_exports.py
Normal file
46
mealie/services/scheduler/tasks/purge_group_exports.py
Normal 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")
|
|
@ -1,3 +1,5 @@
|
|||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
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_preferences = CreateGroupPreferences(
|
||||
group_id=0,
|
||||
group_id=uuid4(),
|
||||
private_group=self.registration.private,
|
||||
first_day_of_week=0,
|
||||
recipe_public=not self.registration.private,
|
||||
|
|
2
tests/fixtures/fixture_admin.py
vendored
2
tests/fixtures/fixture_admin.py
vendored
|
@ -31,7 +31,7 @@ def admin_user(api_client: TestClient, api_routes: utils.AppRoutes):
|
|||
|
||||
try:
|
||||
yield utils.TestUser(
|
||||
group_id=user_data.get("groupId"),
|
||||
_group_id=user_data.get("groupId"),
|
||||
user_id=user_data.get("id"),
|
||||
email=user_data.get("email"),
|
||||
token=token,
|
||||
|
|
4
tests/fixtures/fixture_users.py
vendored
4
tests/fixtures/fixture_users.py
vendored
|
@ -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")
|
||||
|
||||
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:
|
||||
# TODO: Delete User after test
|
||||
pass
|
||||
|
@ -59,7 +59,7 @@ def unique_user(api_client: TestClient, api_routes: utils.AppRoutes):
|
|||
|
||||
try:
|
||||
yield utils.TestUser(
|
||||
group_id=user_data.get("groupId"),
|
||||
_group_id=user_data.get("groupId"),
|
||||
user_id=user_data.get("id"),
|
||||
email=user_data.get("email"),
|
||||
token=token,
|
||||
|
|
|
@ -16,21 +16,21 @@ class Routes:
|
|||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
assert response.status_code == 403
|
||||
|
||||
response = api_client.post(Routes.base, json={}, headers=unique_user.token)
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
@ -75,5 +75,5 @@ def test_admin_delete_group(api_client: TestClient, admin_user: TestUser, unique
|
|||
assert response.status_code == 200
|
||||
|
||||
# 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
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import pytest
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.utils.assertion_helpers import assert_ignore_keys
|
||||
from tests.utils.fixture_schemas import TestUser
|
||||
|
||||
|
||||
class Routes:
|
||||
base = "/api/groups/cookbooks"
|
||||
|
@ -9,38 +13,45 @@ class Routes:
|
|||
return f"{Routes.base}/{item_id}"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def page_data():
|
||||
return {"name": "My New Page", "description": "", "position": 0, "categories": [], "groupId": 1}
|
||||
def get_page_data(group_id: UUID):
|
||||
return {
|
||||
"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):
|
||||
response = api_client.post(Routes.base, json=page_data, headers=admin_token)
|
||||
|
||||
def test_create_cookbook(api_client: TestClient, unique_user: TestUser):
|
||||
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
|
||||
|
||||
|
||||
def test_read_cookbook(api_client: TestClient, page_data, admin_token):
|
||||
response = api_client.get(Routes.item(1), headers=admin_token)
|
||||
def test_read_cookbook(api_client: TestClient, unique_user: TestUser):
|
||||
page_data = get_page_data(unique_user.group_id)
|
||||
|
||||
page_data["id"] = 1
|
||||
page_data["slug"] = "my-new-page"
|
||||
|
||||
assert response.json() == page_data
|
||||
response = api_client.get(Routes.item(1), headers=unique_user.token)
|
||||
assert_ignore_keys(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["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
|
||||
|
||||
|
||||
def test_delete_cookbook(api_client: TestClient, admin_token):
|
||||
response = api_client.delete(Routes.item(1), headers=admin_token)
|
||||
def test_delete_cookbook(api_client: TestClient, unique_user: TestUser):
|
||||
response = api_client.delete(Routes.item(1), headers=unique_user.token)
|
||||
|
||||
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
|
||||
|
|
|
@ -9,20 +9,15 @@ class Routes:
|
|||
user = "/api/users/self"
|
||||
|
||||
|
||||
GROUP_ID = 1
|
||||
ADMIN_ID = 1
|
||||
USER_ID = 2
|
||||
|
||||
|
||||
def test_ownership_on_new_with_admin(api_client: TestClient, admin_token):
|
||||
def test_ownership_on_new_with_admin(api_client: TestClient, admin_user: TestUser):
|
||||
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
|
||||
|
||||
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["groupId"] == GROUP_ID
|
||||
assert recipe["userId"] == admin_user.user_id
|
||||
assert recipe["groupId"] == admin_user.group_id
|
||||
|
||||
|
||||
def test_ownership_on_new_with_user(api_client: TestClient, g2_user: TestUser):
|
||||
|
|
|
@ -4,6 +4,7 @@ from mealie.core.config import get_app_dirs, get_app_settings
|
|||
from mealie.core.settings.db_providers import SQLiteProvider
|
||||
|
||||
os.environ["PRODUCTION"] = "True"
|
||||
os.environ["TESTING"] = "True"
|
||||
|
||||
settings = get_app_settings()
|
||||
app_dirs = get_app_dirs()
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestUser:
|
||||
email: str
|
||||
user_id: int
|
||||
group_id: int
|
||||
_group_id: UUID
|
||||
token: Any
|
||||
|
||||
@property
|
||||
def group_id(self) -> str:
|
||||
return str(self._group_id)
|
||||
|
|
Loading…
Reference in a new issue