feat: add user recipe export functionality (#845)

* feat(frontend):  add user recipe export functionality

* remove depreciated folders

* change/remove depreciated folders

* add testing variable in config

* add GUID support for group_id

* improve testing feedback on 422 errors

* remove/cleanup files/folders

* initial user export support

* delete unused css

* update backup page UI

* remove depreciated settings

* feat:  export download links

* fix #813

* remove top level statements

* show footer

* add export purger to scheduler

* update purge glob

* fix meal-planner lockout

* feat:  add bulk delete/purge exports

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

* feat:  add version checker

* update documentation

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

1
.gitignore vendored
View file

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

View file

@ -2,35 +2,59 @@
To install Mealie on your server there are a few steps for proper configuration. Let's go through them.
!!! 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

View file

@ -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;
}

View file

@ -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);
}
}

View file

@ -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,
});
},

View file

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

View file

@ -7,6 +7,7 @@
:items="recipes"
:items-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;
});

View file

@ -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>

View file

@ -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>

View file

@ -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,

View file

@ -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;
}

View file

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

View file

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

View file

@ -110,7 +110,7 @@
</v-col>
<v-col v-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>

View file

@ -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,

View file

@ -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()

View file

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

View file

@ -2,30 +2,33 @@ from pathlib import Path
class AppDirectories:
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,
]

View file

@ -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

View file

@ -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:

View file

@ -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)

View file

@ -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)

View file

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

View file

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

View file

@ -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()

View file

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

View file

@ -1,15 +1,17 @@
import uuid
import sqlalchemy as sa
import sqlalchemy.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:

View file

@ -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()

View file

@ -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"))

View file

@ -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)

View file

@ -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:

View file

@ -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)

View file

@ -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)

View file

@ -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")),
)

View file

@ -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

View file

@ -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

View file

@ -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="")

View file

@ -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")

View file

@ -1,6 +1,7 @@
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, orm
from sqlalchemy import Column, DateTime, ForeignKey, String, orm
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models._model_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()

View file

@ -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 = []

View file

@ -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,
)

View file

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

View file

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

View file

@ -13,3 +13,4 @@ router.include_router(recipe_crud_routes.user_router, prefix=prefix, tags=["Reci
router.include_router(image_and_assets.user_router, prefix=prefix, tags=["Recipe: Images and Assets"])
router.include_router(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"])

View file

@ -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"}

View file

@ -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)

View file

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

View file

@ -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:

View file

@ -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

View file

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

View file

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

View file

@ -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

View file

@ -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):

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,5 @@
from uuid import uuid4
from mealie.db.data_access_layer.access_model_factory import Database
from mealie.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

View file

@ -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":

View file

@ -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

View file

@ -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 = [

View file

@ -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 = [

View file

@ -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:

View file

@ -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]

View file

@ -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

View file

@ -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 *

View file

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

View file

@ -1,3 +1,5 @@
from uuid import uuid4
from fastapi import HTTPException, status
from 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,

View file

@ -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,

View file

@ -38,7 +38,7 @@ def g2_user(admin_token, api_client: requests, api_routes: utils.AppRoutes):
group_id = json.loads(self_response.text).get("groupId")
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,

View file

@ -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

View file

@ -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

View file

@ -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):

View file

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

View file

@ -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)