Feature/authentication (#185)

* basic crud NOT SECURE

* refactor/database init on startup

* added scratch.py

* tests/user CRUD routes

* password hashing

* change app_config location

* bump python version

* formatting

* login ui starter

* change import from url design

* move components

* remove old snackbar

* refactor/Componenet folder structure rework

* refactor/remove old code

* refactor/rename componenets/js files

* remove console.logs

* refactor/ models to schema and sql to models

* new header styling for imports

* token request

* fix url scrapper

* refactor/rename schema files

* split routes file

* redesigned admin page

* enable relative imports for vue components

* refactor/switch to pages view

* add CamelCase package

* majors settings rework

* user management second pass

* super user CRUD

* refactor/consistent models names

* refactor/consistent model names

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-02-23 15:15:55 -09:00 committed by GitHub
commit f5fa4040bf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
122 changed files with 1909 additions and 695 deletions

1
.gitignore vendored
View file

@ -153,3 +153,4 @@ node_modules/
mealie/data/debug/last_recipe.json
*.sqlite
app_data/db/test.db
scratch.py

View file

@ -27,12 +27,12 @@
<v-icon>mdi-magnify</v-icon>
</v-btn>
<Menu />
<SiteMenu />
<LanguageMenu />
</v-app-bar>
<v-main>
<v-container>
<AddRecipeFab />
<SnackBar />
<router-view></router-view>
</v-container>
<FlashMessage :position="'right bottom'"></FlashMessage>
@ -41,19 +41,20 @@
</template>
<script>
import Menu from "./components/UI/Menu";
import SearchBar from "./components/UI/SearchBar";
import AddRecipeFab from "./components/UI/AddRecipeFab";
import SnackBar from "./components/UI/SnackBar";
import SiteMenu from "@/components/UI/SiteMenu";
import SearchBar from "@/components/UI/Search/SearchBar";
import AddRecipeFab from "@/components/UI/AddRecipeFab";
import LanguageMenu from "@/components/UI/LanguageMenu";
import Vuetify from "./plugins/vuetify";
export default {
name: "App",
components: {
Menu,
SiteMenu,
AddRecipeFab,
SnackBar,
SearchBar,
LanguageMenu,
},
watch: {
@ -63,7 +64,7 @@ export default {
},
created() {
window.addEventListener("keyup", e => {
if (e.key == "/" && !document.activeElement.id.startsWith('input') ) {
if (e.key == "/" && !document.activeElement.id.startsWith("input")) {
this.search = !this.search;
}
});

View file

@ -1,23 +0,0 @@
import backup from "./api/backup";
import recipe from "./api/recipe";
import mealplan from "./api/mealplan";
import settings from "./api/settings";
import themes from "./api/themes";
import migration from "./api/migration";
import myUtils from "./api/upload";
import category from "./api/category";
import meta from "./api/meta";
// import api from "@/api";
export default {
recipes: recipe,
backups: backup,
mealPlans: mealplan,
settings: settings,
themes: themes,
migrations: migration,
utils: myUtils,
categories: category,
meta: meta,
};

View file

@ -1,6 +1,11 @@
const baseURL = "/api/";
import axios from "axios";
import utils from "@/utils";
import { store } from "../store/store";
axios.defaults.headers.common[
"Authorization"
] = `Bearer ${store.getters.getToken}`;
function processResponse(response) {
try {

24
frontend/src/api/index.js Normal file
View file

@ -0,0 +1,24 @@
import backup from "./backup";
import recipe from "./recipe";
import mealplan from "./mealplan";
import settings from "./settings";
import themes from "./themes";
import migration from "./migration";
import myUtils from "./upload";
import category from "./category";
import meta from "./meta";
import users from "./users"
export default {
recipes: recipe,
backups: backup,
mealPlans: mealplan,
settings: settings,
themes: themes,
migrations: migration,
utils: myUtils,
categories: category,
meta: meta,
users: users
};

View file

@ -70,7 +70,7 @@ export default {
router.push(`/`);
},
async allByKeys(recipeKeys, num = 100) {
async allByKeys(recipeKeys, num = 999) {
const response = await apiReq.get(recipeURLs.allRecipes, {
params: {
keys: recipeKeys,

View file

@ -33,7 +33,6 @@ export default {
colors: colors,
};
let response = await apiReq.put(settingsURLs.updateTheme(themeName), body);
console.log(response.data);
return response.data;
},

49
frontend/src/api/users.js Normal file
View file

@ -0,0 +1,49 @@
import { baseURL } from "./api-utils";
import { apiReq } from "./api-utils";
const authPrefix = baseURL + "auth";
const userPrefix = baseURL + "users";
const authURLs = {
token: `${authPrefix}/token`,
};
const usersURLs = {
users: `${userPrefix}`,
self: `${userPrefix}/self`,
userID: id => `${userPrefix}/${id}`,
};
export default {
async login(formData) {
let response = await apiReq.post(authURLs.token, formData, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
return response;
},
async allUsers() {
let response = await apiReq.get(usersURLs.users);
return response.data;
},
async create(user) {
let response = await apiReq.post(usersURLs.users, user);
return response.data;
},
async self() {
let response = await apiReq.get(usersURLs.self);
return response.data;
},
async byID(id) {
let response = await apiReq.get(usersURLs.userID(id));
return response.data;
},
async update(user) {
let response = await apiReq.put(usersURLs.userID(user.id), user);
return response.data;
},
async delete(id) {
let response = await apiReq.delete(usersURLs.userID(id));
return response.data;
},
};

View file

@ -0,0 +1,139 @@
<template>
<div>
<v-btn
class="mt-9 ml-n1"
fixed
left
bottom
fab
small
color="primary"
@click="showSidebar = !showSidebar"
>
<v-icon>mdi-cog</v-icon></v-btn
>
<v-navigation-drawer
:value="mobile ? showSidebar : true"
v-model="showSidebar"
width="180px"
clipped
app
>
<template v-slot:prepend>
<v-list-item two-line>
<v-list-item-avatar>
<img src="https://randomuser.me/api/portraits/women/81.jpg" />
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title>Jane Smith</v-list-item-title>
<v-list-item-subtitle>Admin</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</template>
<v-divider></v-divider>
<v-list nav dense>
<v-list-item
v-for="nav in baseLinks"
:key="nav.title"
link
:to="nav.to"
>
<v-list-item-icon>
<v-icon>{{ nav.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</v-list-item>
</v-list>
<v-divider></v-divider>
<v-list nav dense>
<v-list-item
v-for="nav in superLinks"
:key="nav.title"
link
:to="nav.to"
>
<v-list-item-icon>
<v-icon>{{ nav.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-navigation-drawer>
</div>
</template>
<script>
export default {
data() {
return {
showSidebar: false,
mobile: false,
links: [],
superLinks: [
{
icon: "mdi-cog",
to: "/admin/settings",
title: "Site Settings",
},
{
icon: "mdi-account-group",
to: "/admin/manage-users",
title: "Manage Users",
},
{
icon: "mdi-backup-restore",
to: "/admin/backups",
title: "Backups",
},
{
icon: "mdi-database-import",
to: "/admin/migrations",
title: "Migrations",
},
],
baseLinks: [
{
icon: "mdi-account",
to: "/admin/profile",
title: "Profile",
},
{
icon: "mdi-format-color-fill",
to: "/admin/themes",
title: "Themes",
},
{
icon: "mdi-food",
to: "/admin/meal-planner",
title: "Meal Planner",
},
],
};
},
mounted() {
this.mobile = this.viewScale();
this.showSidebar = !this.viewScale();
},
methods: {
viewScale() {
switch (this.$vuetify.breakpoint.name) {
case "xs":
return true;
case "sm":
return true;
default:
return false;
}
},
},
};
</script>
<style>
</style>

View file

@ -2,10 +2,18 @@
<div class="text-center">
<v-dialog v-model="dialog" width="70%">
<v-card>
<v-card-title> Import Summary </v-card-title>
<v-card-text>
<v-row class="mb-n9">
<v-card flat>
<v-app-bar dark color="primary mb-2">
<v-icon large left>
mdi-import
</v-icon>
<v-toolbar-title class="headline">
Import Summary
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-text class="mb-n4">
<v-row>
<div>
<v-card-text>
<div>
<h3>Recipes</h3>
@ -17,8 +25,8 @@
Failed: {{ recipeNumbers.failure }}
</div>
</v-card-text>
</v-card>
<v-card flat>
</div>
<div>
<v-card-text>
<div>
<h3>Themes</h3>
@ -30,8 +38,8 @@
Failed: {{ themeNumbers.failure }}
</div>
</v-card-text>
</v-card>
<v-card flat>
</div>
<div>
<v-card-text>
<div>
<h3>Settings</h3>
@ -43,7 +51,7 @@
Failed: {{ settingsNumbers.failure }}
</div>
</v-card-text>
</v-card>
</div>
</v-row>
</v-card-text>
<v-tabs v-model="tab">

View file

@ -25,17 +25,19 @@
<v-card-text>
<v-row>
<v-col cols="12" sm="6">
<v-card outlined min-height="250">
<v-card-text class="pt-2 pb-1">
<h3>{{$t('settings.homepage.homepage-categories')}}</h3>
</v-card-text>
<v-divider></v-divider>
<v-list
min-height="200"
dense
max-height="200"
style="overflow:auto"
>
<v-card outlined min-height="350px">
<v-app-bar dark dense color="primary">
<v-icon left>
mdi-home
</v-icon>
<v-toolbar-title class="headline">
Home Page Categories
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-list height="300" dense style="overflow:auto">
<v-list-item-group>
<draggable
v-model="homeCategories"
@ -65,24 +67,19 @@
</v-card>
</v-col>
<v-col cols="12" sm="6">
<v-card outlined min-height="250px">
<v-card-text class="pt-2 pb-1">
<h3>
{{$t('settings.homepage.all-categories')}}
<span>
<v-btn absolute right x-small color="success" icon>
<v-icon>mdi-plus</v-icon></v-btn
>
</span>
</h3>
</v-card-text>
<v-divider></v-divider>
<v-list
min-height="200"
dense
max-height="200"
style="overflow:auto"
>
<v-card outlined height="350px">
<v-app-bar dark dense color="primary">
<v-icon left>
mdi-tag
</v-icon>
<v-toolbar-title class="headline">
All Categories
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-list height="300" dense style="overflow:auto">
<v-list-item-group>
<draggable
v-model="categories"

View file

@ -1,5 +1,5 @@
<template>
<v-card class="my-2" :loading="loading">
<v-card outlined class="my-2" :loading="loading">
<v-card-title>
{{ title }}
<v-spacer></v-spacer>

View file

@ -0,0 +1,29 @@
<template>
<div class="text-center">
<v-dialog v-model="dialog" width="500">
<LoginForm @logged-in="dialog = false" />
</v-dialog>
</div>
</template>
<script>
import LoginForm from "./LoginForm";
export default {
components: {
LoginForm,
},
data() {
return {
dialog: false,
};
},
methods: {
open() {
this.dialog = true;
},
},
};
</script>
<style>
</style>

View file

@ -0,0 +1,145 @@
<template>
<div>
<v-card max-width="500px">
<v-divider></v-divider>
<v-app-bar dark color="primary mt-n1">
<v-icon large left v-if="!loading">
mdi-account
</v-icon>
<v-progress-circular
v-else
indeterminate
color="white"
large
class="mr-2"
>
</v-progress-circular>
<v-toolbar-title class="headline"> Login </v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-text>
<v-form>
<v-text-field
v-if="!options.isLoggingIn"
v-model="user.name"
light="light"
prepend-icon="person"
:label="$t('general.name')"
></v-text-field>
<v-text-field
v-model="user.email"
light="light"
prepend-icon="mdi-email"
:label="$t('login.email')"
type="email"
></v-text-field>
<v-text-field
v-model="user.password"
light="light"
prepend-icon="mdi-lock"
:label="$t('login.password')"
:type="showPassword ? 'text' : 'password'"
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
@click:append="showPassword = !showPassword"
></v-text-field>
<v-checkbox
class="mb-2 mt-0"
v-if="options.isLoggingIn"
v-model="options.shouldStayLoggedIn"
light="light"
:label="$t('login.stay-logged-in')"
hide-details="hide-details"
></v-checkbox>
<v-btn
v-if="options.isLoggingIn"
@click.prevent="login"
dark
color="primary"
block="block"
type="submit"
>{{ $t("login.sign-in") }}</v-btn
>
<v-btn
v-else
block="block"
type="submit"
@click.prevent="options.isLoggingIn = true"
>{{ $t("login.sign-up") }}</v-btn
>
</v-form>
<v-alert v-if="error" outlined class="mt-3 mb-0" type="error">
Could Not Validate Credentials
</v-alert>
</v-card-text>
<!-- <v-card-actions v-if="options.isLoggingIn" class="card-actions">
<div>
Don't have an account?
</div>
<v-spacer></v-spacer>
<v-btn
color="primary"
light="light"
@click="options.isLoggingIn = false"
>
Sign up
</v-btn>
</v-card-actions> -->
</v-card>
</div>
</template>
<script>
import api from "@/api";
export default {
props: {},
data() {
return {
loading: false,
error: false,
showLogin: false,
showPassword: false,
user: {
email: "",
password: "",
},
options: {
isLoggingIn: true,
},
};
},
mounted() {
this.clear();
},
methods: {
clear() {
this.user = { email: "", password: "" };
},
async login() {
this.loading = true;
this.error = false;
let formData = new FormData();
formData.append("username", this.user.email);
formData.append("password", this.user.password);
let key;
try {
key = await api.users.login(formData);
} catch {
this.error = true;
}
if (key.status != 200) this.error = true;
else {
this.$emit("logged-in");
this.clear();
}
console.log(key);
this.$store.commit("setToken", key.data.access_token)
this.loading = false;
},
},
};
</script>
<style>
</style>

View file

@ -27,7 +27,7 @@
<script>
import utils from "@/utils";
import SearchDialog from "../UI/SearchDialog";
import SearchDialog from "../UI/Search/SearchDialog";
export default {
components: {
SearchDialog,

View file

@ -37,7 +37,7 @@
</template>
<script>
import Confirmation from "./Confirmation";
import Confirmation from "../../components/UI/Confirmation";
export default {
props: {

View file

@ -2,9 +2,25 @@
<div class="text-center">
<v-dialog v-model="addRecipe" width="650" @click:outside="reset">
<v-card :loading="processing">
<v-card-title class="headline"
>{{ $t("new-recipe.from-url") }}
</v-card-title>
<v-app-bar dark color="primary mb-2">
<v-icon large left v-if="!processing">
mdi-link
</v-icon>
<v-progress-circular
v-else
indeterminate
color="white"
large
class="mr-2"
>
</v-progress-circular>
<v-toolbar-title class="headline">
{{ $t("new-recipe.from-url") }}
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-text>
<v-form ref="urlForm">
@ -14,7 +30,10 @@
required
validate-on-blur
autofocus
class="mt-1"
:rules="[isValidWebUrl]"
:hint="$t('new-recipe.url-form-hint')"
persistent-hint
></v-text-field>
</v-form>
@ -30,7 +49,12 @@
<v-btn color="grey" text @click="reset">
{{ $t("general.close") }}
</v-btn>
<v-btn color="success" text @click="createRecipe">
<v-btn
color="success"
text
@click="createRecipe"
:loading="processing"
>
{{ $t("general.submit") }}
</v-btn>
</v-card-actions>

View file

@ -53,7 +53,7 @@
</template>
<script>
import RecipeCard from "./RecipeCard";
import RecipeCard from "../Recipe/RecipeCard";
export default {
components: {
RecipeCard,

View file

@ -21,14 +21,20 @@
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" text @click="cancel"> {{ $t("general.cancel") }} </v-btn>
<v-btn :color="color" text @click="confirm"> {{ $t("general.confirm") }} </v-btn>
<v-btn color="grey" text @click="cancel">
{{ $t("general.cancel") }}
</v-btn>
<v-btn :color="color" text @click="confirm">
{{ $t("general.confirm") }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
const CLOSE_EVENT = "close";
const OPEN_EVENT = "open";
/**
* Confirmation Component used to add a second validaion step to an action.
* @version 1.0.1
@ -51,7 +57,7 @@ export default {
*/
icon: {
type: String,
default: "mid-alert-circle"
default: "mid-alert-circle",
},
/**
* Color theme of the component. Chose one of the defined theme colors.
@ -59,28 +65,35 @@ export default {
*/
color: {
type: String,
default: "error"
default: "error",
},
/**
* Define the max width of the component.
*/
width: {
type: Number,
default: 400
default: 400,
},
/**
* zIndex of the component.
*/
zIndex: {
type: Number,
default: 200
}
default: 200,
},
},
watch: {
dialog() {
if (this.dialog === false) {
this.$emit(CLOSE_EVENT);
} else this.$emit(OPEN_EVENT);
},
},
data: () => ({
/**
* Keep state of open or closed
*/
dialog: false
dialog: false,
}),
methods: {
/**
@ -120,8 +133,8 @@ export default {
//Hide Modal
this.dialog = false;
}
}
},
},
};
</script>

View file

@ -0,0 +1,80 @@
<template>
<div class="text-center">
<LoginDialog ref="loginDialog" />
<v-menu
transition="slide-x-transition"
bottom
right
offset-y
close-delay="200"
>
<template v-slot:activator="{ on, attrs }">
<v-btn v-bind="attrs" v-on="on" icon>
<v-icon>mdi-translate</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item-group v-model="selectedItem" color="primary">
<v-list-item
v-for="(item, i) in allLanguages"
:key="i"
link
@click="setLanguage(item.value)"
>
<v-list-item-content>
<v-list-item-title>
{{ item.name }}
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list-item-group>
</v-list>
</v-menu>
</div>
</template>
<script>
import LoginDialog from "../Login/LoginDialog";
export default {
components: {
LoginDialog,
},
data: function() {
return {
selectedItem: 0,
items: [
{
name: "English",
value: "en",
},
],
};
},
mounted() {
let active = this.$store.getters.getActiveLang;
this.allLanguages.forEach((element, index) => {
if (element.value === active) {
this.selectedItem = index;
return;
}
});
},
computed: {
allLanguages() {
return this.$store.getters.getAllLangs;
},
},
methods: {
setLanguage(selectedLanguage) {
this.$store.commit("setLang", selectedLanguage);
},
},
};
</script>
<style>
.menu-text {
text-align: left !important;
}
</style>

View file

@ -1,99 +0,0 @@
<template>
<div class="text-center">
<v-btn icon @click="showLogin = true">
<v-icon>mdi-account</v-icon>
</v-btn>
<v-dialog v-model="showLogin" width="500">
<v-flex class="login-form text-xs-center">
<v-card>
<v-card-text>
<v-form>
<v-text-field
v-if="!options.isLoggingIn"
v-model="user.name"
light="light"
prepend-icon="person"
:label="$t('general.name')"
></v-text-field>
<v-text-field
v-model="user.email"
light="light"
prepend-icon="mdi-email"
:label="$t('login.email')"
type="email"
></v-text-field>
<v-text-field
v-model="user.password"
light="light"
prepend-icon="mdi-lock"
:label="$t('login.password')"
type="password"
></v-text-field>
<v-checkbox
class="mb-2 mt-0"
v-if="options.isLoggingIn"
v-model="options.shouldStayLoggedIn"
light="light"
:label="$t('login.stay-logged-in')"
hide-details="hide-details"
></v-checkbox>
<v-btn
v-if="options.isLoggingIn"
@click.prevent="login"
dark
color="primary"
block="block"
type="submit"
>{{ $t("login.sign-in") }}</v-btn
>
<v-btn
v-else
block="block"
type="submit"
@click.prevent="options.isLoggingIn = true"
>{{ $t("login.sign-up") }}</v-btn
>
</v-form>
</v-card-text>
<!-- <v-card-actions v-if="options.isLoggingIn" class="card-actions">
Don't have an account?
<v-btn
color="primary"
light="light"
@click="options.isLoggingIn = false"
>
Sign up
</v-btn>
</v-card-actions> -->
</v-card>
</v-flex>
</v-dialog>
</div>
</template>
<script>
import api from "@/api";
export default {
props: {},
data() {
return {
showLogin: false,
user: {
email: "",
password: "",
},
options: {
isLoggingIn: true,
},
};
},
methods: {
async login() {
let key = await api.login(this.user.email, this.user.password);
},
},
};
</script>
<style>
</style>

View file

@ -43,8 +43,8 @@
</template>
<script>
import SearchBar from "../UI/SearchBar";
import RecipeCard from "../UI/RecipeCard";
import SearchBar from "./SearchBar";
import RecipeCard from "../../Recipe/RecipeCard";
export default {
components: {
SearchBar,

View file

@ -1,27 +0,0 @@
<template>
<v-row>
<v-col cols="2"> </v-col>
<v-col>
<v-expand-transition>
<Search class="search-bar" />
</v-expand-transition>
</v-col>
<v-col cols="2">
<v-btn icon>
<v-icon> mdi-filter </v-icon>
</v-btn>
</v-col>
</v-row>
</template>
<script>
import Search from "./Search";
export default {
components: {
Search,
},
};
</script>
<style>
</style>

View file

@ -1,5 +1,6 @@
<template>
<div class="text-center">
<LoginDialog ref="loginDialog" />
<v-menu
transition="slide-x-transition"
bottom
@ -15,7 +16,13 @@
</template>
<v-list>
<v-list-item v-for="(item, i) in items" :key="i" link :to="item.nav">
<v-list-item
v-for="(item, i) in filteredItems"
:key="i"
link
:to="item.nav ? item.nav : null"
@click="item.login ? openLoginDialog() : null"
>
<v-list-item-icon>
<v-icon>{{ item.icon }}</v-icon>
</v-list-item-icon>
@ -31,36 +38,70 @@
</template>
<script>
import LoginDialog from "../Login/LoginDialog";
export default {
components: {
LoginDialog,
},
data: function() {
return {
items: [
{
icon: "mdi-account",
title: "Login",
restricted: false,
login: true,
},
{
icon: "mdi-calendar-week",
title: this.$i18n.t("meal-plan.dinner-this-week"),
nav: "/meal-plan/this-week",
restricted: true,
},
{
icon: "mdi-calendar-today",
title: this.$i18n.t("meal-plan.dinner-today"),
nav: "/meal-plan/today",
restricted: true,
},
{
icon: "mdi-calendar-multiselect",
title: this.$i18n.t("meal-plan.planner"),
nav: "/meal-plan/planner",
restricted: true,
},
{
icon: "mdi-account",
title: "Logout",
restricted: true,
nav: "/logout",
},
{
icon: "mdi-cog",
title: this.$i18n.t("general.settings"),
nav: "/settings/site",
nav: "/admin",
restricted: true,
},
],
};
},
mounted() {},
computed: {
loggedIn() {
return this.$store.getters.getIsLoggedIn;
},
filteredItems() {
if (this.loggedIn) {
return this.items.filter(x => x.restricted == true);
} else {
return this.items.filter(x => x.restricted == false);
}
},
},
methods: {
navRouter(route) {
this.$router.push(route);
openLoginDialog() {
this.$refs.loginDialog.open();
},
},
};

View file

@ -1,41 +0,0 @@
<template>
<div class="text-center">
<v-snackbar :value="active" :timeout="timeout" :color="type">
{{ text }}
<template v-slot:action="{ attrs }">
<v-btn color="white" text v-bind="attrs" @click="close(false)">
{{$t('general.close')}}
</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script>
export default {
data: () => ({
snackbar: false,
timeout: -1,
}),
computed: {
text() {
return this.$store.getters.getSnackText;
},
active() {
return this.$store.getters.getSnackActive;
},
type() {
return this.$store.getters.getSnackType;
},
},
methods: {
close(value) {
this.$store.commit("setSnackActive", value);
},
},
};
</script>
<style>
</style>

View file

@ -4,8 +4,9 @@
"take-me-home": "Take me Home"
},
"new-recipe": {
"from-url": "From URL",
"from-url": "Import a Recipe",
"recipe-url": "Recipe URL",
"url-form-hint": "Copy and paste a link from your favorite recipe website",
"error-message": "Looks like there was an error parsing the URL. Check the log and debug/last_recipe.json to see what went wrong.",
"bulk-add": "Bulk Add",
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Paste in your recipe data. Each line will be treated as an item in a list"
@ -89,6 +90,8 @@
},
"settings": {
"general-settings": "General Settings",
"change-password": "Change Password",
"admin-settings": "Admin Settings",
"local-api": "Local API",
"language": "Language",
"add-a-new-theme": "Add a New Theme",

View file

@ -48,11 +48,11 @@
<script>
import api from "@/api";
import SuccessFailureAlert from "../../UI/SuccessFailureAlert";
import ImportSummaryDialog from "./ImportSummaryDialog";
import UploadBtn from "../../UI/UploadBtn";
import AvailableBackupCard from "./AvailableBackupCard";
import NewBackupCard from "./NewBackupCard";
import SuccessFailureAlert from "@/components/UI/SuccessFailureAlert";
import ImportSummaryDialog from "@/components/Admin/Backup/ImportSummaryDialog";
import UploadBtn from "@/components/UI/UploadBtn";
import AvailableBackupCard from "@/components/Admin/Backup/AvailableBackupCard";
import NewBackupCard from "@/components/Admin/Backup/NewBackupCard";
export default {
components: {

View file

@ -0,0 +1,243 @@
<template>
<v-data-table
:headers="headers"
:items="users"
sort-by="calories"
class="elevation-1"
>
<template v-slot:top>
<v-toolbar flat>
<v-toolbar-title>Mealie Users</v-toolbar-title>
<v-divider class="mx-4" inset vertical></v-divider>
<v-spacer></v-spacer>
<v-dialog v-model="dialog" max-width="600px">
<template v-slot:activator="{ on, attrs }">
<v-btn color="primary" dark class="mb-2" v-bind="attrs" v-on="on">
Create User
</v-btn>
</template>
<v-card>
<v-app-bar dark dense color="primary">
<v-icon left>
mdi-account
</v-icon>
<v-toolbar-title class="headline">
{{ formTitle }}
</v-toolbar-title>
<v-spacer></v-spacer>
<v-toolbar-title class="headline">
User ID: {{ editedItem.id }}
</v-toolbar-title>
</v-app-bar>
<v-card-text>
<v-container>
<v-row>
<v-col cols="12" sm="12" md="6">
<v-text-field
v-model="editedItem.fullName"
label="Full Name"
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="6">
<v-text-field
v-model="editedItem.email"
label="Email"
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="6">
<v-text-field
v-model="editedItem.family"
label="Family Group"
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="6" v-if="showPassword">
<v-text-field
v-model="editedItem.password"
label="User Password"
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="3">
<v-switch
v-model="editedItem.admin"
label="Admin"
></v-switch>
</v-col>
</v-row>
</v-container>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" text @click="close">
Cancel
</v-btn>
<v-btn color="primary" @click="save">
Save
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<Confirmation
ref="deleteUserDialog"
title="Confirm User Deletion"
:message="
`Are you sure you want to delete the user <b>${activeName} ID: ${activeId}<b/>`
"
icon="mdi-alert"
@confirm="deleteUser"
:width="450"
@close="closeDelete"
/>
</v-toolbar>
</template>
<template v-slot:item.actions="{ item }">
<v-btn class="mr-1" small color="error" @click="deleteItem(item)">
<v-icon small left>
mdi-delete
</v-icon>
Delete
</v-btn>
<v-btn small color="success" @click="editItem(item)">
<v-icon small left class="mr-2">
mdi-pencil
</v-icon>
Edit
</v-btn>
</template>
<template v-slot:item.admin="{ item }">
{{ item.admin ? "Admin" : "User" }}
</template>
<template v-slot:no-data>
<v-btn color="primary" @click="initialize">
Reset
</v-btn>
</template>
</v-data-table>
</template>
<script>
import Confirmation from "@/components/UI/Confirmation";
import api from "@/api";
export default {
components: { Confirmation },
data: () => ({
dialog: false,
activeId: null,
activeName: null,
headers: [
{
text: "User ID",
align: "start",
sortable: false,
value: "id",
},
{ text: "Full Name", value: "fullName" },
{ text: "Email", value: "email" },
{ text: "Family", value: "family" },
{ text: "Admin", value: "admin" },
{ text: "", value: "actions", sortable: false, align: "center" },
],
users: [],
editedIndex: -1,
editedItem: {
id: 0,
fullName: "",
email: "",
family: "",
admin: false,
},
defaultItem: {
id: 0,
fullName: "",
email: "",
family: "",
admin: false,
},
}),
computed: {
formTitle() {
return this.editedIndex === -1 ? "New User" : "Edit User";
},
showPassword() {
return this.editedIndex === -1 ? true : false;
},
},
watch: {
dialog(val) {
val || this.close();
},
dialogDelete(val) {
val || this.closeDelete();
},
},
created() {
this.initialize();
},
methods: {
async initialize() {
this.users = await api.users.allUsers();
},
async deleteUser() {
await api.users.delete(this.editedIndex);
this.initialize();
},
editItem(item) {
this.editedIndex = this.users.indexOf(item);
this.editedItem = Object.assign({}, item);
this.dialog = true;
},
deleteItem(item) {
this.activeId = item.id;
this.activeName = item.fullName;
this.editedIndex = this.users.indexOf(item);
this.editedItem = Object.assign({}, item);
this.$refs.deleteUserDialog.open();
},
deleteItemConfirm() {
this.users.splice(this.editedIndex, 1);
this.closeDelete();
},
close() {
this.dialog = false;
this.$nextTick(() => {
this.editedItem = Object.assign({}, this.defaultItem);
this.editedIndex = -1;
});
},
closeDelete() {
this.dialogDelete = false;
this.$nextTick(() => {
this.editedItem = Object.assign({}, this.defaultItem);
this.editedIndex = -1;
});
},
async save() {
if (this.editedIndex > -1) {
console.log("New User", this.editedItem);
api.users.update(this.editedItem);
} else {
api.users.create(this.editedItem);
}
await this.initialize();
this.close();
},
},
};
</script>
<style>
</style>

View file

@ -97,7 +97,7 @@
<script>
import api from "@/api";
import TimePickerDialog from "./TimePickerDialog";
import TimePickerDialog from "@/components/Admin/MealPlanner/TimePickerDialog";
export default {
components: {
TimePickerDialog,

View file

@ -13,8 +13,8 @@
{{ $t("migration.recipe-migration") }}
</v-card-title>
<v-divider></v-divider>
</v-card>
<v-card-text>
<v-row dense>
<v-col
:cols="12"
@ -35,13 +35,15 @@
/>
</v-col>
</v-row>
</v-card-text>
</v-card>
</div>
</template>
<script>
import MigrationCard from "./MigrationCard";
import SuccessFailureAlert from "../../UI/SuccessFailureAlert";
import MigrationCard from "@/components/Admin/Migration/MigrationCard";
import SuccessFailureAlert from "@/components/UI/SuccessFailureAlert";
import api from "@/api";
export default {
components: {

View file

@ -0,0 +1,92 @@
<template>
<v-card>
<v-card-title class="headline">
<span>
<v-avatar color="accent" size="40" class="mr-2" v-if="!loading">
<img src="https://cdn.vuetifyjs.com/images/john.jpg" alt="John" />
</v-avatar>
<v-progress-circular
v-else
indeterminate
color="primary"
large
class="mr-2"
>
</v-progress-circular>
</span>
Profile
<v-spacer></v-spacer>
User ID: {{ user.id }}
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-form>
<v-text-field label="Full Name" v-model="user.fullName"> </v-text-field>
<v-text-field label="Email" v-model="user.email"> </v-text-field>
<v-text-field
label="Family"
readonly
v-model="user.family"
persistent-hint
hint="Family groups can only be set by administrators"
>
</v-text-field>
</v-form>
</v-card-text>
<v-card-actions>
<v-btn color="accent" class="mr-2">
<v-icon left> mdi-lock </v-icon>
{{ $t("settings.change-password") }}
</v-btn>
<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>
</template>
<script>
// import AvatarPicker from '@/components/AvatarPicker'
import api from "@/api";
export default {
pageTitle: "My Profile",
data() {
return {
loading: false,
user: {
fullName: "Change Me",
email: "changeme@email.com",
family: "public",
admin: true,
id: 1,
},
showAvatarPicker: false,
};
},
async mounted() {
this.refreshProfile();
},
methods: {
async refreshProfile() {
this.user = await api.users.self();
},
openAvatarPicker() {
this.showAvatarPicker = true;
},
selectAvatar(avatar) {
this.user.avatar = avatar;
},
async updateUser() {
this.loading = true;
let newKey = await api.users.update(this.user);
this.$store.commit("setToken", newKey.access_token);
this.refreshProfile();
this.loading = false;
},
},
};
</script>

View file

@ -1,7 +1,7 @@
<template>
<v-card>
<v-card-title>
{{ $t("settings.general-settings") }}
<v-card-title class="headline">
{{ $t("settings.admin-settings") }}
<v-spacer></v-spacer>
<span>
<v-btn class="pt-1" text href="/docs">
@ -10,23 +10,7 @@
</v-btn>
</span>
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<h2 class="mt-1 mb-4">{{ $t("settings.language") }}</h2>
<v-row>
<v-col sm="3">
<v-select
dense
v-model="selectedLang"
:items="langOptions"
item-text="name"
item-value="value"
:label="$t('settings.language')"
>
</v-select>
</v-col>
</v-row>
</v-card-text>
<v-divider></v-divider>
<HomePageSettings />
<v-divider></v-divider>
@ -34,7 +18,7 @@
</template>
<script>
import HomePageSettings from "./HomePageSettings";
import HomePageSettings from "@/components/Admin/General/HomePageSettings";
export default {
components: {

View file

@ -150,9 +150,9 @@
<script>
import api from "@/api";
import ColorPickerDialog from "./ColorPickerDialog";
import NewThemeDialog from "./NewThemeDialog";
import Confirmation from "../../UI/Confirmation";
import ColorPickerDialog from "@/components/Admin/Theme/ColorPickerDialog";
import NewThemeDialog from "@/components/Admin/Theme/NewThemeDialog";
import Confirmation from "@/components/UI/Confirmation";
export default {
components: {

View file

@ -0,0 +1,20 @@
<template>
<div>
<AdminSidebar />
<v-slide-x-transition hide-on-leave>
<router-view></router-view>
</v-slide-x-transition>
</div>
</template>
<script>
import AdminSidebar from "../../components/Admin/AdminSidebar";
export default {
components: {
AdminSidebar,
},
};
</script>
<style>
</style>

View file

@ -0,0 +1,19 @@
<template>
<v-row justify="start" height="100%">
<v-col align="center">
<LoginForm />
</v-col>
</v-row>
</template>
<script>
import LoginForm from "../components/Login/LoginForm";
export default {
components: {
LoginForm,
},
};
</script>
<style>
</style>

View file

@ -12,7 +12,7 @@
</v-img>
<br v-else />
<ButtonRow
<EditorButtonRow
@json="jsonEditor = true"
@editor="jsonEditor = false"
@save="createRecipe"
@ -43,12 +43,12 @@ import api from "@/api";
import RecipeEditor from "../components/Recipe/RecipeEditor";
import VJsoneditor from "v-jsoneditor";
import ButtonRow from "../components/UI/ButtonRow";
import EditorButtonRow from "../components/Recipe/EditorButtonRow";
export default {
components: {
VJsoneditor,
RecipeEditor,
ButtonRow,
EditorButtonRow,
},
data() {
return {

View file

@ -13,7 +13,7 @@
:performTime="recipeDetails.performTime"
/>
</v-img>
<ButtonRow
<EditorButtonRow
:open="showIcons"
@json="jsonEditor = true"
@editor="
@ -62,14 +62,14 @@ import VJsoneditor from "v-jsoneditor";
import RecipeViewer from "../components/Recipe/RecipeViewer";
import RecipeEditor from "../components/Recipe/RecipeEditor";
import RecipeTimeCard from "../components/Recipe/RecipeTimeCard.vue";
import ButtonRow from "../components/UI/ButtonRow";
import EditorButtonRow from "../components/Recipe/EditorButtonRow";
export default {
components: {
VJsoneditor,
RecipeViewer,
RecipeEditor,
ButtonRow,
EditorButtonRow,
RecipeTimeCard,
},
data() {

View file

@ -34,8 +34,8 @@
</template>
<script>
import SearchBar from "../components/UI/SearchBar";
import RecipeCard from "../components/UI/RecipeCard";
import SearchBar from "../components/UI/Search/SearchBar";
import RecipeCard from "../components/Recipe/RecipeCard";
export default {
components: {
SearchBar,

View file

@ -1,90 +0,0 @@
<template>
<v-container>
<v-alert
v-if="newVersion"
color="green"
type="success"
outlined
v-html="
$t('settings.new-version-available', {
aContents:
'target=\'_blank\' href=\'https://github.com/hay-kot/mealie\' class=\'green--text\'',
})
"
>
</v-alert>
<General />
<Theme class="mt-2" />
<Backup class="mt-2" />
<MealPlanner class="mt-2" />
<Migration class="mt-2" />
<p class="text-center my-2">
{{ $t("settings.current") }}
{{ version }} |
{{ $t("settings.latest") }}
{{ latestVersion }}
·
<a href="https://hay-kot.github.io/mealie/" target="_blank">
{{ $t("settings.explore-the-docs") }}
</a>
·
<a
href="https://hay-kot.github.io/mealie/contributors/non-coders/"
target="_blank"
>
{{ $t("settings.contribute") }}
</a>
</p>
</v-container>
</template>
<script>
import Backup from "../components/Settings/Backup";
import General from "../components/Settings/General";
import MealPlanner from "../components/Settings/MealPlanner";
import Theme from "../components/Settings/Theme";
import Migration from "../components/Settings/Migration";
import api from "@/api";
import axios from "axios";
export default {
components: {
Backup,
MealPlanner,
Theme,
Migration,
General,
},
data() {
return {
latestVersion: null,
version: null,
};
},
async mounted() {
this.getVersion();
let versionData = await api.meta.get_version();
this.version = versionData.version;
},
computed: {
newVersion() {
if ((this.latestVersion != null) & (this.latestVersion != this.version)) {
return true;
} else {
return false;
}
},
},
methods: {
async getVersion() {
let response = await axios.get(
"https://api.github.com/repos/hay-kot/mealie/releases/latest"
);
this.latestVersion = response.data.tag_name;
},
},
};
</script>
<style>
</style>

View file

@ -1,40 +0,0 @@
import HomePage from "./pages/HomePage";
import Page404 from "./pages/404Page";
import SearchPage from "./pages/SearchPage";
import RecipePage from "./pages/RecipePage";
import RecipeNewPage from "./pages/RecipeNewPage";
import SettingsPage from "./pages/SettingsPage";
import AllRecipesPage from "./pages/AllRecipesPage";
import CategoryPage from "./pages/CategoryPage";
import MeaplPlanPage from "./pages/MealPlanPage";
import Debug from "./pages/Debug";
import MealPlanThisWeekPage from "./pages/MealPlanThisWeekPage";
import api from "@/api";
export const routes = [
{ path: "/", component: HomePage },
{ path: "/mealie", component: HomePage },
{ path: "/debug", component: Debug },
{ path: "/search", component: SearchPage },
{ path: "/recipes/all", component: AllRecipesPage },
{ path: "/recipes/:category", component: CategoryPage },
{ path: "/recipe/:recipe", component: RecipePage },
{ path: "/new/", component: RecipeNewPage },
{ path: "/settings/site", component: SettingsPage },
{ path: "/meal-plan/planner", component: MeaplPlanPage },
{ path: "/meal-plan/this-week", component: MealPlanThisWeekPage },
{
path: "/meal-plan/today",
beforeEnter: async (_to, _from, next) => {
await todaysMealRoute().then(redirect => {
next(redirect);
});
},
},
{ path: "*", component: Page404 },
];
async function todaysMealRoute() {
const response = await api.mealPlans.today();
return "/recipe/" + response.data;
}

View file

@ -0,0 +1,48 @@
import Admin from "@/pages/Admin";
import Backup from "@/pages/Admin/Backup";
import Theme from "@/pages/Admin/Theme";
import MealPlanner from "@/pages/Admin/MealPlanner";
import Migration from "@/pages/Admin/Migration";
import Profile from "@/pages/Admin/Profile";
import ManageUsers from "@/pages/Admin/ManageUsers";
import Settings from "@/pages/Admin/Settings";
export default {
path: "/admin",
component: Admin,
children: [
{
path: "",
component: Profile,
},
{
path: "profile",
component: Profile,
},
{
path: "backups",
component: Backup,
},
{
path: "themes",
component: Theme,
},
{
path: "meal-planner",
component: MealPlanner,
},
{
path: "migrations",
component: Migration,
},
{
path: "manage-users",
component: ManageUsers,
},
{
path: "settings",
component: Settings,
},
],
};

View file

@ -0,0 +1,51 @@
import HomePage from "../pages/HomePage";
import Page404 from "../pages/404Page";
import SearchPage from "../pages/SearchPage";
import RecipePage from "../pages/RecipePage";
import RecipeNewPage from "../pages/RecipeNewPage";
import AllRecipesPage from "../pages/AllRecipesPage";
import CategoryPage from "../pages/CategoryPage";
import MeaplPlanPage from "../pages/MealPlanPage";
import Debug from "../pages/Debug";
import LoginPage from "../pages/LoginPage";
import MealPlanThisWeekPage from "../pages/MealPlanThisWeekPage";
import api from "@/api";
import Admin from "./admin";
import { store } from "../store/store";
export const routes = [
{ path: "/", name: "home", component: HomePage },
{
path: "/logout",
beforeEnter: (_to, _from, next) => {
store.commit("setToken", "");
store.commit("setIsLoggedIn", false);
next("/");
},
},
{ path: "/mealie", component: HomePage },
{ path: "/login", component: LoginPage },
{ path: "/debug", component: Debug },
{ path: "/search", component: SearchPage },
{ path: "/recipes/all", component: AllRecipesPage },
{ path: "/recipes/:category", component: CategoryPage },
{ path: "/recipe/:recipe", component: RecipePage },
{ path: "/new/", component: RecipeNewPage },
{ path: "/meal-plan/planner", component: MeaplPlanPage },
{ path: "/meal-plan/this-week", component: MealPlanThisWeekPage },
Admin,
{
path: "/meal-plan/today",
beforeEnter: async (_to, _from, next) => {
await todaysMealRoute().then(redirect => {
next(redirect);
});
},
},
{ path: "*", component: Page404 },
];
async function todaysMealRoute() {
const response = await api.mealPlans.today();
return "/recipe/" + response.data;
}

View file

@ -1,5 +1,6 @@
import api from "@/api";
import Vuetify from "../../plugins/vuetify";
import axios from "axios";
function inDarkMode(payload) {
let isDark;
@ -18,6 +19,8 @@ const state = {
activeTheme: {},
darkMode: "system",
isDark: false,
isLoggedIn: false,
token: "",
};
const mutations = {
@ -35,6 +38,14 @@ const mutations = {
state.darkMode = payload;
}
},
setIsLoggedIn(state, payload) {
state.isLoggedIn = payload;
},
setToken(state, payload) {
state.isLoggedIn = true;
axios.defaults.headers.common["Authorization"] = `Bearer ${payload}`;
state.token = payload;
},
};
const actions = {
@ -47,6 +58,7 @@ const actions = {
}
},
async initTheme({ dispatch, getters }) {
//If theme is empty resetTheme
if (Object.keys(getters.getActiveTheme).length === 0) {
@ -63,6 +75,8 @@ const getters = {
getActiveTheme: state => state.activeTheme,
getDarkMode: state => state.darkMode,
getIsDark: state => state.isDark,
getIsLoggedIn: state => state.isLoggedIn,
getToken: state => state.token,
};
export default {

View file

@ -20,11 +20,6 @@ const store = new Vuex.Store({
homePage,
},
state: {
// Home Page Settings
// Snackbar
snackActive: false,
snackText: "",
snackType: "warning",
// All Recipe Data Store
recentRecipes: [],
@ -33,15 +28,6 @@ const store = new Vuex.Store({
},
mutations: {
setSnackBar(state, payload) {
state.snackText = payload.text;
state.snackType = payload.type;
state.snackActive = true;
},
setSnackActive(state, payload) {
state.snackActive = payload;
},
setRecentRecipes(state, payload) {
state.recentRecipes = payload;
},
@ -68,11 +54,6 @@ const store = new Vuex.Store({
},
getters: {
//
getSnackText: state => state.snackText,
getSnackActive: state => state.snackActive,
getSnackType: state => state.snackType,
getRecentRecipes: state => state.recentRecipes,
getMealPlanCategories: state => state.mealPlanCategories,
},

View file

@ -1,3 +1,4 @@
const path = require("path");
module.exports = {
transpileDependencies: ["vuetify"],
publicPath: process.env.NODE_ENV === "production" ? "/" : "/",
@ -18,4 +19,11 @@ module.exports = {
enableInSFC: true,
},
},
configureWebpack: {
resolve: {
alias: {
"@": path.resolve("src"),
},
},
},
};

View file

@ -2,7 +2,9 @@ import uvicorn
from fastapi import FastAPI
# import utils.startup as startup
from app_config import APP_VERSION, PORT, PRODUCTION, docs_url, redoc_url
from core.config import APP_VERSION, PORT, SECRET, docs_url, redoc_url
from db.db_setup import sql_exists
from db.init_db import init_db
from routes import (
backup_routes,
debug_routes,
@ -17,8 +19,8 @@ from routes.recipe import (
recipe_crud_routes,
tag_routes,
)
from services.settings_services import default_settings_init
from utils.logger import logger
from routes.users import users
from fastapi.logger import logger
app = FastAPI(
title="Mealie",
@ -29,16 +31,17 @@ app = FastAPI(
)
def data_base_first_run():
init_db()
def start_scheduler():
import services.scheduler.scheduled_jobs
def init_settings():
default_settings_init()
import services.theme_services
def api_routers():
# Authentication
app.include_router(users.router)
# Recipes
app.include_router(all_recipe_routes.router)
app.include_router(category_routes.router)
@ -56,10 +59,11 @@ def api_routers():
app.include_router(debug_routes.router)
if not sql_exists:
data_base_first_run()
api_routers()
start_scheduler()
init_settings()
if __name__ == "__main__":
logger.info("-----SYSTEM STARTUP-----")

View file

@ -15,9 +15,12 @@ def ensure_dirs():
ENV = CWD.joinpath(".env")
dotenv.load_dotenv(ENV)
SECRET = "super-secret-key"
# General
APP_VERSION = "v0.3.0"
DB_VERSION = "v0.2.1"
DB_VERSION = "v0.3.0"
PRODUCTION = os.environ.get("ENV")
PORT = int(os.getenv("mealie_port", 9000))
API = os.getenv("api_docs", True)
@ -30,7 +33,7 @@ else:
redoc_url = None
# Helpful Globals
DATA_DIR = CWD.parent.joinpath("app_data")
DATA_DIR = CWD.parent.parent.joinpath("app_data")
if PRODUCTION:
DATA_DIR = Path("/app/data")
@ -59,6 +62,8 @@ REQUIRED_DIRS = [
ensure_dirs()
LOGGER_FILE = DATA_DIR.joinpath("mealie.log")
# DATABASE ENV
SQLITE_FILE = None

29
mealie/core/security.py Normal file
View file

@ -0,0 +1,29 @@
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Compares a plain string to a hashed password
Args:
plain_password (str): raw password string
hashed_password (str): hashed password from the database
Returns:
bool: Returns True if a match return False
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Takes in a raw password and hashes it. Used prior to saving
a new password to the database.
Args:
password (str): Password String
Returns:
str: Hashed Password
"""
return pwd_context.hash(password)

View file

@ -1,10 +1,11 @@
from sqlalchemy.orm.session import Session
from db.db_base import BaseDocument
from db.sql.meal_models import MealPlanModel
from db.sql.recipe_models import Category, RecipeModel, Tag
from db.sql.settings_models import SiteSettingsModel
from db.sql.theme_models import SiteThemeModel
from db.models.mealplan import MealPlanModel
from db.models.recipe import Category, RecipeModel, Tag
from db.models.settings import SiteSettingsModel
from db.models.theme import SiteThemeModel
from db.models.users import User
"""
# TODO
@ -55,6 +56,12 @@ class _Themes(BaseDocument):
self.sql_model = SiteThemeModel
class _Users(BaseDocument):
def __init__(self) -> None:
self.primary_key = "id"
self.sql_model = User
class Database:
def __init__(self) -> None:
self.recipes = _Recipes()
@ -63,6 +70,7 @@ class Database:
self.themes = _Themes()
self.categories = _Categories()
self.tags = _Tags()
self.users = _Users()
db = Database()

View file

@ -3,7 +3,7 @@ from typing import List
from sqlalchemy.orm import load_only
from sqlalchemy.orm.session import Session
from db.sql.model_base import SqlAlchemyBase
from db.models.model_base import SqlAlchemyBase
class BaseDocument:
@ -108,7 +108,10 @@ class BaseDocument:
db_entries = [x.dict() for x in result]
if limit == 1:
try:
return db_entries[0]
except IndexError:
return None
return db_entries
@ -124,9 +127,8 @@ class BaseDocument:
"""
new_document = self.sql_model(session=session, **document)
session.add(new_document)
return_data = new_document.dict()
session.commit()
return_data = new_document.dict()
return return_data
def update(self, session: Session, match_value: str, new_data: str) -> dict:

View file

@ -1,7 +1,7 @@
from app_config import SQLITE_FILE, USE_SQL
from core.config import SQLITE_FILE, USE_SQL
from sqlalchemy.orm.session import Session
from db.sql.db_session import sql_global_init
from db.models.db_session import sql_global_init
sql_exists = True

63
mealie/db/init_db.py Normal file
View file

@ -0,0 +1,63 @@
from core.security import get_password_hash
from fastapi.logger import logger
from schema.settings import SiteSettings, Webhooks
from sqlalchemy.orm import Session
from sqlalchemy.orm.session import Session
from db.database import db
from db.db_setup import create_session
def init_db(db: Session = None) -> None:
if not db:
db = create_session()
default_settings_init(db)
default_theme_init(db)
default_user_init(db)
db.close()
def default_theme_init(session: Session):
default_theme = {
"name": "default",
"colors": {
"primary": "#E58325",
"accent": "#00457A",
"secondary": "#973542",
"success": "#5AB1BB",
"info": "#4990BA",
"warning": "#FF4081",
"error": "#EF5350",
},
}
try:
db.themes.create(session, default_theme)
logger.info("Generating default theme...")
except:
logger.info("Default Theme Exists.. skipping generation")
def default_settings_init(session: Session):
try:
webhooks = Webhooks()
default_entry = SiteSettings(name="main", webhooks=webhooks)
document = db.settings.create(session, default_entry.dict())
logger.info(f"Created Site Settings: \n {document}")
except:
pass
def default_user_init(session: Session):
default_user = {
"full_name": "Change Me",
"email": "changeme@email.com",
"password": get_password_hash("MyPassword"),
"family": "public",
"admin": True,
}
logger.info("Generating Default User")
db.users.create(session, default_user)

View file

@ -0,0 +1,5 @@
from db.models.mealplan import *
from db.models.recipe import *
from db.models.settings import *
from db.models.theme import *
from db.models.users import *

View file

@ -1,7 +1,7 @@
from pathlib import Path
import sqlalchemy as sa
from db.sql.model_base import SqlAlchemyBase
from db.models.model_base import SqlAlchemyBase
from sqlalchemy.orm import sessionmaker
@ -18,7 +18,7 @@ def sql_global_init(db_file: Path, check_thread=False):
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
import db.sql._all_models
import db.models._all_models
SqlAlchemyBase.metadata.create_all(engine)

View file

@ -3,7 +3,7 @@ from typing import List
import sqlalchemy as sa
import sqlalchemy.orm as orm
from db.sql.model_base import BaseMixins, SqlAlchemyBase
from db.models.model_base import BaseMixins, SqlAlchemyBase
class Meal(SqlAlchemyBase):

View file

@ -4,11 +4,11 @@ from typing import List
import sqlalchemy as sa
import sqlalchemy.orm as orm
from db.sql.model_base import BaseMixins, SqlAlchemyBase
from db.models.model_base import BaseMixins, SqlAlchemyBase
from slugify import slugify
from sqlalchemy.ext.orderinglist import ordering_list
from sqlalchemy.orm import validates
from utils.logger import logger
from fastapi.logger import logger
class ApiExtras(SqlAlchemyBase):

View file

@ -1,7 +1,6 @@
import sqlalchemy as sa
import sqlalchemy.orm as orm
from db.sql.model_base import BaseMixins, SqlAlchemyBase
from db.sql.recipe_models import Category
from db.models.model_base import BaseMixins, SqlAlchemyBase
class SiteSettingsModel(SqlAlchemyBase, BaseMixins):

View file

@ -1,6 +1,6 @@
import sqlalchemy as sa
import sqlalchemy.orm as orm
from db.sql.model_base import BaseMixins, SqlAlchemyBase
from db.models.model_base import BaseMixins, SqlAlchemyBase
class SiteThemeModel(SqlAlchemyBase):

44
mealie/db/models/users.py Normal file
View file

@ -0,0 +1,44 @@
from db.models.model_base import BaseMixins, SqlAlchemyBase
from sqlalchemy import Boolean, Column, Integer, String
class User(SqlAlchemyBase, BaseMixins):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
full_name = Column(String, index=True)
email = Column(String, unique=True, index=True)
password = Column(String)
is_active = Column(Boolean(), default=True)
family = Column(String)
admin = Column(Boolean(), default=False)
def __init__(
self,
session,
full_name,
email,
password,
family="public",
admin=False,
) -> None:
self.full_name = full_name
self.email = email
self.family = family
self.admin = admin
self.password = password
def dict(self):
return {
"id": self.id,
"full_name": self.full_name,
"email": self.email,
"admin": self.admin,
"family": self.family,
"password": self.password,
}
def update(self, full_name, email, family, admin, session=None):
self.full_name = full_name
self.email = email
self.family = family
self.admin = admin

View file

@ -1,4 +0,0 @@
from db.sql.meal_models import *
from db.sql.recipe_models import *
from db.sql.settings_models import *
from db.sql.theme_models import *

View file

View file

@ -1,15 +1,15 @@
import operator
import shutil
from app_config import BACKUP_DIR, TEMPLATE_DIR
from core.config import BACKUP_DIR, TEMPLATE_DIR
from db.db_setup import generate_session
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from models.backup_models import BackupJob, ImportJob, Imports, LocalBackup
from schema.backup import BackupJob, ImportJob, Imports, LocalBackup
from services.backups.exports import backup_all
from services.backups.imports import ImportDatabase
from sqlalchemy.orm.session import Session
from starlette.responses import FileResponse
from utils.snackbar import SnackResponse
from schema.snackbar import SnackResponse
router = APIRouter(prefix="/api/backups", tags=["Backups"])

View file

@ -1,9 +1,7 @@
import json
from app_config import APP_VERSION, DEBUG_DIR
from core.config import APP_VERSION, DEBUG_DIR, LOGGER_FILE
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
from utils.logger import LOGGER_FILE
router = APIRouter(prefix="/api/debug", tags=["Debug"])

23
mealie/routes/deps.py Normal file
View file

@ -0,0 +1,23 @@
from core.config import SECRET
from db.database import db
from db.db_setup import create_session
from fastapi_login import LoginManager
from sqlalchemy.orm.session import Session
from schema.user import UserInDB
manager = LoginManager(SECRET, "/api/auth/token")
@manager.user_loader
def query_user(user_email: str, session: Session = None) -> UserInDB:
"""
Get a user from the db
:param user_id: E-Mail of the user
:return: None or the UserInDB object
"""
session = session if session else create_session()
user = db.users.get(session, user_email, "email")
session.close()
return UserInDB(**user)

View file

@ -5,7 +5,7 @@ from db.db_setup import generate_session
from fastapi import APIRouter, Depends, HTTPException
from services.meal_services import MealPlan
from sqlalchemy.orm.session import Session
from utils.snackbar import SnackResponse
from schema.snackbar import SnackResponse
router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"])

View file

@ -2,14 +2,14 @@ import operator
import shutil
from typing import List
from app_config import MIGRATION_DIR
from core.config import MIGRATION_DIR
from db.db_setup import generate_session
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from models.migration_models import MigrationFile, Migrations
from schema.migration import MigrationFile, Migrations
from services.migrations.chowdown import chowdown_migrate as chowdow_migrate
from services.migrations.nextcloud import migrate as nextcloud_migrate
from sqlalchemy.orm.session import Session
from utils.snackbar import SnackResponse
from schema.snackbar import SnackResponse
router = APIRouter(prefix="/api/migrations", tags=["Migration"])

View file

@ -3,7 +3,7 @@ from typing import List, Optional
from db.database import db
from db.db_setup import generate_session
from fastapi import APIRouter, Depends, Query
from models.recipe_models import AllRecipeRequest
from schema.recipe import AllRecipeRequest
from slugify import slugify
from sqlalchemy.orm.session import Session

View file

@ -1,11 +1,11 @@
from db.database import db
from db.db_setup import generate_session
from fastapi import APIRouter, Depends
from models.category_models import RecipeCategoryResponse
from schema.category import RecipeCategoryResponse
from sqlalchemy.orm.session import Session
from utils.snackbar import SnackResponse
from schema.snackbar import SnackResponse
from utils.snackbar import SnackResponse
from schema.snackbar import SnackResponse
router = APIRouter(
prefix="/api/categories",
@ -27,7 +27,6 @@ def get_all_recipes_by_category(
return db.categories.get(session, category)
@router.delete("/{category}")
async def delete_recipe_category(
category: str, session: Session = Depends(generate_session)

View file

@ -2,12 +2,12 @@ from db.db_setup import generate_session
from fastapi import APIRouter, Depends, File, Form, HTTPException
from fastapi.logger import logger
from fastapi.responses import FileResponse
from models.recipe_models import RecipeURLIn
from schema.recipe import RecipeURLIn
from services.image_services import read_image, write_image
from services.recipe_services import Recipe
from services.scraper.scraper import create_from_url
from sqlalchemy.orm.session import Session
from utils.snackbar import SnackResponse
from schema.snackbar import SnackResponse
router = APIRouter(
prefix="/api/recipes",

View file

@ -2,9 +2,9 @@ from db.database import db
from db.db_setup import generate_session
from fastapi import APIRouter, Depends
from sqlalchemy.orm.session import Session
from utils.snackbar import SnackResponse
from schema.snackbar import SnackResponse
from utils.snackbar import SnackResponse
from schema.snackbar import SnackResponse
router = APIRouter(tags=["Recipes"])

View file

@ -1,11 +1,10 @@
from db.database import db
from db.db_setup import generate_session
from fastapi import APIRouter, Depends
from models.settings_models import SiteSettings
from services.settings_services import default_settings_init
from schema.settings import SiteSettings
from sqlalchemy.orm.session import Session
from utils.post_webhooks import post_webhooks
from utils.snackbar import SnackResponse
from schema.snackbar import SnackResponse
router = APIRouter(prefix="/api/site-settings", tags=["Settings"])
@ -17,8 +16,7 @@ def get_main_settings(session: Session = Depends(generate_session)):
try:
data = db.settings.get(session, "main")
except:
default_settings_init(session)
data = db.settings.get(session, "main")
return
return data

View file

@ -1,8 +1,8 @@
from db.db_setup import generate_session
from fastapi import APIRouter, Depends
from models.theme_models import SiteTheme
from schema.theme import SiteTheme
from sqlalchemy.orm.session import Session
from utils.snackbar import SnackResponse
from schema.snackbar import SnackResponse
from db.database import db
router = APIRouter(prefix="/api", tags=["Themes"])

View file

View file

@ -0,0 +1,32 @@
from datetime import timedelta
from core.security import verify_password
from db.db_setup import generate_session
from fastapi import APIRouter, Depends
from fastapi.security import OAuth2PasswordRequestForm
from fastapi_login.exceptions import InvalidCredentialsException
from routes.deps import manager, query_user
from schema.user import UserInDB
from sqlalchemy.orm.session import Session
router = APIRouter(prefix="/api/auth", tags=["Auth"])
@router.post("/token")
def token(
data: OAuth2PasswordRequestForm = Depends(),
session: Session = Depends(generate_session),
):
email = data.username
password = data.password
user: UserInDB = query_user(email, session)
if not user:
raise InvalidCredentialsException # you can also use your own HTTPException
elif not verify_password(password, user.password):
raise InvalidCredentialsException
access_token = manager.create_access_token(
data=dict(sub=email), expires=timedelta(hours=2)
)
return {"access_token": access_token, "token_type": "bearer"}

View file

@ -0,0 +1,85 @@
from datetime import timedelta
from core.security import get_password_hash
from db.database import db
from db.db_setup import generate_session
from fastapi import APIRouter, Depends
from routes.deps import manager, query_user
from schema.user import UserBase, UserIn, UserInDB, UserOut
from sqlalchemy.orm.session import Session
router = APIRouter(prefix="/api/users", tags=["Users"])
@router.post("", response_model=UserOut, status_code=201)
async def create_user(
new_user: UserIn,
current_user=Depends(manager),
session: Session = Depends(generate_session),
):
""" Returns a list of all user in the Database """
new_user.password = get_password_hash(new_user.password)
data = db.users.create(session, new_user.dict())
return data
@router.get("", response_model=list[UserOut])
async def get_all_users(
current_user: UserInDB = Depends(manager),
session: Session = Depends(generate_session),
):
if current_user.admin:
return db.users.get_all(session)
else:
return {"details": "user not authorized"}
@router.get("/self", response_model=UserOut)
async def get_user_by_id(
current_user: UserInDB = Depends(manager),
session: Session = Depends(generate_session),
):
return current_user.dict()
@router.get("/{id}", response_model=UserOut)
async def get_user_by_id(
id: int,
current_user: UserInDB = Depends(manager),
session: Session = Depends(generate_session),
):
return db.users.get(session, id)
@router.put("/{id}")
async def update_user(
id: int,
new_data: UserBase,
current_user: UserInDB = Depends(manager),
session: Session = Depends(generate_session),
):
if current_user.id == id or current_user.admin:
updated_user = db.users.update(session, id, new_data.dict())
email = updated_user.get("email")
if current_user.id == id:
access_token = manager.create_access_token(
data=dict(sub=email), expires=timedelta(hours=2)
)
return {"access_token": access_token, "token_type": "bearer"}
return
@router.delete("/{id}")
async def delete_user(
id: int,
current_user: UserInDB = Depends(manager),
session: Session = Depends(generate_session),
):
""" Removes a user from the database. Must be the current user or a super user"""
if current_user.id == id or current_user.admin:
return db.users.delete(session, id)

View file

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

32
mealie/schema/user.py Normal file
View file

@ -0,0 +1,32 @@
from typing import Optional
from fastapi_camelcase import CamelModel
# from pydantic import EmailStr
class UserBase(CamelModel):
full_name: Optional[str] = None
email: str
family: str
admin: bool
class Config:
schema_extra = {
"fullName": "Change Me",
"email": "changeme@email.com",
"family": "public",
"admin": "false",
}
class UserIn(UserBase):
password: str
class UserOut(UserBase):
id: int
class UserInDB(UserIn, UserOut):
pass

View file

@ -3,13 +3,13 @@ import shutil
from datetime import datetime
from pathlib import Path
from app_config import BACKUP_DIR, IMG_DIR, TEMP_DIR, TEMPLATE_DIR
from core.config import BACKUP_DIR, IMG_DIR, TEMP_DIR, TEMPLATE_DIR
from db.database import db
from db.db_setup import create_session
from jinja2 import Template
from services.meal_services import MealPlan
from services.recipe_services import Recipe
from utils.logger import logger
from fastapi.logger import logger
class ExportDatabase:

View file

@ -4,13 +4,13 @@ import zipfile
from pathlib import Path
from typing import List
from app_config import BACKUP_DIR, IMG_DIR, TEMP_DIR
from core.config import BACKUP_DIR, IMG_DIR, TEMP_DIR
from db.database import db
from models.import_models import RecipeImport, SettingsImport, ThemeImport
from models.theme_models import SiteTheme
from schema.restore import RecipeImport, SettingsImport, ThemeImport
from schema.theme import SiteTheme
from services.recipe_services import Recipe
from sqlalchemy.orm.session import Session
from utils.logger import logger
from fastapi.logger import logger
class ImportDatabase:

View file

@ -2,8 +2,8 @@ import shutil
from pathlib import Path
import requests
from app_config import IMG_DIR
from utils.logger import logger
from core.config import IMG_DIR
from fastapi.logger import logger
def read_image(recipe_slug: str) -> Path:

Some files were not shown because too many files have changed in this diff Show more