Refactor/backend routers (#388)

* update router

* update caddy file

* setup depends in docker-fole

* make changes for serving on subpath

* set dev config

* fix router signups

* consolidate links

* backup-functionality to dashboard

* new user card

* consolidate theme into profile

* fix theme tests

* fix pg tests

* fix pg tests

* remove unused import

* mobile margin

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-05-04 20:45:11 -08:00 committed by GitHub
parent be5ac7a17a
commit c1370afb16
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 878 additions and 1094 deletions

19
.vscode/settings.json vendored
View file

@ -1,28 +1,19 @@
{ {
"python.formatting.provider": "black", "python.formatting.provider": "black",
"python.pythonPath": ".venv/bin/python3.9", "python.pythonPath": ".venv/bin/python3.9",
"python.linting.pylintEnabled": true, "python.linting.pylintEnabled": false,
"python.linting.enabled": true, "python.linting.enabled": true,
"python.testing.unittestEnabled": false, "python.testing.unittestEnabled": false,
"python.testing.nosetestsEnabled": false, "python.testing.nosetestsEnabled": false,
"python.testing.pytestEnabled": true, "python.testing.pytestEnabled": true,
"python.testing.autoTestDiscoverOnSaveEnabled": false, "python.testing.autoTestDiscoverOnSaveEnabled": false,
"python.testing.pytestArgs": ["tests"], "python.testing.pytestArgs": ["tests"],
"cSpell.enableFiletypes": [ "cSpell.enableFiletypes": ["!javascript", "!python", "!yaml"],
"!javascript",
"!python",
"!yaml"
],
"i18n-ally.localesPaths": "frontend/src/locales/messages", "i18n-ally.localesPaths": "frontend/src/locales/messages",
"i18n-ally.sourceLanguage": "en-US", "i18n-ally.sourceLanguage": "en-US",
"i18n-ally.enabledFrameworks": ["vue"], "i18n-ally.enabledFrameworks": ["vue"],
"i18n-ally.keystyle": "nested", "i18n-ally.keystyle": "nested",
"cSpell.words": [ "cSpell.words": ["compression", "hkotel", "performant", "postgres", "webp"],
"compression", "search.mode": "reuseEditor",
"hkotel", "python.linting.flake8Enabled": true
"performant",
"postgres",
"webp"
],
"search.mode": "reuseEditor"
} }

View file

@ -6,11 +6,11 @@
:80 { :80 {
@proxied path /api/* /docs /openapi.json @proxied path /api/* /docs /openapi.json
root * /app/dist encode gzip zstd
encode gzip
uri strip_suffix / uri strip_suffix /
handle_path /api/recipes/media/* { # Handles Recipe Images / Assets
handle_path /api/media/recipes/* {
root * /app/data/recipes/ root * /app/data/recipes/
file_server file_server
} }
@ -20,8 +20,8 @@
} }
handle { handle {
try_files {path}.html {path} / root * /app/dist
try_files {path}.html {path} /index.html
file_server file_server
} }
} }

9
Caddyfile.dev Normal file
View file

@ -0,0 +1,9 @@
{
admin off
}
localhost {
handle /mealie/* {
reverse_proxy http://127.0.0.1:9090
}
}

View file

@ -6,6 +6,8 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: mealie container_name: mealie
restart: always restart: always
depends_on:
- "postgres"
ports: ports:
- 9090:80 - 9090:80
environment: environment:

View file

@ -135,14 +135,18 @@ export const recipeAPI = {
}, },
recipeImage(recipeSlug) { recipeImage(recipeSlug) {
return `/api/recipes/media/${recipeSlug}/images/original.webp`; return `/api/media/recipes/${recipeSlug}/images/original.webp`;
}, },
recipeSmallImage(recipeSlug) { recipeSmallImage(recipeSlug) {
return `/api/recipes/media/${recipeSlug}/images/min-original.webp`; return `/api/media/recipes/${recipeSlug}/images/min-original.webp`;
}, },
recipeTinyImage(recipeSlug) { recipeTinyImage(recipeSlug) {
return `/api/recipes/media/${recipeSlug}/images/tiny-original.webp`; return `/api/media/recipes/${recipeSlug}/images/tiny-original.webp`;
},
recipeAssetPath(recipeSlug, assetName) {
return `api/media/recipes/${recipeSlug}/assets/${assetName}`;
}, },
}; };

View file

@ -6,10 +6,10 @@ const prefix = baseURL + "themes";
const settingsURLs = { const settingsURLs = {
allThemes: `${baseURL}themes`, allThemes: `${baseURL}themes`,
specificTheme: themeName => `${prefix}/${themeName}`, specificTheme: id => `${prefix}/${id}`,
createTheme: `${prefix}/create`, createTheme: `${prefix}/create`,
updateTheme: themeName => `${prefix}/${themeName}`, updateTheme: id => `${prefix}/${id}`,
deleteTheme: themeName => `${prefix}/${themeName}`, deleteTheme: id => `${prefix}/${id}`,
}; };
export const themeAPI = { export const themeAPI = {
@ -32,22 +32,18 @@ export const themeAPI = {
); );
}, },
update(themeName, colors) { update(data) {
const body = {
name: themeName,
colors: colors,
};
return apiReq.put( return apiReq.put(
settingsURLs.updateTheme(themeName), settingsURLs.updateTheme(data.id),
body, data,
() => i18n.t("settings.theme.error-updating-theme"), () => i18n.t("settings.theme.error-updating-theme"),
() => i18n.t("settings.theme.theme-updated") () => i18n.t("settings.theme.theme-updated")
); );
}, },
delete(themeName) { delete(id) {
return apiReq.delete( return apiReq.delete(
settingsURLs.deleteTheme(themeName), settingsURLs.deleteTheme(id),
null, null,
() => i18n.t("settings.theme.error-deleting-theme"), () => i18n.t("settings.theme.error-deleting-theme"),
() => i18n.t("settings.theme.theme-deleted") () => i18n.t("settings.theme.theme-deleted")

View file

@ -3,7 +3,7 @@
<div class="text-center"> <div class="text-center">
<h3>{{ buttonText }}</h3> <h3>{{ buttonText }}</h3>
</div> </div>
<v-text-field v-model="color" hide-details class="ma-0 pa-0" solo v-show="$vuetify.breakpoint.mdAndUp"> <v-text-field v-model="color" hide-details class="ma-0 pa-0" solo >
<template v-slot:append> <template v-slot:append>
<v-menu v-model="menu" top nudge-bottom="105" nudge-left="16" :close-on-content-click="false"> <v-menu v-model="menu" top nudge-bottom="105" nudge-left="16" :close-on-content-click="false">
<template v-slot:activator="{ on }"> <template v-slot:activator="{ on }">
@ -17,15 +17,7 @@
</v-menu> </v-menu>
</template> </template>
</v-text-field> </v-text-field>
<div class="text-center" v-show="$vuetify.breakpoint.smAndDown">
<v-menu v-model="menu" top nudge-bottom="105" nudge-left="16" :close-on-content-click="false">
<template v-slot:activator="{ on, attrs }">
<v-chip label :color="`${color}`" dark v-bind="attrs" v-on="on">
{{ color }}
</v-chip>
</template>
</v-menu>
</div>
</div> </div>
</template> </template>

View file

@ -1,8 +1,8 @@
<template> <template>
<div> <div>
<v-checkbox <v-checkbox
v-for="option in options" v-for="(option, index) in options"
:key="option.text" :key="index"
class="mb-n4 mt-n3" class="mb-n4 mt-n3"
dense dense
:label="option.text" :label="option.text"
@ -61,6 +61,4 @@ export default {
}, },
}, },
}; };
</script> </script>
<style lang="scss" scoped></style>

View file

@ -14,14 +14,7 @@
<v-list-item-title class="pl-2" v-text="item.name"></v-list-item-title> <v-list-item-title class="pl-2" v-text="item.name"></v-list-item-title>
</v-list-item-content> </v-list-item-content>
<v-list-item-action> <v-list-item-action>
<v-btn <v-btn v-if="!edit" color="primary" icon :href="assetURL(item.fileName)" target="_blank" top>
v-if="!edit"
color="primary"
icon
:href="`/api/recipes/media/${slug}/assets/${item.fileName}`"
target="_blank"
top
>
<v-icon> mdi-download</v-icon> <v-icon> mdi-download</v-icon>
</v-btn> </v-btn>
<div v-else> <div v-else>
@ -118,6 +111,9 @@ export default {
}, },
}, },
methods: { methods: {
assetURL(assetName) {
return api.recipes.recipeAssetPath(this.slug, assetName);
},
setFileObject(obj) { setFileObject(obj) {
this.fileObject = obj; this.fileObject = obj;
}, },
@ -135,7 +131,8 @@ export default {
this.value.splice(index, 1); this.value.splice(index, 1);
}, },
copyLink(name, fileName) { copyLink(name, fileName) {
const copyText = `![${name}](${this.baseURL}/api/recipes/media/${this.slug}/assets/${fileName})`; const assetLink = api.recipes.recipeAssetPath(this.slug, fileName);
const copyText = `![${name}](${assetLink})`;
navigator.clipboard.writeText(copyText).then( navigator.clipboard.writeText(copyText).then(
() => console.log("Copied", copyText), () => console.log("Copied", copyText),
() => console.log("Copied Failed", copyText) () => console.log("Copied Failed", copyText)

View file

@ -18,7 +18,7 @@
color="secondary darken-1" color="secondary darken-1"
class="rounded-sm static" class="rounded-sm static"
> >
{{ yields }} {{ recipe.yields }}
</v-btn> </v-btn>
</v-col> </v-col>
<Rating :value="recipe.rating" :name="recipe.name" :slug="recipe.slug" /> <Rating :value="recipe.rating" :name="recipe.name" :slug="recipe.slug" />

View file

@ -2,7 +2,7 @@
<v-form ref="file"> <v-form ref="file">
<input ref="uploader" class="d-none" type="file" @change="onFileChanged" /> <input ref="uploader" class="d-none" type="file" @change="onFileChanged" />
<slot v-bind="{ isSelecting, onButtonClick }"> <slot v-bind="{ isSelecting, onButtonClick }">
<v-btn :loading="isSelecting" @click="onButtonClick" color="accent" :text="textBtn"> <v-btn :loading="isSelecting" @click="onButtonClick" :small="small" color="accent" :text="textBtn">
<v-icon left> {{ icon }}</v-icon> <v-icon left> {{ icon }}</v-icon>
{{ text ? text : defaultText }} {{ text ? text : defaultText }}
</v-btn> </v-btn>
@ -15,6 +15,9 @@ const UPLOAD_EVENT = "uploaded";
import { api } from "@/api"; import { api } from "@/api";
export default { export default {
props: { props: {
small: {
default: false,
},
post: { post: {
type: Boolean, type: Boolean,
default: true, default: true,
@ -27,7 +30,7 @@ export default {
default: true, default: true,
}, },
}, },
data: () => ({ data: () => ({
file: null, file: null,
isSelecting: false, isSelecting: false,
}), }),

View file

@ -1,39 +1,30 @@
<template> <template>
<div class="mt-n5" v-if="recipes"> <div v-if="recipes">
<v-card flat class="transparent" height="60px"> <v-app-bar color="transparent" flat class="mt-n1 rounded" v-if="!disableToolbar">
<v-card-text> <v-icon large left v-if="title">
<v-row v-if="title != null"> {{ titleIcon }}
<v-col> </v-icon>
<v-btn-toggle group> <v-toolbar-title class="headline"> {{ title }} </v-toolbar-title>
<v-btn text> <v-spacer></v-spacer>
{{ title.toUpperCase() }} <v-menu offset-y v-if="$listeners.sortRecent || $listeners.sort">
</v-btn> <template v-slot:activator="{ on, attrs }">
</v-btn-toggle> <v-btn-toggle group>
</v-col> <v-btn text v-bind="attrs" v-on="on">
<v-spacer></v-spacer> {{ $t("general.sort") }}
<v-col align="end"> </v-btn>
<v-menu offset-y v-if="sortable"> </v-btn-toggle>
<template v-slot:activator="{ on, attrs }"> </template>
<v-btn-toggle group> <v-list>
<v-btn text v-bind="attrs" v-on="on"> <v-list-item @click="$emit('sortRecent')">
{{ $t("general.sort") }} <v-list-item-title>{{ $t("general.recent") }}</v-list-item-title>
</v-btn> </v-list-item>
</v-btn-toggle> <v-list-item @click="$emit('sort')">
</template> <v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title>
<v-list> </v-list-item>
<v-list-item @click="$emit('sort-recent')"> </v-list>
<v-list-item-title>{{ $t("general.recent") }}</v-list-item-title> </v-menu>
</v-list-item> </v-app-bar>
<v-list-item @click="$emit('sort')"> <div v-if="recipes" class="mt-2">
<v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-col>
</v-row>
</v-card-text>
</v-card>
<div v-if="recipes">
<v-row v-if="!viewScale"> <v-row v-if="!viewScale">
<v-col :sm="6" :md="6" :lg="4" :xl="3" v-for="recipe in recipes.slice(0, cardLimit)" :key="recipe.name"> <v-col :sm="6" :md="6" :lg="4" :xl="3" v-for="recipe in recipes.slice(0, cardLimit)" :key="recipe.name">
<RecipeCard <RecipeCard
@ -91,9 +82,12 @@ export default {
MobileRecipeCard, MobileRecipeCard,
}, },
props: { props: {
sortable: { disableToolbar: {
default: false, default: false,
}, },
titleIcon: {
default: "mdi-tag-multiple-outline",
},
title: { title: {
default: null, default: null,
}, },

View file

@ -0,0 +1,142 @@
<template>
<div>
<BaseDialog
:title="$t('settings.backup.create-heading')"
titleIcon="mdi-database"
@submit="createBackup"
:submit-text="$t('general.create')"
:loading="loading"
>
<template v-slot:open="{ open }">
<v-btn @click="open" class="mx-2" small :color="color"> <v-icon left> mdi-plus </v-icon> Custom </v-btn>
</template>
<v-card-text class="mt-6">
<v-text-field dense :label="$t('settings.backup.backup-tag')" v-model="tag"></v-text-field>
</v-card-text>
<v-card-actions class="mt-n9 flex-wrap">
<v-switch v-model="fullBackup" :label="switchLabel"></v-switch>
<v-spacer></v-spacer>
</v-card-actions>
<v-expand-transition>
<div v-if="!fullBackup">
<v-card-text class="mt-n4">
<v-row>
<v-col sm="4">
<p>{{ $t("general.options") }}</p>
<ImportOptions @update-options="updateOptions" class="mt-5" />
</v-col>
<v-col>
<p>{{ $t("general.templates") }}</p>
<v-checkbox
v-for="template in availableTemplates"
:key="template"
class="mb-n4 mt-n3"
dense
:label="template"
@click="appendTemplate(template)"
></v-checkbox>
</v-col>
</v-row>
</v-card-text>
</div>
</v-expand-transition>
</BaseDialog>
</div>
</template>
<script>
import BaseDialog from "./BaseDialog";
import ImportOptions from "@/components/FormHelpers/ImportOptions";
import { api } from "@/api";
export default {
props: {
color: { default: "primary" },
},
components: {
BaseDialog,
ImportOptions,
},
data() {
return {
tag: null,
fullBackup: true,
loading: false,
options: {
recipes: true,
settings: true,
themes: true,
pages: true,
users: true,
groups: true,
},
availableTemplates: [],
selectedTemplates: [],
};
},
computed: {
switchLabel() {
if (this.fullBackup) {
return this.$t("settings.backup.full-backup");
} else return this.$t("settings.backup.partial-backup");
},
},
mounted() {
this.resetData();
this.getAvailableBackups();
},
methods: {
resetData() {
this.tag = null;
this.fullBackup = true;
this.loading = false;
this.options = {
recipes: true,
settings: true,
themes: true,
pages: true,
users: true,
groups: true,
};
this.availableTemplates = [];
this.selectedTemplates = [];
},
updateOptions(options) {
this.options = options;
},
async getAvailableBackups() {
const response = await api.backups.requestAvailable();
response.templates.forEach(element => {
this.availableTemplates.push(element);
});
},
async createBackup() {
this.loading = true;
const data = {
tag: this.tag,
options: {
recipes: this.options.recipes,
settings: this.options.settings,
pages: this.options.pages,
themes: this.options.themes,
users: this.options.users,
groups: this.options.groups,
},
templates: this.selectedTemplates,
};
if (await api.backups.create(data)) {
this.$emit("created");
}
this.loading = false;
},
appendTemplate(templateName) {
if (this.selectedTemplates.includes(templateName)) {
let index = this.selectedTemplates.indexOf(templateName);
if (index !== -1) {
this.selectedTemplates.splice(index, 1);
}
} else this.selectedTemplates.push(templateName);
},
},
};
</script>

View file

@ -3,14 +3,14 @@
<slot name="open" v-bind="{ open }"> </slot> <slot name="open" v-bind="{ open }"> </slot>
<v-dialog v-model="dialog" :width="modalWidth + 'px'" :content-class="top ? 'top-dialog' : undefined"> <v-dialog v-model="dialog" :width="modalWidth + 'px'" :content-class="top ? 'top-dialog' : undefined">
<v-card class="pb-10" height="100%"> <v-card class="pb-10" height="100%">
<v-app-bar dark :color="color" class="mt-n1 mb-2"> <v-app-bar dark :color="color" class="mt-n1 mb-0">
<v-icon large left> <v-icon large left>
{{ titleIcon }} {{ titleIcon }}
</v-icon> </v-icon>
<v-toolbar-title class="headline"> {{ title }} </v-toolbar-title> <v-toolbar-title class="headline"> {{ title }} </v-toolbar-title>
<v-spacer></v-spacer> <v-spacer></v-spacer>
</v-app-bar> </v-app-bar>
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear> <v-progress-linear class="mt-1" v-if="loading" indeterminate color="primary"></v-progress-linear>
<slot> </slot> <slot> </slot>
<v-card-actions> <v-card-actions>
<slot name="card-actions"> <slot name="card-actions">
@ -18,8 +18,12 @@
{{ $t("general.cancel") }} {{ $t("general.cancel") }}
</v-btn> </v-btn>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn color="error" text @click="deleteEvent" v-if="$listeners.delete">
{{ $t("general.delete") }}
</v-btn>
<v-btn color="success" @click="submitEvent"> <v-btn color="success" @click="submitEvent">
{{ $t("general.submit") }} {{ submitText }}
</v-btn> </v-btn>
</slot> </slot>
</v-card-actions> </v-card-actions>
@ -31,6 +35,7 @@
</template> </template>
<script> <script>
import i18n from "@/i18n";
export default { export default {
props: { props: {
color: { color: {
@ -51,16 +56,34 @@ export default {
top: { top: {
default: false, default: false,
}, },
submitText: {
default: () => i18n.t("general.create"),
},
}, },
data() { data() {
return { return {
dialog: false, dialog: false,
submitted: false,
}; };
}, },
computed: {
determineClose() {
return this.submitted && !this.loading;
},
},
watch: {
determineClose() {
this.submitted = false;
this.dialog = false;
},
dialog(val) {
if (val) this.submitted = false;
},
},
methods: { methods: {
submitEvent() { submitEvent() {
this.$emit("submit"); this.$emit("submit");
this.close(); this.submitted = true;
}, },
open() { open() {
this.dialog = true; this.dialog = true;
@ -68,6 +91,10 @@ export default {
close() { close() {
this.dialog = false; this.dialog = false;
}, },
deleteEvent() {
this.$emit("delete");
this.submitted = true;
},
}, },
}; };
</script> </script>

View file

@ -48,7 +48,7 @@
</template> </template>
<script> <script>
import ImportOptions from "./ImportOptions"; import ImportOptions from "@/components/FormHelpers/ImportOptions";
import TheDownloadBtn from "@/components/UI/Buttons/TheDownloadBtn.vue"; import TheDownloadBtn from "@/components/UI/Buttons/TheDownloadBtn.vue";
import { backupURLs } from "@/api/backup"; import { backupURLs } from "@/api/backup";
export default { export default {

View file

@ -1,17 +1,19 @@
w<template> w<template>
<v-card v-bind="$attrs" :class="classes" class="v-card--material pa-3"> <v-card v-bind="$attrs" :class="classes" class="v-card--material pa-3">
<div class="d-flex grow flex-wrap"> <div class="d-flex grow flex-wrap">
<v-sheet <slot name="avatar">
:color="color" <v-sheet
:max-height="icon ? 90 : undefined" :color="color"
:width="icon ? 'auto' : '100%'" :max-height="icon ? 90 : undefined"
elevation="6" :width="icon ? 'auto' : '100%'"
class="text-start v-card--material__heading mb-n6 mt-n10 pa-7" elevation="6"
dark class="text-start v-card--material__heading mb-n6 mt-n10 pa-7"
> dark
<v-icon v-if="icon" size="40" v-text="icon" /> >
<div v-if="text" class="headline font-weight-thin" v-text="text" /> <v-icon v-if="icon" size="40" v-text="icon" />
</v-sheet> <div v-if="text" class="headline font-weight-thin" v-text="text" />
</v-sheet>
</slot>
<div v-if="$slots['after-heading']" class="ml-auto"> <div v-if="$slots['after-heading']" class="ml-auto">
<slot name="after-heading" /> <slot name="after-heading" />
@ -29,7 +31,7 @@ w<template>
</template> </template>
<template v-if="$slots.bottom"> <template v-if="$slots.bottom">
<v-divider class="mt-2" /> <v-divider class="mt-2" v-if="!$slots.actions" />
<div class="pb-0"> <div class="pb-0">
<slot name="bottom" /> <slot name="bottom" />
@ -73,6 +75,7 @@ export default {
classes() { classes() {
return { return {
"v-card--material--has-heading": this.hasHeading, "v-card--material--has-heading": this.hasHeading,
"mt-3": this.$vuetify.breakpoint.name == "xs" || this.$vuetify.breakpoint.name == "sm"
}; };
}, },
hasHeading() { hasHeading() {

View file

@ -133,11 +133,6 @@ export default {
to: "/admin/profile", to: "/admin/profile",
title: this.$t("settings.profile"), title: this.$t("settings.profile"),
}, },
{
icon: "mdi-format-color-fill",
to: "/admin/themes",
title: this.$t("general.themes"),
},
{ {
icon: "mdi-food", icon: "mdi-food",
to: "/admin/meal-planner", to: "/admin/meal-planner",
@ -167,11 +162,6 @@ export default {
to: "/admin/manage-users", to: "/admin/manage-users",
title: this.$t("settings.manage-users"), title: this.$t("settings.manage-users"),
}, },
{
icon: "mdi-backup-restore",
to: "/admin/backups",
title: this.$t("settings.backup-and-exports"),
},
{ {
icon: "mdi-database-import", icon: "mdi-database-import",
to: "/admin/migrations", to: "/admin/migrations",

View file

@ -1,79 +0,0 @@
<template>
<div>
<ImportDialog
:name="selectedName"
:date="selectedDate"
ref="import_dialog"
@import="importBackup"
@delete="deleteBackup"
/>
<v-row>
<v-col :cols="12" :sm="6" :md="6" :lg="4" :xl="4" v-for="backup in backups" :key="backup.name">
<v-card hover outlined @click="openDialog(backup)">
<v-card-text>
<v-row align="center">
<v-col cols="2">
<v-icon large color="primary">mdi-backup-restore</v-icon>
</v-col>
<v-col cols="10">
<div class="text-truncate">
<strong>{{ backup.name }}</strong>
</div>
<div class="text-truncate">{{ $d(Date.parse(backup.date), "medium") }}</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
</div>
</template>
<script>
import ImportDialog from "./ImportDialog";
import { api } from "@/api";
export default {
props: {
backups: Array,
},
components: {
ImportDialog,
},
data() {
return {
selectedName: "",
selectedDate: "",
loading: false,
};
},
methods: {
openDialog(backup) {
this.selectedDate = backup.date;
this.selectedName = backup.name;
this.$refs.import_dialog.open();
},
async importBackup(data) {
this.$emit("loading");
const response = await api.backups.import(data.name, data);
if (response) {
let importData = response.data;
this.$emit("finished", importData);
} else {
this.$emit("finished");
}
},
async deleteBackup(data) {
this.$emit("loading");
if (await api.backups.delete(data.name)) {
this.selectedBackup = null;
}
this.backupLoading = false;
this.$emit("finished");
},
},
};
</script>
<style></style>

View file

@ -1,113 +0,0 @@
<template>
<v-card :loading="loading">
<v-card-title> {{ $t("settings.backup.create-heading") }} </v-card-title>
<v-card-text class="mt-n3">
<v-text-field dense :label="$t('settings.backup.backup-tag')" v-model="tag"></v-text-field>
</v-card-text>
<v-card-actions class="mt-n9 flex-wrap">
<v-switch v-model="fullBackup" :label="switchLabel"></v-switch>
<v-spacer></v-spacer>
<v-btn color="success" text @click="createBackup()">
{{ $t("general.create") }}
</v-btn>
</v-card-actions>
<v-expand-transition>
<div v-if="!fullBackup">
<v-card-text class="mt-n4">
<v-row>
<v-col sm="4">
<p>{{ $t("general.options") }}</p>
<ImportOptions @update-options="updateOptions" class="mt-5" />
</v-col>
<v-col>
<p>{{ $t("general.templates") }}</p>
<v-checkbox
v-for="template in availableTemplates"
:key="template"
class="mb-n4 mt-n3"
dense
:label="template"
@click="appendTemplate(template)"
></v-checkbox>
</v-col>
</v-row>
</v-card-text>
</div>
</v-expand-transition>
</v-card>
</template>
<script>
import ImportOptions from "./ImportOptions";
import { api } from "@/api";
export default {
components: { ImportOptions },
data() {
return {
tag: null,
fullBackup: true,
loading: false,
options: {
recipes: true,
settings: true,
themes: true,
users: true,
groups: true,
},
availableTemplates: [],
selectedTemplates: [],
};
},
mounted() {
this.getAvailableBackups();
},
computed: {
switchLabel() {
if (this.fullBackup) {
return this.$t("settings.backup.full-backup");
} else return this.$t("settings.backup.partial-backup");
},
},
methods: {
updateOptions(options) {
this.options = options;
},
async getAvailableBackups() {
let response = await api.backups.requestAvailable();
response.templates.forEach(element => {
this.availableTemplates.push(element);
});
},
async createBackup() {
this.loading = true;
let data = {
tag: this.tag,
options: {
recipes: this.options.recipes,
settings: this.options.settings,
themes: this.options.themes,
users: this.options.users,
groups: this.options.groups,
},
templates: this.selectedTemplates,
};
if (await api.backups.create(data)) {
this.$emit("created");
}
this.loading = false;
},
appendTemplate(templateName) {
if (this.selectedTemplates.includes(templateName)) {
let index = this.selectedTemplates.indexOf(templateName);
if (index !== -1) {
this.selectedTemplates.splice(index, 1);
}
} else this.selectedTemplates.push(templateName);
},
},
};
</script>
<style></style>

View file

@ -1,75 +0,0 @@
<template>
<v-card :loading="backupLoading" class="mt-3">
<v-card-title class="headline">
{{ $t("settings.backup-and-exports") }}
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-row>
<v-col cols="12" md="6" sm="12">
<NewBackupCard @created="processFinished" />
</v-col>
<v-col cols="12" md="6" sm="12">
<p>
{{ $t("settings.backup-info") }}
</p>
</v-col>
</v-row>
<v-divider class="my-3"></v-divider>
<v-card-title class="mt-n6">
{{ $t("settings.available-backups") }}
<span>
<TheUploadBtn class="mt-1" url="/api/backups/upload" @uploaded="getAvailableBackups" />
</span>
<v-spacer></v-spacer>
</v-card-title>
<AvailableBackupCard @loading="backupLoading = true" @finished="processFinished" :backups="availableBackups" />
<ImportSummaryDialog ref="report" :import-data="importData" />
</v-card-text>
</v-card>
</template>
<script>
import { api } from "@/api";
import TheUploadBtn from "@/components/UI/Buttons/TheUploadBtn";
import ImportSummaryDialog from "@/components/ImportSummaryDialog";
import AvailableBackupCard from "@/pages/Admin/Backup/AvailableBackupCard";
import NewBackupCard from "@/pages/Admin/Backup/NewBackupCard";
export default {
components: {
TheUploadBtn,
AvailableBackupCard,
NewBackupCard,
ImportSummaryDialog,
},
data() {
return {
failedImports: [],
successfulImports: [],
backupLoading: false,
availableBackups: [],
importData: [],
};
},
mounted() {
this.getAvailableBackups();
},
methods: {
async getAvailableBackups() {
let response = await api.backups.requestAvailable();
this.availableBackups = response.imports;
this.availableTemplates = response.templates;
},
processFinished(data) {
this.getAvailableBackups();
this.backupLoading = false;
this.$refs.report.open(data);
},
},
};
</script>
<style></style>

View file

@ -26,6 +26,8 @@
</v-btn> </v-btn>
</template> </template>
</TheUploadBtn> </TheUploadBtn>
<BackupDialog :color="color" />
<v-btn :loading="loading" class="mx-2" small :color="color" @click="createBackup"> <v-btn :loading="loading" class="mx-2" small :color="color" @click="createBackup">
<v-icon left> mdi-plus </v-icon> Create <v-icon left> mdi-plus </v-icon> Create
</v-btn> </v-btn>
@ -36,7 +38,7 @@
<v-list-item @click.prevent="openDialog(item)"> <v-list-item @click.prevent="openDialog(item)">
<v-list-item-avatar> <v-list-item-avatar>
<v-icon large dark :color="color"> <v-icon large dark :color="color">
mdi-backup-restore mdi-database
</v-icon> </v-icon>
</v-list-item-avatar> </v-list-item-avatar>
@ -65,13 +67,14 @@
import TheUploadBtn from "@/components/UI/Buttons/TheUploadBtn"; import TheUploadBtn from "@/components/UI/Buttons/TheUploadBtn";
import ImportSummaryDialog from "@/components/ImportSummaryDialog"; import ImportSummaryDialog from "@/components/ImportSummaryDialog";
import { api } from "@/api"; import { api } from "@/api";
import StatCard from "./StatCard"; import StatCard from "@/components/UI/StatCard";
import ImportDialog from "../Backup/ImportDialog"; import BackupDialog from "@/components/UI/Dialogs/BackupDialog";
import ImportDialog from "@/components/UI/Dialogs/ImportDialog";
export default { export default {
components: { StatCard, ImportDialog, TheUploadBtn, ImportSummaryDialog }, components: { StatCard, ImportDialog, TheUploadBtn, ImportSummaryDialog, BackupDialog },
data() { data() {
return { return {
color: "secondary", color: "accent",
selectedName: "", selectedName: "",
selectedDate: "", selectedDate: "",
loading: false, loading: false,
@ -91,7 +94,6 @@ export default {
async getAvailableBackups() { async getAvailableBackups() {
const response = await api.backups.requestAvailable(); const response = await api.backups.requestAvailable();
this.availableBackups = response.imports; this.availableBackups = response.imports;
console.log(this.availableBackups);
}, },
async deleteBackup(name) { async deleteBackup(name) {
@ -106,6 +108,7 @@ export default {
this.selectedName = backup.name; this.selectedName = backup.name;
this.$refs.import_dialog.open(); this.$refs.import_dialog.open();
}, },
async importBackup(data) { async importBackup(data) {
this.loading = true; this.loading = true;
const response = await api.backups.import(data.name, data); const response = await api.backups.import(data.name, data);

View file

@ -49,12 +49,12 @@
<script> <script>
import { api } from "@/api"; import { api } from "@/api";
import StatCard from "./StatCard"; import StatCard from "@/components/UI/StatCard";
export default { export default {
components: { StatCard }, components: { StatCard },
data() { data() {
return { return {
color: "secondary", color: "accent",
total: 0, total: 0,
events: [], events: [],
icons: { icons: {

View file

@ -82,7 +82,7 @@
<script> <script>
import { api } from "@/api"; import { api } from "@/api";
import StatCard from "./StatCard"; import StatCard from "@/components/UI/StatCard";
import EventViewer from "./EventViewer"; import EventViewer from "./EventViewer";
import BackupViewer from "./BackupViewer"; import BackupViewer from "./BackupViewer";
export default { export default {

View file

@ -0,0 +1,215 @@
<template>
<div>
<StatCard icon="mdi-format-color-fill" :color="color">
<template v-slot:after-heading>
<div class="ml-auto text-right">
<div class="body-3 grey--text font-weight-light" v-text="$t('general.themes')" />
<h3 class="display-2 font-weight-light text--primary">
<small> {{ selectedTheme.name }} </small>
</h3>
</div>
</template>
<template v-slot:actions>
<v-btn-toggle v-model="darkMode" color="primary " mandatory>
<v-btn small value="system">
<v-icon>mdi-desktop-tower-monitor</v-icon>
<span class="ml-1" v-show="$vuetify.breakpoint.smAndUp">
{{ $t("settings.theme.default-to-system") }}
</span>
</v-btn>
<v-btn small value="light">
<v-icon>mdi-white-balance-sunny</v-icon>
<span class="ml-1" v-show="$vuetify.breakpoint.smAndUp">
{{ $t("settings.theme.light") }}
</span>
</v-btn>
<v-btn small value="dark">
<v-icon>mdi-weather-night</v-icon>
<span class="ml-1" v-show="$vuetify.breakpoint.smAndUp">
{{ $t("settings.theme.dark") }}
</span>
</v-btn>
</v-btn-toggle>
</template>
<template v-slot:bottom>
<v-virtual-scroll height="290" item-height="70" :items="availableThemes" class="mt-2">
<template v-slot:default="{ item }">
<v-list-item @click="selectedTheme = item">
<v-list-item-avatar>
<v-icon large dark :color="item.colors.primary">
mdi-format-color-fill
</v-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title v-text="item.name"></v-list-item-title>
<v-row flex align-center class="mt-2 justify-space-around px-4 pb-2">
<v-sheet
class="rounded flex mx-1"
v-for="(item, index) in item.colors"
:key="index"
:color="item"
height="20"
>
</v-sheet>
</v-row>
</v-list-item-content>
<v-list-item-action class="ml-auto">
<v-btn large icon @click.stop="editTheme(item)">
<v-icon color="accent">mdi-square-edit-outline</v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>
</template>
</v-virtual-scroll>
<v-divider></v-divider>
<v-card-actions>
<v-spacer class="mx-2"></v-spacer>
<v-btn class="my-1 mb-n1" :color="color" @click="createTheme">
<v-icon left> mdi-plus </v-icon> {{ $t("general.create") }}
</v-btn>
</v-card-actions>
</template>
</StatCard>
<BaseDialog
:loading="loading"
:title="modalLabel.title"
title-icon="mdi-format-color-fill"
modal-width="700"
ref="themeDialog"
:submit-text="modalLabel.button"
@submit="processSubmit"
@delete="deleteTheme"
>
<v-card-text class="mt-3">
<v-text-field
:label="$t('settings.theme.theme-name')"
v-model="defaultData.name"
:rules="[rules.required]"
></v-text-field>
<v-row dense dflex wrap justify-content-center v-if="defaultData.colors">
<v-col cols="12" sm="6" v-for="(_, key) in defaultData.colors" :key="key">
<ColorPickerDialog :button-text="labels[key]" v-model="defaultData.colors[key]" />
</v-col>
</v-row>
</v-card-text>
</BaseDialog>
</div>
</template>
<script>
import { api } from "@/api";
import ColorPickerDialog from "@/components/FormHelpers/ColorPickerDialog";
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
import StatCard from "@/components/UI/StatCard";
export default {
components: { StatCard, BaseDialog, ColorPickerDialog },
data() {
return {
availableThemes: [],
color: "accent",
newTheme: false,
loading: false,
defaultData: {
name: "",
colors: {
primary: "#E58325",
accent: "#00457A",
secondary: "#973542",
success: "#43A047",
info: "#4990BA",
warning: "#FF4081",
error: "#EF5350",
},
},
rules: {
required: val => !!val || this.$t("settings.theme.theme-name-is-required"),
},
};
},
computed: {
labels() {
return {
primary: this.$t("settings.theme.primary"),
secondary: this.$t("settings.theme.secondary"),
accent: this.$t("settings.theme.accent"),
success: this.$t("settings.theme.success"),
info: this.$t("settings.theme.info"),
warning: this.$t("settings.theme.warning"),
error: this.$t("settings.theme.error"),
};
},
modalLabel() {
if (this.newTheme) {
return {
title: this.$t("settings.add-a-new-theme"),
button: this.$t("general.create"),
};
} else {
return {
title: "Update Theme",
button: this.$t("general.update"),
};
}
},
selectedTheme: {
set(val) {
this.$store.commit("setTheme", val);
},
get() {
return this.$store.getters.getActiveTheme;
},
},
darkMode: {
set(val) {
this.$store.commit("setDarkMode", val);
},
get() {
return this.$store.getters.getDarkMode;
},
},
},
async mounted() {
await this.getAllThemes();
},
methods: {
async getAllThemes() {
this.availableThemes = await api.themes.requestAll();
},
editTheme(theme) {
console.log(theme);
this.defaultData = theme;
this.newTheme = false;
this.$refs.themeDialog.open();
},
createTheme() {
this.newTheme = true;
this.$refs.themeDialog.open();
console.log("Create Theme");
},
async processSubmit() {
if (this.newTheme) {
console.log("New Theme");
await api.themes.create(this.defaultData);
} else {
await api.themes.update(this.defaultData);
}
this.getAllThemes();
},
async deleteTheme() {
console.log(this.defaultData);
await api.themes.delete(this.defaultData.id);
this.getAllThemes();
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,190 @@
<template>
<StatCard icon="mdi-account">
<template v-slot:avatar>
<v-avatar color="accent" size="120" class="white--text headline mt-n16">
<img :src="userProfileImage" v-if="!hideImage" @error="hideImage = true" />
<div v-else>
{{ initials }}
</div>
</v-avatar>
</template>
<template v-slot:after-heading>
<div class="ml-auto text-right">
<div class="body-3 grey--text font-weight-light" v-text="$t('user.user-id-with-value', { id: user.id })" />
<h3 class="display-2 font-weight-light text--primary">
<small> {{ $t("group.group") }}: {{ user.group }} </small>
</h3>
</div>
</template>
<template v-slot:actions>
<BaseDialog
:title="$t('user.reset-password')"
title-icon="mdi-lock"
:submit-text="$t('settings.change-password')"
@submit="changePassword"
:loading="loading"
:top="true"
>
<template v-slot:open="{ open }">
<v-btn color="primary" class="mr-1" small @click="open">
<v-icon left>mdi-lock</v-icon>
Change Password
</v-btn>
</template>
<v-card-text>
<v-form ref="passChange">
<v-text-field
v-model="password.current"
prepend-icon="mdi-lock"
:label="$t('user.current-password')"
:rules="[existsRule]"
validate-on-blur
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword.current = !showPassword.current"
></v-text-field>
<v-text-field
v-model="password.newOne"
prepend-icon="mdi-lock"
:label="$t('user.new-password')"
:rules="[minRule]"
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword.newOne = !showPassword.newOne"
></v-text-field>
<v-text-field
v-model="password.newTwo"
prepend-icon="mdi-lock"
:label="$t('user.confirm-password')"
:rules="[password.newOne === password.newTwo || $t('user.password-must-match')]"
validate-on-blur
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword.newTwo = !showPassword.newTwo"
></v-text-field>
</v-form>
</v-card-text>
</BaseDialog>
</template>
<template v-slot:bottom>
<v-card-text>
<v-form>
<v-text-field
:label="$t('user.full-name')"
required
v-model="user.fullName"
:rules="[existsRule]"
validate-on-blur
>
</v-text-field>
<v-text-field :label="$t('user.email')" :rules="[emailRule]" validate-on-blur required v-model="user.email">
</v-text-field>
</v-form>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pb-1 pt-3">
<TheUploadBtn
icon="mdi-image-area"
:text="$t('user.upload-photo')"
:url="userProfileImage"
file-name="profile_image"
/>
<v-spacer></v-spacer>
<v-btn color="success" @click="updateUser">
<v-icon left> mdi-content-save </v-icon>
{{ $t("general.update") }}
</v-btn>
</v-card-actions>
</template>
</StatCard>
</template>
<script>
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
import StatCard from "@/components/UI/StatCard";
import TheUploadBtn from "@/components/UI/Buttons/TheUploadBtn";
import { api } from "@/api";
import { validators } from "@/mixins/validators";
import { initials } from "@/mixins/initials";
export default {
components: {
BaseDialog,
TheUploadBtn,
StatCard,
},
mixins: [validators, initials],
data() {
return {
hideImage: false,
passwordLoading: false,
password: {
current: "",
newOne: "",
newTwo: "",
},
showPassword: false,
loading: false,
user: {
fullName: "",
email: "",
group: "",
admin: false,
id: 0,
},
};
},
computed: {
userProfileImage() {
this.resetImage();
return `api/users/${this.user.id}/image`;
},
},
async mounted() {
this.refreshProfile();
},
methods: {
resetImage() {
this.hideImage = false;
},
async refreshProfile() {
this.user = await api.users.self();
},
openAvatarPicker() {
this.showAvatarPicker = true;
},
selectAvatar(avatar) {
this.user.avatar = avatar;
},
async updateUser() {
this.loading = true;
const response = await api.users.update(this.user);
if (response) {
this.$store.commit("setToken", response.data.access_token);
this.refreshProfile();
this.loading = false;
this.$store.dispatch("requestUserData");
}
},
async changePassword() {
this.paswordLoading = true;
let data = {
currentPassword: this.password.current,
newPassword: this.password.newOne,
};
if (this.$refs.passChange.validate()) {
if (await api.users.changePassword(this.user.id, data)) {
this.$emit("refresh");
}
}
this.paswordLoading = false;
},
},
};
</script>
<style></style>

View file

@ -1,206 +1,27 @@
<template> <template>
<v-row dense> <div class="mt-10">
<v-col cols="12" md="8" sm="12"> <v-row>
<v-card> <v-col cols="12" sm="12" lg="6">
<v-card-title class="headline"> <UserCard />
<span> </v-col>
<v-progress-circular v-if="loading" indeterminate color="primary" large class="mr-2"> </v-progress-circular> <v-col cols="12" sm="12" lg="6"> </v-col>
</span> </v-row>
{{ $t("settings.profile") }} <v-row class="mt-7">
<v-spacer></v-spacer> <v-col cols="12" sm="12" lg="6">
{{ $t("user.user-id-with-value", { id: user.id }) }} <ThemeCard />
</v-card-title> </v-col>
<v-divider></v-divider> <v-col cols="12" sm="12" lg="6"> </v-col>
<v-card-text> </v-row>
<v-row> </div>
<v-col cols="12" md="3" align="center" justify="center">
<v-avatar color="accent" size="120" class="white--text headline mr-2">
<img :src="userProfileImage" v-if="!hideImage" @error="hideImage = true" />
<div v-else>
{{ initials }}
</div>
</v-avatar>
</v-col>
<v-col cols="12" md="9">
<v-form>
<v-text-field
:label="$t('user.full-name')"
required
v-model="user.fullName"
:rules="[existsRule]"
validate-on-blur
>
</v-text-field>
<v-text-field
:label="$t('user.email')"
:rules="[emailRule]"
validate-on-blur
required
v-model="user.email"
>
</v-text-field>
<v-text-field
:label="$t('group.group')"
readonly
v-model="user.group"
persistent-hint
:hint="$t('group.groups-can-only-be-set-by-administrators')"
>
</v-text-field>
</v-form>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<TheUploadBtn
icon="mdi-image-area"
:text="$t('user.upload-photo')"
:url="userProfileImage"
file-name="profile_image"
/>
<v-spacer></v-spacer>
<v-btn color="success" class="mr-2" @click="updateUser">
<v-icon left> mdi-content-save </v-icon>
{{ $t("general.save") }}
</v-btn>
</v-card-actions>
</v-card>
</v-col>
<v-col cols="12" md="4" sm="12">
<v-card height="100%">
<v-card-title class="headline">
{{ $t("user.reset-password") }}
<v-spacer></v-spacer>
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-form ref="passChange">
<v-text-field
v-model="password.current"
prepend-icon="mdi-lock"
:label="$t('user.current-password')"
:rules="[existsRule]"
validate-on-blur
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword.current = !showPassword.current"
></v-text-field>
<v-text-field
v-model="password.newOne"
prepend-icon="mdi-lock"
:label="$t('user.new-password')"
:rules="[minRule]"
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword.newOne = !showPassword.newOne"
></v-text-field>
<v-text-field
v-model="password.newTwo"
prepend-icon="mdi-lock"
:label="$t('user.confirm-password')"
:rules="[password.newOne === password.newTwo || $t('user.password-must-match')]"
validate-on-blur
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword.newTwo = !showPassword.newTwo"
></v-text-field>
</v-form>
</v-card-text>
<v-card-actions>
<v-btn icon @click="showPassword = !showPassword" :loading="passwordLoading">
<v-icon v-if="!showPassword">mdi-eye-off</v-icon>
<v-icon v-else> mdi-eye </v-icon>
</v-btn>
<v-spacer></v-spacer>
<v-btn color="accent" class="mr-2" @click="changePassword">
<v-icon left> mdi-lock </v-icon>
{{ $t("settings.change-password") }}
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</template> </template>
<script> <script>
// import AvatarPicker from '@/components/AvatarPicker' import ThemeCard from "./ThemeCard";
import TheUploadBtn from "@/components/UI/Buttons/TheUploadBtn"; import UserCard from "./UserCard";
import { api } from "@/api";
import { validators } from "@/mixins/validators";
import { initials } from "@/mixins/initials";
export default { export default {
components: { components: {
TheUploadBtn, UserCard,
}, ThemeCard,
mixins: [validators, initials],
data() {
return {
hideImage: false,
passwordLoading: false,
password: {
current: "",
newOne: "",
newTwo: "",
},
showPassword: false,
loading: false,
user: {
fullName: "",
email: "",
group: "",
admin: false,
id: 0,
},
};
},
computed: {
userProfileImage() {
this.resetImage();
return `api/users/${this.user.id}/image`;
},
},
async mounted() {
this.refreshProfile();
},
methods: {
resetImage() {
this.hideImage = false;
},
async refreshProfile() {
this.user = await api.users.self();
},
openAvatarPicker() {
this.showAvatarPicker = true;
},
selectAvatar(avatar) {
this.user.avatar = avatar;
},
async updateUser() {
this.loading = true;
const response = await api.users.update(this.user);
if (response) {
this.$store.commit("setToken", response.data.access_token);
this.refreshProfile();
this.loading = false;
this.$store.dispatch("requestUserData");
}
},
async changePassword() {
this.paswordLoading = true;
let data = {
currentPassword: this.password.current,
newPassword: this.password.newOne,
};
if (this.$refs.passChange.validate()) {
if (await api.users.changePassword(this.user.id, data)) {
this.$emit("refresh");
}
}
this.paswordLoading = false;
},
}, },
}; };
</script> </script>

View file

@ -1,89 +0,0 @@
<template>
<div>
<v-btn text color="info" @click="dialog = true">
{{ $t("settings.add-a-new-theme") }}
</v-btn>
<v-dialog v-model="dialog" width="500">
<v-card>
<v-app-bar dense dark color="primary mb-2">
<v-icon large left class="mt-1">
mdi-format-color-fill
</v-icon>
<v-toolbar-title class="headline">
{{ $t("settings.add-a-new-theme") }}
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-title> </v-card-title>
<v-form @submit.prevent="select">
<v-card-text>
<v-text-field
:label="$t('settings.theme.theme-name')"
v-model="themeName"
:rules="[rules.required]"
></v-text-field>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" text @click="dialog = false">
{{ $t("general.cancel") }}
</v-btn>
<v-btn color="success" text type="submit" :disabled="!themeName">
{{ $t("general.create") }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
</div>
</template>
<script>
export default {
props: {
buttonText: String,
value: String,
},
data() {
return {
dialog: false,
themeName: "",
rules: {
required: val => !!val || this.$t("settings.theme.theme-name-is-required"),
},
};
},
watch: {
color() {
this.updateColor();
},
},
methods: {
randomColor() {
return "#" + Math.floor(Math.random() * 16777215).toString(16);
},
select() {
const newTheme = {
name: this.themeName,
colors: {
primary: "#E58325",
accent: "#00457A",
secondary: "#973542",
success: "#5AB1BB",
info: "#4990BA",
warning: "#FF4081",
error: "#EF5350",
},
};
this.$emit("new-theme", newTheme);
this.dialog = false;
},
},
};
</script>
<style></style>

View file

@ -1,88 +0,0 @@
<template>
<div>
<ConfirmationDialog
:title="$t('settings.theme.delete-theme')"
:message="$t('settings.theme.are-you-sure-you-want-to-delete-this-theme')"
color="error"
icon="mdi-alert-circle"
ref="deleteThemeConfirm"
v-on:confirm="deleteSelectedTheme()"
/>
<v-card flat outlined class="ma-2">
<v-card-text class="mb-n5 mt-n2">
<h3>
{{ theme.name }}
{{ current ? $t("general.current-parenthesis") : "" }}
</h3>
</v-card-text>
<v-card-text>
<v-row flex align-center>
<v-card
v-for="(color, index) in theme.colors"
:key="index"
class="ma-1 mx-auto"
height="34"
width="36"
:color="color"
>
</v-card>
</v-row>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<v-btn text color="error" @click="confirmDelete">
{{ $t("general.delete") }}
</v-btn>
<v-spacer></v-spacer>
<!-- <v-btn text color="accent" @click="editTheme">Edit</v-btn> -->
<v-btn text color="success" @click="saveThemes">{{ $t("general.apply") }}</v-btn>
</v-card-actions>
</v-card>
</div>
</template>
<script>
import ConfirmationDialog from "@/components/UI/Dialogs/ConfirmationDialog";
import { api } from "@/api";
const DELETE_EVENT = "delete";
const APPLY_EVENT = "apply";
const EDIT_EVENT = "edit";
export default {
components: {
ConfirmationDialog,
},
props: {
theme: Object,
current: {
default: false,
},
},
methods: {
confirmDelete() {
if (this.theme.name === "default") {
// Notify User Can't Delete Default
} else if (this.theme !== {}) {
this.$refs.deleteThemeConfirm.open();
}
},
async deleteSelectedTheme() {
//Delete Theme from DB
if (await api.themes.delete(this.theme.name)) {
//Get the new list of available from DB
this.availableThemes = await api.themes.requestAll();
this.$emit(DELETE_EVENT);
}
},
async saveThemes() {
this.$store.commit("setTheme", this.theme);
this.$emit(APPLY_EVENT, this.theme);
},
editTheme() {
this.$emit(EDIT_EVENT);
},
},
};
</script>
<style></style>

View file

@ -1,155 +0,0 @@
<template>
<v-card>
<v-card-title class="headline">
{{ $t("settings.theme.theme-settings") }}
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<h2 class="mt-4 mb-1">{{ $t("settings.theme.dark-mode") }}</h2>
<p>
{{
$t(
"settings.theme.choose-how-mealie-looks-to-you-set-your-theme-preference-to-follow-your-system-settings-or-choose-to-use-the-light-or-dark-theme"
)
}}
</p>
<v-row dense align="center">
<v-col cols="6">
<v-btn-toggle v-model="selectedDarkMode" color="primary " mandatory @change="setStoresDarkMode">
<v-btn value="system">
<v-icon>mdi-desktop-tower-monitor</v-icon>
<span class="ml-1" v-show="$vuetify.breakpoint.smAndUp">
{{ $t("settings.theme.default-to-system") }}
</span>
</v-btn>
<v-btn value="light">
<v-icon>mdi-white-balance-sunny</v-icon>
<span class="ml-1" v-show="$vuetify.breakpoint.smAndUp">
{{ $t("settings.theme.light") }}
</span>
</v-btn>
<v-btn value="dark">
<v-icon>mdi-weather-night</v-icon>
<span class="ml-1" v-show="$vuetify.breakpoint.smAndUp">
{{ $t("settings.theme.dark") }}
</span>
</v-btn>
</v-btn-toggle>
</v-col>
</v-row></v-card-text
>
<v-divider></v-divider>
<v-card-text>
<h2 class="mt-1 mb-1">{{ $t("settings.theme.theme") }}</h2>
<p>
{{
$t(
"settings.theme.select-a-theme-from-the-dropdown-or-create-a-new-theme-note-that-the-default-theme-will-be-served-to-all-users-who-have-not-set-a-theme-preference"
)
}}
</p>
<v-row dense align-content="center" v-if="selectedTheme.colors">
<v-col>
<ColorPickerDialog :button-text="$t('settings.theme.primary')" v-model="selectedTheme.colors.primary" />
</v-col>
<v-col>
<ColorPickerDialog :button-text="$t('settings.theme.secondary')" v-model="selectedTheme.colors.secondary" />
</v-col>
<v-col>
<ColorPickerDialog :button-text="$t('settings.theme.accent')" v-model="selectedTheme.colors.accent" />
</v-col>
<v-col>
<ColorPickerDialog :button-text="$t('settings.theme.success')" v-model="selectedTheme.colors.success" />
</v-col>
<v-col>
<ColorPickerDialog :button-text="$t('settings.theme.info')" v-model="selectedTheme.colors.info" />
</v-col>
<v-col>
<ColorPickerDialog :button-text="$t('settings.theme.warning')" v-model="selectedTheme.colors.warning" />
</v-col>
<v-col>
<ColorPickerDialog :button-text="$t('settings.theme.error')" v-model="selectedTheme.colors.error" />
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row>
<v-col cols="12" sm="12" md="6" lg="4" xl="3" v-for="theme in availableThemes" :key="theme.name">
<ThemeCard :theme="theme" :current="selectedTheme.name == theme.name ? true : false" @delete="getAllThemes" />
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<NewThemeDialog @new-theme="appendTheme" class="mt-1" />
<v-spacer></v-spacer>
<v-btn color="success" @click="saveThemes" class="mr-2">
<v-icon left> mdi-content-save </v-icon>
{{ $t("general.save") }}
</v-btn>
</v-card-actions>
</v-card>
</template>
<script>
import { api } from "@/api";
import ColorPickerDialog from "@/components/FormHelpers/ColorPickerDialog";
import NewThemeDialog from "./NewThemeDialog";
import ThemeCard from "./ThemeCard";
export default {
components: {
ColorPickerDialog,
NewThemeDialog,
ThemeCard,
},
data() {
return {
selectedDarkMode: "system",
availableThemes: [],
};
},
async mounted() {
await this.getAllThemes();
this.selectedDarkMode = this.$store.getters.getDarkMode;
},
computed: {
selectedTheme() {
return this.$store.getters.getActiveTheme;
},
},
methods: {
async getAllThemes() {
this.availableThemes = await api.themes.requestAll();
},
/**
* Create the new Theme and select it.
*/
async appendTheme(NewThemeDialog) {
const response = await api.themes.create(NewThemeDialog);
if (response) {
this.availableThemes.push(NewThemeDialog);
this.$store.commit("setTheme", NewThemeDialog);
}
},
setStoresDarkMode() {
this.$store.commit("setDarkMode", this.selectedDarkMode);
},
/**
* This will save the current colors and make the selected theme live.
*/
saveThemes() {
api.themes.update(this.selectedTheme.name, this.selectedTheme.colors);
},
},
};
</script>
<style></style>

View file

@ -1,6 +1,7 @@
<template> <template>
<v-container> <v-container>
<CardSection <CardSection
title-icon=""
v-if="siteSettings.showRecent" v-if="siteSettings.showRecent"
:title="$t('page.recent')" :title="$t('page.recent')"
:recipes="recentRecipes" :recipes="recentRecipes"

View file

@ -2,6 +2,7 @@
<v-container> <v-container>
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear> <v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear>
<CardSection <CardSection
title-icon=""
:sortable="true" :sortable="true"
:title="$t('page.all-recipes')" :title="$t('page.all-recipes')"
:recipes="allRecipes" :recipes="allRecipes"

View file

@ -1,6 +1,4 @@
import Admin from "@/pages/Admin"; import Admin from "@/pages/Admin";
import Backup from "@/pages/Admin/Backup";
import Theme from "@/pages/Admin/Theme";
import MealPlanner from "@/pages/Admin/MealPlanner"; import MealPlanner from "@/pages/Admin/MealPlanner";
import Migration from "@/pages/Admin/Migration"; import Migration from "@/pages/Admin/Migration";
import Profile from "@/pages/Admin/Profile"; import Profile from "@/pages/Admin/Profile";
@ -31,21 +29,6 @@ export const adminRoutes = {
title: "settings.profile", title: "settings.profile",
}, },
}, },
{
path: "backups",
component: Backup,
meta: {
title: "settings.backup-and-exports",
},
},
{
path: "themes",
component: Theme,
meta: {
title: "general.themes",
},
},
{ {
path: "meal-planner", path: "meal-planner",
component: MealPlanner, component: MealPlanner,

View file

@ -22,6 +22,7 @@ export const routes = [
]; ];
const router = new VueRouter({ const router = new VueRouter({
base: process.env.BASE_URL,
routes, routes,
mode: process.env.NODE_ENV === "production" ? "history" : "hash", mode: process.env.NODE_ENV === "production" ? "history" : "hash",
scrollBehavior() { scrollBehavior() {

View file

@ -60,7 +60,7 @@ const actions = {
}, },
async resetTheme({ commit }) { async resetTheme({ commit }) {
const defaultTheme = await api.themes.requestByName("default"); const defaultTheme = await api.themes.requestByName(1);
if (defaultTheme.colors) { if (defaultTheme.colors) {
Vuetify.framework.theme.themes.dark = defaultTheme.colors; Vuetify.framework.theme.themes.dark = defaultTheme.colors;
Vuetify.framework.theme.themes.light = defaultTheme.colors; Vuetify.framework.theme.themes.light = defaultTheme.colors;

View file

@ -1,18 +1,19 @@
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from mealie.core import root_logger
from mealie.core.config import APP_VERSION, settings from mealie.core.config import APP_VERSION, settings
from mealie.core.root_logger import get_logger
from mealie.routes import backup_routes, debug_routes, migration_routes, theme_routes, utility_routes from mealie.routes import backup_routes, debug_routes, migration_routes, theme_routes, utility_routes
from mealie.routes.about import about_router from mealie.routes.about import about_router
from mealie.routes.groups import groups from mealie.routes.groups import groups_router
from mealie.routes.mealplans import mealplans from mealie.routes.mealplans import meal_plan_router
from mealie.routes.recipe import router as recipe_router from mealie.routes.media import media_router
from mealie.routes.site_settings import all_settings from mealie.routes.recipe import recipe_router
from mealie.routes.users import users from mealie.routes.site_settings import settings_router
from mealie.routes.users import user_router
from mealie.services.events import create_general_event from mealie.services.events import create_general_event
logger = root_logger.get_logger() logger = get_logger()
app = FastAPI( app = FastAPI(
title="Mealie", title="Mealie",
@ -29,15 +30,16 @@ def start_scheduler():
def api_routers(): def api_routers():
# Authentication # Authentication
app.include_router(users.router) app.include_router(user_router)
app.include_router(groups.router) app.include_router(groups_router)
# Recipes # Recipes
app.include_router(recipe_router) app.include_router(recipe_router)
app.include_router(media_router)
app.include_router(about_router) app.include_router(about_router)
# Meal Routes # Meal Routes
app.include_router(mealplans.router) app.include_router(meal_plan_router)
# Settings Routes # Settings Routes
app.include_router(all_settings.router) app.include_router(settings_router)
app.include_router(theme_routes.router) app.include_router(theme_routes.router)
# Backups/Imports Routes # Backups/Imports Routes
app.include_router(backup_routes.router) app.include_router(backup_routes.router)

View file

@ -93,7 +93,7 @@ class _Settings(BaseDocument):
class _Themes(BaseDocument): class _Themes(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
self.primary_key = "name" self.primary_key = "id"
self.sql_model = SiteThemeModel self.sql_model = SiteThemeModel
self.schema = SiteTheme self.schema = SiteTheme

View file

@ -1,23 +1,21 @@
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.orm as orm import sqlalchemy.orm as orm
from mealie.db.models.model_base import SqlAlchemyBase from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase
from sqlalchemy.sql.sqltypes import Integer
class SiteThemeModel(SqlAlchemyBase): class SiteThemeModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "site_theme" __tablename__ = "site_theme"
name = sa.Column(sa.String, primary_key=True) id = sa.Column(Integer, primary_key=True, unique=True)
name = sa.Column(sa.String, nullable=False, unique=True)
colors = orm.relationship("ThemeColorsModel", uselist=False, cascade="all, delete") colors = orm.relationship("ThemeColorsModel", uselist=False, cascade="all, delete")
def __init__(self, name: str, colors: dict, session=None) -> None: def __init__(self, name: str, colors: dict, *arg, **kwargs) -> None:
self.name = name self.name = name
self.colors = ThemeColorsModel(**colors) self.colors = ThemeColorsModel(**colors)
def update(self, session=None, name: str = None, colors: dict = None) -> dict:
self.colors.update(**colors)
return self
class ThemeColorsModel(SqlAlchemyBase, BaseMixins):
class ThemeColorsModel(SqlAlchemyBase):
__tablename__ = "theme_colors" __tablename__ = "theme_colors"
id = sa.Column(sa.Integer, primary_key=True) id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.String, sa.ForeignKey("site_theme.name")) parent_id = sa.Column(sa.String, sa.ForeignKey("site_theme.name"))
@ -28,21 +26,3 @@ class ThemeColorsModel(SqlAlchemyBase):
info = sa.Column(sa.String) info = sa.Column(sa.String)
warning = sa.Column(sa.String) warning = sa.Column(sa.String)
error = sa.Column(sa.String) error = sa.Column(sa.String)
def update(
self,
primary: str = None,
accent: str = None,
secondary: str = None,
success: str = None,
info: str = None,
warning: str = None,
error: str = None,
) -> None:
self.primary = primary
self.accent = accent
self.secondary = secondary
self.success = success
self.info = info
self.warning = warning
self.error = error

View file

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

View file

@ -0,0 +1,8 @@
from fastapi import APIRouter
from . import crud, groups
groups_router = APIRouter()
groups_router.include_router(crud.router)
groups_router.include_router(groups.router)

View file

@ -0,0 +1,9 @@
from fastapi import APIRouter
from . import crud, helpers, mealplans
meal_plan_router = APIRouter()
meal_plan_router.include_router(crud.router)
meal_plan_router.include_router(helpers.router)
meal_plan_router.include_router(mealplans.router)

View file

@ -0,0 +1,7 @@
from fastapi import APIRouter
from . import recipe
media_router = APIRouter(prefix="/api/media", tags=["Site Media"])
media_router.include_router(recipe.router)

View file

@ -0,0 +1,41 @@
from enum import Enum
from fastapi import APIRouter, HTTPException, status
from mealie.schema.recipe import Recipe
from starlette.responses import FileResponse
"""
These routes are for development only! These assets are served by Caddy when not
in development mode. If you make changes, be sure to test the production container.
"""
router = APIRouter(prefix="/recipes")
class ImageType(str, Enum):
original = "original.webp"
small = "min-original.webp"
tiny = "tiny-original.webp"
@router.get("/{recipe_slug}/images/{file_name}")
async def get_recipe_img(recipe_slug: str, file_name: ImageType = ImageType.original):
"""Takes in a recipe slug, returns the static image. This route is proxied in the docker image
and should not hit the API in production"""
recipe_image = Recipe(slug=recipe_slug).image_dir.joinpath(file_name.value)
if recipe_image:
return FileResponse(recipe_image)
else:
raise HTTPException(status.HTTP_404_NOT_FOUND)
@router.get("/{recipe_slug}/assets/{file_name}")
async def get_recipe_asset(recipe_slug: str, file_name: str):
""" Returns a recipe asset """
file = Recipe(slug=recipe_slug).asset_dir.joinpath(file_name)
try:
return FileResponse(file)
except Exception:
raise HTTPException(status.HTTP_404_NOT_FOUND)

View file

View file

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

View file

@ -1,13 +1,17 @@
from shutil import copyfileobj
from fastapi import APIRouter, Depends, File, Form, HTTPException, status from fastapi import APIRouter, Depends, File, Form, HTTPException, status
from fastapi.datastructures import UploadFile
from mealie.core.root_logger import get_logger from mealie.core.root_logger import get_logger
from mealie.db.database import db from mealie.db.database import db
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user
from mealie.schema.recipe import Recipe, RecipeURLIn from mealie.schema.recipe import Recipe, RecipeAsset, RecipeURLIn
from mealie.services.events import create_recipe_event from mealie.services.events import create_recipe_event
from mealie.services.image.image import scrape_image, write_image from mealie.services.image.image import scrape_image, write_image
from mealie.services.recipe.media import check_assets, delete_assets from mealie.services.recipe.media import check_assets, delete_assets
from mealie.services.scraper.scraper import create_from_url from mealie.services.scraper.scraper import create_from_url
from slugify import slugify
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
router = APIRouter(prefix="/api/recipes", tags=["Recipe CRUD"]) router = APIRouter(prefix="/api/recipes", tags=["Recipe CRUD"])
@ -126,3 +130,30 @@ def scrape_image_url(
""" Removes an existing image and replaces it with the incoming file. """ """ Removes an existing image and replaces it with the incoming file. """
scrape_image(url.url, recipe_slug) scrape_image(url.url, recipe_slug)
@router.post("/{recipe_slug}/assets", response_model=RecipeAsset)
def upload_recipe_asset(
recipe_slug: str,
name: str = Form(...),
icon: str = Form(...),
extension: str = Form(...),
file: UploadFile = File(...),
session: Session = Depends(generate_session),
current_user=Depends(get_current_user),
):
""" Upload a file to store as a recipe asset """
file_name = slugify(name) + "." + extension
asset_in = RecipeAsset(name=name, icon=icon, file_name=file_name)
dest = Recipe(slug=recipe_slug).asset_dir.joinpath(file_name)
with dest.open("wb") as buffer:
copyfileobj(file.file, buffer)
if not dest.is_file():
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)
recipe: Recipe = db.recipes.get(session, recipe_slug)
recipe.assets.append(asset_in)
db.recipes.update(session, recipe_slug, recipe.dict())
return asset_in

View file

@ -1,70 +0,0 @@
import shutil
from enum import Enum
from fastapi import APIRouter, Depends, File, Form, HTTPException, status
from fastapi.datastructures import UploadFile
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user
from mealie.schema.recipe import Recipe, RecipeAsset
from slugify import slugify
from sqlalchemy.orm.session import Session
from starlette.responses import FileResponse
router = APIRouter(prefix="/api/recipes/media", tags=["Recipe Media"])
class ImageType(str, Enum):
original = "original.webp"
small = "min-original.webp"
tiny = "tiny-original.webp"
@router.get("/{recipe_slug}/images/{file_name}")
async def get_recipe_img(recipe_slug: str, file_name: ImageType = ImageType.original):
"""Takes in a recipe slug, returns the static image. This route is proxied in the docker image
and should not hit the API in production"""
recipe_image = Recipe(slug=recipe_slug).image_dir.joinpath(file_name.value)
if recipe_image:
return FileResponse(recipe_image)
else:
raise HTTPException(status.HTTP_404_NOT_FOUND)
@router.get("/{recipe_slug}/assets/{file_name}")
async def get_recipe_asset(recipe_slug: str, file_name: str):
""" Returns a recipe asset """
file = Recipe(slug=recipe_slug).asset_dir.joinpath(file_name)
try:
return FileResponse(file)
except Exception:
raise HTTPException(status.HTTP_404_NOT_FOUND)
@router.post("/{recipe_slug}/assets", response_model=RecipeAsset)
def upload_recipe_asset(
recipe_slug: str,
name: str = Form(...),
icon: str = Form(...),
extension: str = Form(...),
file: UploadFile = File(...),
session: Session = Depends(generate_session),
current_user=Depends(get_current_user),
):
""" Upload a file to store as a recipe asset """
file_name = slugify(name) + "." + extension
asset_in = RecipeAsset(name=name, icon=icon, file_name=file_name)
dest = Recipe(slug=recipe_slug).asset_dir.joinpath(file_name)
with dest.open("wb") as buffer:
shutil.copyfileobj(file.file, buffer)
if not dest.is_file():
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)
recipe: Recipe = db.recipes.get(session, recipe_slug)
recipe.assets.append(asset_in)
db.recipes.update(session, recipe_slug, recipe.dict())
return asset_in

View file

@ -0,0 +1,9 @@
from fastapi import APIRouter
from . import all_settings, custom_pages, site_settings
settings_router = APIRouter()
settings_router.include_router(all_settings.router)
settings_router.include_router(custom_pages.router)
settings_router.include_router(site_settings.router)

View file

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, status, HTTPException from fastapi import APIRouter, Depends, HTTPException, status
from mealie.db.database import db from mealie.db.database import db
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user
@ -21,27 +21,27 @@ def create_theme(data: SiteTheme, session: Session = Depends(generate_session),
db.themes.create(session, data.dict()) db.themes.create(session, data.dict())
@router.get("/themes/{theme_name}") @router.get("/themes/{id}")
def get_single_theme(theme_name: str, session: Session = Depends(generate_session)): def get_single_theme(id: int, session: Session = Depends(generate_session)):
""" Returns a named theme """ """ Returns a named theme """
return db.themes.get(session, theme_name) return db.themes.get(session, id)
@router.put("/themes/{theme_name}", status_code=status.HTTP_200_OK) @router.put("/themes/{id}", status_code=status.HTTP_200_OK)
def update_theme( def update_theme(
theme_name: str, id: int,
data: SiteTheme, data: SiteTheme,
session: Session = Depends(generate_session), session: Session = Depends(generate_session),
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
): ):
""" Update a theme database entry """ """ Update a theme database entry """
db.themes.update(session, theme_name, data.dict()) db.themes.update(session, id, data.dict())
@router.delete("/themes/{theme_name}", status_code=status.HTTP_200_OK) @router.delete("/themes/{id}", status_code=status.HTTP_200_OK)
def delete_theme(theme_name: str, session: Session = Depends(generate_session), current_user=Depends(get_current_user)): def delete_theme(id: int, session: Session = Depends(generate_session), current_user=Depends(get_current_user)):
""" Deletes theme from the database """ """ Deletes theme from the database """
try: try:
db.themes.delete(session, theme_name) db.themes.delete(session, id)
except Exception: except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST) raise HTTPException(status.HTTP_400_BAD_REQUEST)

View file

@ -0,0 +1,9 @@
from fastapi import APIRouter
from . import auth, crud, sign_up
user_router = APIRouter()
user_router.include_router(auth.router)
user_router.include_router(sign_up.router)
user_router.include_router(crud.router)

View file

@ -1,9 +0,0 @@
from fastapi import APIRouter
from mealie.routes.users import auth, crud, sign_up
router = APIRouter()
router.include_router(sign_up.router)
router.include_router(auth.router)
router.include_router(sign_up.router)
router.include_router(crud.router)

View file

@ -1,3 +1,5 @@
from typing import Optional
from pydantic import BaseModel from pydantic import BaseModel
@ -5,7 +7,7 @@ class Colors(BaseModel):
primary: str = "#E58325" primary: str = "#E58325"
accent: str = "#00457A" accent: str = "#00457A"
secondary: str = "#973542" secondary: str = "#973542"
success: str = "#4CAF50" success: str = "#43A047"
info: str = "#4990BA" info: str = "#4990BA"
warning: str = "#FF4081" warning: str = "#FF4081"
error: str = "#EF5350" error: str = "#EF5350"
@ -15,6 +17,7 @@ class Colors(BaseModel):
class SiteTheme(BaseModel): class SiteTheme(BaseModel):
id: Optional[int]
name: str = "default" name: str = "default"
colors: Colors = Colors() colors: Colors = Colors()

View file

@ -14,18 +14,19 @@ def default_settings():
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def default_theme(): def default_theme():
return SiteTheme().dict() return SiteTheme(id=1).dict()
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def new_theme(): def new_theme():
return { return {
"id": 2,
"name": "myTestTheme", "name": "myTestTheme",
"colors": { "colors": {
"primary": "#E58325", "primary": "#E58325",
"accent": "#00457A", "accent": "#00457A",
"secondary": "#973542", "secondary": "#973542",
"success": "#5AB1BB", "success": "#43A047",
"info": "#4990BA", "info": "#4990BA",
"warning": "#FF4081", "warning": "#FF4081",
"error": "#EF5350", "error": "#EF5350",
@ -54,7 +55,7 @@ def test_update_settings(api_client: TestClient, api_routes: AppRoutes, default_
def test_default_theme(api_client: TestClient, api_routes: AppRoutes, default_theme): def test_default_theme(api_client: TestClient, api_routes: AppRoutes, default_theme):
response = api_client.get(api_routes.themes_theme_name("default")) response = api_client.get(api_routes.themes_theme_name(1))
assert response.status_code == 200 assert response.status_code == 200
assert json.loads(response.content) == default_theme assert json.loads(response.content) == default_theme
@ -64,7 +65,7 @@ def test_create_theme(api_client: TestClient, api_routes: AppRoutes, new_theme,
response = api_client.post(api_routes.themes_create, json=new_theme, headers=token) response = api_client.post(api_routes.themes_create, json=new_theme, headers=token)
assert response.status_code == 201 assert response.status_code == 201
response = api_client.get(api_routes.themes_theme_name(new_theme.get("name")), headers=token) response = api_client.get(api_routes.themes_theme_name(new_theme.get("id")), headers=token)
assert response.status_code == 200 assert response.status_code == 200
assert json.loads(response.content) == new_theme assert json.loads(response.content) == new_theme
@ -77,7 +78,7 @@ def test_read_all_themes(api_client: TestClient, api_routes: AppRoutes, default_
def test_read_theme(api_client: TestClient, api_routes: AppRoutes, default_theme, new_theme): def test_read_theme(api_client: TestClient, api_routes: AppRoutes, default_theme, new_theme):
for theme in [default_theme, new_theme]: for theme in [default_theme, new_theme]:
response = api_client.get(api_routes.themes_theme_name(theme.get("name"))) response = api_client.get(api_routes.themes_theme_name(theme.get("id")))
assert response.status_code == 200 assert response.status_code == 200
assert json.loads(response.content) == theme assert json.loads(response.content) == theme
@ -94,14 +95,14 @@ def test_update_theme(api_client: TestClient, api_routes: AppRoutes, token, defa
} }
new_theme["colors"] = theme_colors new_theme["colors"] = theme_colors
response = api_client.put(api_routes.themes_theme_name(new_theme.get("name")), json=new_theme, headers=token) response = api_client.put(api_routes.themes_theme_name(new_theme.get("id")), json=new_theme, headers=token)
assert response.status_code == 200 assert response.status_code == 200
response = api_client.get(api_routes.themes_theme_name(new_theme.get("name"))) response = api_client.get(api_routes.themes_theme_name(new_theme.get("id")))
assert json.loads(response.content) == new_theme assert json.loads(response.content) == new_theme
def test_delete_theme(api_client: TestClient, api_routes: AppRoutes, default_theme, new_theme, token): def test_delete_theme(api_client: TestClient, api_routes: AppRoutes, default_theme, new_theme, token):
for theme in [default_theme, new_theme]: for theme in [default_theme, new_theme]:
response = api_client.delete(api_routes.themes_theme_name(theme.get("name")), headers=token) response = api_client.delete(api_routes.themes_theme_name(theme.get("id")), headers=token)
assert response.status_code == 200 assert response.status_code == 200