From 9e23587c0dc9cde0901092e1819041496a783bec Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Sun, 28 Mar 2021 11:52:34 -0800 Subject: [PATCH] Feature/site settings (#225) * general cleanup * submit on enter * fix signup form * fix duplicate slugs when testing * custom pages starter * api start * functional * docs * fix page reload on submit * set initial selection for category button * Site Pages Import/Export * documentation updates * generate .secret file for jwt hashing * properly align default passwords * set default password globally * set group for signups Co-authored-by: hay-kot --- .gitignore | 3 +- docs/docs/changelog/v0.4.0.md | 7 +- docs/docs/getting-started/install.md | 15 +- .../backups-and-exports.md | 1 + .../site-administration/building-pages.md | 8 ++ docs/mkdocs.yml | 1 + frontend/src/App.vue | 3 +- frontend/src/api/category.js | 2 +- frontend/src/api/mealplan.js | 1 - frontend/src/api/siteSettings.js | 32 +++++ .../components/Admin/Backup/ImportOptions.vue | 5 + .../Backup/ImportSummaryDialog/index.vue | 49 +++---- .../Admin/General/CreatePageDialog.vue | 98 +++++++++++++ .../Admin/General/CustomPageCreator.vue | 129 ++++++++++++++++++ .../Admin/General/HomePageSettings.vue | 7 +- .../Admin/ManageUsers/GroupDashboard.vue | 4 +- .../Admin/ManageUsers/TheSignUpTable.vue | 4 +- .../Admin/ManageUsers/TheUserTable.vue | 4 +- .../components/Admin/Theme/NewThemeDialog.vue | 4 +- .../FormHelpers/CategorySelector.vue | 50 +++++++ frontend/src/components/Login/LoginForm.vue | 6 +- frontend/src/components/Login/SignUpForm.vue | 3 +- frontend/src/components/UI/AddRecipeFab.vue | 9 +- frontend/src/components/UI/CardSection.vue | 99 ++++++++------ .../src/components/UI/CategorySidebar.vue | 10 +- frontend/src/pages/Admin/Settings/index.vue | 5 + frontend/src/pages/HomePage.vue | 23 ++-- frontend/src/pages/Recipes/CustomPage.vue | 95 +++++++++++++ frontend/src/routes/index.js | 2 + frontend/src/store/index.js | 13 +- frontend/src/store/modules/siteSettings.js | 2 +- mealie/app.py | 5 +- mealie/core/config.py | 28 +++- mealie/core/security.py | 3 +- mealie/db/database.py | 12 +- mealie/db/db_base.py | 24 ++-- mealie/db/init_db.py | 7 +- mealie/db/models/group.py | 10 +- mealie/db/models/recipe/category.py | 9 +- mealie/db/models/settings.py | 26 +++- mealie/routes/backup_routes.py | 11 +- .../__init__.py} | 0 mealie/routes/site_settings/all_settings.py | 7 + mealie/routes/site_settings/custom_pages.py | 75 ++++++++++ .../site_settings.py} | 4 +- mealie/schema/backup.py | 1 + mealie/schema/recipe.py | 7 +- mealie/schema/restore.py | 4 + mealie/schema/settings.py | 30 +++- mealie/services/backups/exports.py | 14 +- mealie/services/backups/imports.py | 28 +++- tests/conftest.py | 4 +- 52 files changed, 807 insertions(+), 196 deletions(-) create mode 100644 docs/docs/site-administration/building-pages.md create mode 100644 frontend/src/components/Admin/General/CreatePageDialog.vue create mode 100644 frontend/src/components/Admin/General/CustomPageCreator.vue create mode 100644 frontend/src/components/FormHelpers/CategorySelector.vue create mode 100644 frontend/src/pages/Recipes/CustomPage.vue rename mealie/routes/{users/__init__ copy.py => site_settings/__init__.py} (100%) create mode 100644 mealie/routes/site_settings/all_settings.py create mode 100644 mealie/routes/site_settings/custom_pages.py rename mealie/routes/{setting_routes.py => site_settings/site_settings.py} (95%) diff --git a/.gitignore b/.gitignore index 4fcab0443..8eadb1fb9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Byte-compiled / optimized / DLL files .env -__pycache__/ +*__pycache__/ *.py[cod] *$py.class # frontend/.env.development @@ -8,6 +8,7 @@ docs/site/ mealie/temp/* mealie/temp/api.html .temp/ +.secret dev/data/backups/* diff --git a/docs/docs/changelog/v0.4.0.md b/docs/docs/changelog/v0.4.0.md index 8a2ce3d11..b8b1457d6 100644 --- a/docs/docs/changelog/v0.4.0.md +++ b/docs/docs/changelog/v0.4.0.md @@ -12,7 +12,7 @@ A new database will be created. You must export your data and then import it after upgrading. #### Site Settings - With the addition of group settings and a re-write of the database layer of the application backend, there is no migration path for your current site settings. Webhooks Settings, Meal Plan Categories are now managed by groups. Site settings, mainly homepage settings, are not site specific and managed by administrators. + With the addition of group settings and a re-write of the database layer of the application backend, there is no migration path for your current site settings. Webhooks Settings, Meal Plan Categories are now managed by groups. Site settings, mainly homepage settings, are now site specific and managed by administrators. #### ENV Variables Names have been changed to be more consistent with industry standards. See the [Installation Page](/getting-started/install/) for new parameters. @@ -49,6 +49,11 @@ - Group Management - Create/Edit/Delete Restrictions +### Custom Pages + - You can now create custom pages that are displayed on the homepage sidebar to organize categories of recipes into pages. For example, if you have several categories that encompass "Entrée" you can group all those categories together under the "Entrée" page. See [Building Pages](/site-administration/building-pages/) for more information. +!!! tip + Note that this replaces the behavior of automatically adding categories to the sidebar. + ### UI Improvements - Completed Redesign of the Admin Panel - Profile Pages diff --git a/docs/docs/getting-started/install.md b/docs/docs/getting-started/install.md index 6cc9fcfa1..0b2378aa7 100644 --- a/docs/docs/getting-started/install.md +++ b/docs/docs/getting-started/install.md @@ -45,13 +45,14 @@ services: ## Env Variables -| Variables | Default | Description | -| ---------------- | -------- | ----------------------------------------------------------------------------------- | -| DB_TYPE | sqlite | The database type to be used. Current Options 'sqlite' | -| API_PORT | 9000 | The port exposed by backend API. **do not change this if you're running in docker** | -| API_DOCS | True | Turns on/off access to the API documentation locally. | -| DEFAULT_PASSWORD | ChangeMe | The default password for all users created in Mealie | -| TZ | UTC | Must be set to get correct date/time on the server | +| Variables | Default | Description | +| ---------------- | ---------- | ----------------------------------------------------------------------------------- | +| DB_TYPE | sqlite | The database type to be used. Current Options 'sqlite' | +| DEFAULT_GROUP | Home | The default group for users | +| DEFAULT_PASSWORD | MyPassword | The default password for all users created in Mealie | +| API_PORT | 9000 | The port exposed by backend API. **do not change this if you're running in docker** | +| API_DOCS | True | Turns on/off access to the API documentation locally. | +| TZ | UTC | Must be set to get correct date/time on the server | ## Deployed as a Python Application diff --git a/docs/docs/site-administration/backups-and-exports.md b/docs/docs/site-administration/backups-and-exports.md index 5ee16d2cd..24f9762f2 100644 --- a/docs/docs/site-administration/backups-and-exports.md +++ b/docs/docs/site-administration/backups-and-exports.md @@ -9,6 +9,7 @@ All recipe data can be imported and exported as necessary from the UI. Under the - [x] Recipe Data - [ ] Meal Plan - [x] Site Settings + - [x] Custom Pages - [x] User Data - [x] Group Data diff --git a/docs/docs/site-administration/building-pages.md b/docs/docs/site-administration/building-pages.md new file mode 100644 index 000000000..77c525ad4 --- /dev/null +++ b/docs/docs/site-administration/building-pages.md @@ -0,0 +1,8 @@ +# Building Pages + +Custom pages can be created to organize multiple categories into a single page. Links to your custom pages are displayed on the home page sidebar and accessible by all users, however only Administrators can create or update pages. + +To create a new page. Navigate to the settings page at `/admin/settings` and scroll down to the custom pages section. Here you can create, view, and edit your custom pages. To reorder how they are displayed on the sidebar you can drag and drop the pages into the preferred order. + +!!! tip + To save the order of pages you must click the save button displayed on the bottom of the Custom Page section. This is not necessary for updating individual page data. \ No newline at end of file diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 62f8c7453..e61268a5e 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -45,6 +45,7 @@ nav: - Site Administration: - User Settings: "site-administration/user-settings.md" - Site Settings: "site-administration/site-settings.md" + - Building Pages: "site-administration/building-pages.md" - User Management: "site-administration/user-management.md" - Backups and Restore: "site-administration/backups-and-exports.md" - Recipe Migration: "site-administration/migration-imports.md" diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4698f055a..3584609a3 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -76,10 +76,9 @@ export default { mounted() { this.$store.dispatch("initTheme"); this.$store.dispatch("requestRecentRecipes"); - this.$store.dispatch("requestHomePageSettings"); - this.$store.dispatch("requestSiteSettings"); this.$store.dispatch("refreshToken"); this.$store.dispatch("requestCurrentGroup"); + this.$store.dispatch("requestCategories"); this.darkModeSystemCheck(); this.darkModeAddEventListener(); }, diff --git a/frontend/src/api/category.js b/frontend/src/api/category.js index 22054058b..6855a822b 100644 --- a/frontend/src/api/category.js +++ b/frontend/src/api/category.js @@ -10,7 +10,7 @@ const categoryURLs = { }; export default { - async get_all() { + async getAll() { let response = await apiReq.get(categoryURLs.get_all); return response.data; }, diff --git a/frontend/src/api/mealplan.js b/frontend/src/api/mealplan.js index 4de5d7a85..0271f51de 100644 --- a/frontend/src/api/mealplan.js +++ b/frontend/src/api/mealplan.js @@ -16,7 +16,6 @@ const mealPlanURLs = { export default { async create(postBody) { - console.log(postBody); let response = await apiReq.post(mealPlanURLs.create, postBody); return response; }, diff --git a/frontend/src/api/siteSettings.js b/frontend/src/api/siteSettings.js index 1a3e73cb5..fd8736bc3 100644 --- a/frontend/src/api/siteSettings.js +++ b/frontend/src/api/siteSettings.js @@ -7,6 +7,8 @@ const settingsURLs = { siteSettings: `${settingsBase}`, updateSiteSettings: `${settingsBase}`, testWebhooks: `${settingsBase}/webhooks/test`, + customPages: `${settingsBase}/custom-pages`, + customPage: id => `${settingsBase}/custom-pages/${id}`, }; export default { @@ -19,4 +21,34 @@ export default { let response = await apiReq.put(settingsURLs.updateSiteSettings, body); return response.data; }, + + async getPages() { + let response = await apiReq.get(settingsURLs.customPages); + return response.data; + }, + + async getPage(id) { + let response = await apiReq.get(settingsURLs.customPage(id)); + return response.data; + }, + + async createPage(body) { + let response = await apiReq.post(settingsURLs.customPages, body); + return response.data; + }, + + async deletePage(id) { + let response = await apiReq.delete(settingsURLs.customPage(id)); + return response.data; + }, + + async updatePage(body) { + let response = await apiReq.put(settingsURLs.customPage(body.id), body); + return response.data; + }, + + async updateAllPages(allPages) { + let response = await apiReq.put(settingsURLs.customPages, allPages); + return response; + }, }; diff --git a/frontend/src/components/Admin/Backup/ImportOptions.vue b/frontend/src/components/Admin/Backup/ImportOptions.vue index 4db4f2119..9228f20d9 100644 --- a/frontend/src/components/Admin/Backup/ImportOptions.vue +++ b/frontend/src/components/Admin/Backup/ImportOptions.vue @@ -26,6 +26,10 @@ export default { value: true, text: this.$t("general.settings"), }, + pages: { + value: true, + text: "Pages", + }, themes: { value: true, text: this.$t("general.themes"), @@ -50,6 +54,7 @@ export default { recipes: this.options.recipes.value, settings: this.options.settings.value, themes: this.options.themes.value, + pages: this.options.pages.value, users: this.options.users.value, groups: this.options.groups.value, }); diff --git a/frontend/src/components/Admin/Backup/ImportSummaryDialog/index.vue b/frontend/src/components/Admin/Backup/ImportSummaryDialog/index.vue index a0bdfb9ae..8714c6fea 100644 --- a/frontend/src/components/Admin/Backup/ImportSummaryDialog/index.vue +++ b/frontend/src/components/Admin/Backup/ImportSummaryDialog/index.vue @@ -28,38 +28,14 @@ {{ $t("general.recipes") }} {{ $t("general.themes") }} {{ $t("general.settings") }} + Pages {{ $t("general.users") }} {{ $t("general.groups") }} - + - - - - - - - - - - - - - - - - + @@ -82,6 +58,7 @@ export default { settingsData: [], userData: [], groupData: [], + pageData: [], importHeaders: [ { text: "Status", @@ -118,15 +95,29 @@ export default { groupNumbers() { return this.calculateNumbers(this.$t("general.groups"), this.groupData); }, + pageNumbers() { + return this.calculateNumbers("Pages", this.pageData); + }, allNumbers() { return [ this.recipeNumbers, - this.settingsNumbers, this.themeNumbers, + this.settingsNumbers, + this.pageNumbers, this.userNumbers, this.groupNumbers, ]; }, + allTables() { + return [ + this.recipeData, + this.themeData, + this.settingsData, + this.pageData, + this.userData, + this.groupData, + ]; + }, }, methods: { @@ -141,12 +132,12 @@ export default { return numbers; }, open(importData) { - console.log(importData); this.recipeData = importData.recipeImports; this.themeData = importData.themeImports; this.settingsData = importData.settingsImports; this.userData = importData.userImports; this.groupData = importData.groupImports; + this.pageData = importData.pageImports; this.dialog = true; }, }, diff --git a/frontend/src/components/Admin/General/CreatePageDialog.vue b/frontend/src/components/Admin/General/CreatePageDialog.vue new file mode 100644 index 000000000..a6eff9709 --- /dev/null +++ b/frontend/src/components/Admin/General/CreatePageDialog.vue @@ -0,0 +1,98 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/Admin/General/CustomPageCreator.vue b/frontend/src/components/Admin/General/CustomPageCreator.vue new file mode 100644 index 000000000..7bd15dbb6 --- /dev/null +++ b/frontend/src/components/Admin/General/CustomPageCreator.vue @@ -0,0 +1,129 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/Admin/General/HomePageSettings.vue b/frontend/src/components/Admin/General/HomePageSettings.vue index 30a2b2c18..eeb956dc3 100644 --- a/frontend/src/components/Admin/General/HomePageSettings.vue +++ b/frontend/src/components/Admin/General/HomePageSettings.vue @@ -38,7 +38,7 @@ - {{$t('settings.homepage.home-page-sections')}} + {{ $t("settings.homepage.home-page-sections") }} @@ -80,7 +80,7 @@ - {{$t('settings.homepage.all-categories')}} + {{ $t("settings.homepage.all-categories") }} @@ -153,13 +153,12 @@ export default { }, computed: { allCategories() { - return this.$store.getters.getCategories; + return this.$store.getters.getAllCategories; }, }, methods: { writeLang(val) { - console.log(val); this.settings.language = val; }, deleteCategoryfromDatabase(category) { diff --git a/frontend/src/components/Admin/ManageUsers/GroupDashboard.vue b/frontend/src/components/Admin/ManageUsers/GroupDashboard.vue index 4407c750a..238e48f83 100644 --- a/frontend/src/components/Admin/ManageUsers/GroupDashboard.vue +++ b/frontend/src/components/Admin/ManageUsers/GroupDashboard.vue @@ -39,7 +39,7 @@ - + {{ $t("general.cancel") }} - + {{ $t("general.create") }} diff --git a/frontend/src/components/Admin/ManageUsers/TheSignUpTable.vue b/frontend/src/components/Admin/ManageUsers/TheSignUpTable.vue index b44368cf7..36c1a93d9 100644 --- a/frontend/src/components/Admin/ManageUsers/TheSignUpTable.vue +++ b/frontend/src/components/Admin/ManageUsers/TheSignUpTable.vue @@ -40,7 +40,7 @@ - + {{ $t("general.cancel") }} - + {{ $t("general.save") }} diff --git a/frontend/src/components/Admin/ManageUsers/TheUserTable.vue b/frontend/src/components/Admin/ManageUsers/TheUserTable.vue index ae51ba232..bad882719 100644 --- a/frontend/src/components/Admin/ManageUsers/TheUserTable.vue +++ b/frontend/src/components/Admin/ManageUsers/TheUserTable.vue @@ -48,7 +48,7 @@ {{ $t("user.user-id-with-value", { id: editedItem.id }) }} - + @@ -97,7 +97,7 @@ {{ $t("general.cancel") }} - + {{ $t("general.save") }} diff --git a/frontend/src/components/Admin/Theme/NewThemeDialog.vue b/frontend/src/components/Admin/Theme/NewThemeDialog.vue index aff8b0bb2..8fc830066 100644 --- a/frontend/src/components/Admin/Theme/NewThemeDialog.vue +++ b/frontend/src/components/Admin/Theme/NewThemeDialog.vue @@ -17,7 +17,7 @@ - + {{ $t("general.cancel") }} - + {{ $t("general.create") }} diff --git a/frontend/src/components/FormHelpers/CategorySelector.vue b/frontend/src/components/FormHelpers/CategorySelector.vue new file mode 100644 index 000000000..390bd60d6 --- /dev/null +++ b/frontend/src/components/FormHelpers/CategorySelector.vue @@ -0,0 +1,50 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/Login/LoginForm.vue b/frontend/src/components/Login/LoginForm.vue index 108219f96..2b8bcb3c0 100644 --- a/frontend/src/components/Login/LoginForm.vue +++ b/frontend/src/components/Login/LoginForm.vue @@ -17,7 +17,7 @@ - + {{ $t("user.sign-in") }} - + diff --git a/frontend/src/components/Login/SignUpForm.vue b/frontend/src/components/Login/SignUpForm.vue index a7fc0e962..63b699f65 100644 --- a/frontend/src/components/Login/SignUpForm.vue +++ b/frontend/src/components/Login/SignUpForm.vue @@ -21,7 +21,7 @@ have a valid invitation link. If you haven't recieved an invitation you are unable to sign-up. To recieve a link, contact the sites administrator. - + - + {{ $t("general.close") }} - + {{ $t("general.submit") }} diff --git a/frontend/src/components/UI/CardSection.vue b/frontend/src/components/UI/CardSection.vue index 078e2b83b..82ff6c141 100644 --- a/frontend/src/components/UI/CardSection.vue +++ b/frontend/src/components/UI/CardSection.vue @@ -1,8 +1,8 @@ @@ -84,10 +91,12 @@ export default { sortable: { default: false, }, - title: String, + title: { + default: null, + }, recipes: Array, cardLimit: { - default: 6, + default: 999, }, }, computed: { diff --git a/frontend/src/components/UI/CategorySidebar.vue b/frontend/src/components/UI/CategorySidebar.vue index 8c8337bc1..390a03c56 100644 --- a/frontend/src/components/UI/CategorySidebar.vue +++ b/frontend/src/components/UI/CategorySidebar.vue @@ -33,6 +33,7 @@ diff --git a/frontend/src/pages/HomePage.vue b/frontend/src/pages/HomePage.vue index ffe054190..322c612d5 100644 --- a/frontend/src/pages/HomePage.vue +++ b/frontend/src/pages/HomePage.vue @@ -2,10 +2,10 @@ @@ -35,14 +35,9 @@ export default { }; }, computed: { - showRecent() { - return this.$store.getters.getShowRecent; - }, - showLimit() { - return this.$store.getters.getShowLimit; - }, - homeCategories() { - return this.$store.getters.getHomeCategories; + siteSettings() { + console.log(this.$store.getters.getSiteSettings); + return this.$store.getters.getSiteSettings; }, recentRecipes() { let recipes = this.$store.getters.getRecentRecipes; @@ -55,9 +50,11 @@ export default { }, methods: { async buildPage() { - this.homeCategories.forEach(async element => { + await this.$store.dispatch("requestSiteSettings"); + this.siteSettings.categories.forEach(async element => { let recipes = await this.getRecipeByCategory(element.slug); - recipes.position = element.position; + if (recipes.recipes.length < 0 ) recipes.recipes = [] + console.log(recipes) this.recipeByCategory.push(recipes); }); }, diff --git a/frontend/src/pages/Recipes/CustomPage.vue b/frontend/src/pages/Recipes/CustomPage.vue new file mode 100644 index 000000000..45fe6993a --- /dev/null +++ b/frontend/src/pages/Recipes/CustomPage.vue @@ -0,0 +1,95 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/routes/index.js b/frontend/src/routes/index.js index df94b1512..73cd7bcfb 100644 --- a/frontend/src/routes/index.js +++ b/frontend/src/routes/index.js @@ -3,6 +3,7 @@ import Page404 from "@/pages/404Page"; import SearchPage from "@/pages/SearchPage"; import ViewRecipe from "@/pages/Recipe/ViewRecipe"; import NewRecipe from "@/pages/Recipe/NewRecipe"; +import CustomPage from "@/pages/Recipes/CustomPage"; import AllRecipes from "@/pages/Recipes/AllRecipes"; import CategoryPage from "@/pages/Recipes/CategoryPage"; import Planner from "@/pages/MealPlan/Planner"; @@ -31,6 +32,7 @@ export const routes = [ { path: "/debug", component: Debug }, { path: "/search", component: SearchPage }, { path: "/recipes/all", component: AllRecipes }, + { path: "/pages/:customPage", component: CustomPage }, { path: "/recipes/:category", component: CategoryPage }, { path: "/recipe/:recipe", component: ViewRecipe }, { path: "/new/", component: NewRecipe }, diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index b6bbc277c..3ee9a449a 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -4,7 +4,6 @@ import api from "@/api"; import createPersistedState from "vuex-persistedstate"; import userSettings from "./modules/userSettings"; import language from "./modules/language"; -import homePage from "./modules/homePage"; import siteSettings from "./modules/siteSettings"; import groups from "./modules/groups"; @@ -13,13 +12,12 @@ Vue.use(Vuex); const store = new Vuex.Store({ plugins: [ createPersistedState({ - paths: ["userSettings", "language", "homePage", "SideSettings"], + paths: ["userSettings", "language", "SideSettings"], }), ], modules: { userSettings, language, - homePage, siteSettings, groups, }, @@ -28,6 +26,7 @@ const store = new Vuex.Store({ recentRecipes: [], allRecipes: [], mealPlanCategories: [], + allCategories: [], }, mutations: { @@ -38,6 +37,9 @@ const store = new Vuex.Store({ setMealPlanCategories(state, payload) { state.mealPlanCategories = payload; }, + setAllCategories(state, payload) { + state.allCategories = payload; + }, }, actions: { @@ -54,11 +56,16 @@ const store = new Vuex.Store({ this.commit("setRecentRecipes", payload); }, + async requestCategories({ commit }) { + const categories = await api.categories.getAll(); + commit("setAllCategories", categories); + }, }, getters: { getRecentRecipes: state => state.recentRecipes, getMealPlanCategories: state => state.mealPlanCategories, + getAllCategories: state => state.allCategories, }, }); diff --git a/frontend/src/store/modules/siteSettings.js b/frontend/src/store/modules/siteSettings.js index d8357fa9b..1495b4c72 100644 --- a/frontend/src/store/modules/siteSettings.js +++ b/frontend/src/store/modules/siteSettings.js @@ -11,7 +11,7 @@ const state = { const mutations = { setSettings(state, payload) { - state.settings = payload; + state.siteSettings = payload; }, }; diff --git a/mealie/app.py b/mealie/app.py index f4faeafd1..89ee63842 100644 --- a/mealie/app.py +++ b/mealie/app.py @@ -4,7 +4,8 @@ from fastapi.logger import logger # import utils.startup as startup from mealie.core.config import APP_VERSION, PORT, docs_url, redoc_url -from mealie.routes import backup_routes, debug_routes, migration_routes, setting_routes, theme_routes +from mealie.routes import backup_routes, debug_routes, migration_routes, theme_routes +from mealie.routes.site_settings import all_settings from mealie.routes.groups import groups from mealie.routes.mealplans import mealplans from mealie.routes.recipe import all_recipe_routes, category_routes, recipe_crud_routes, tag_routes @@ -36,7 +37,7 @@ def api_routers(): # Meal Routes app.include_router(mealplans.router) # Settings Routes - app.include_router(setting_routes.router) + app.include_router(all_settings.router) app.include_router(theme_routes.router) # Backups/Imports Routes app.include_router(backup_routes.router) diff --git a/mealie/core/config.py b/mealie/core/config.py index 1f99bc0e5..fdaaa6e77 100644 --- a/mealie/core/config.py +++ b/mealie/core/config.py @@ -1,4 +1,5 @@ import os +import secrets from pathlib import Path import dotenv @@ -17,12 +18,10 @@ def ensure_dirs(): # Register ENV ENV = CWD.joinpath(".env") #! I'm Broken Fix Me! dotenv.load_dotenv(ENV) +PRODUCTION = os.environ.get("ENV") -SECRET = "test-secret-shhh" - # General -PRODUCTION = os.environ.get("ENV") PORT = int(os.getenv("mealie_port", 9000)) API = os.getenv("api_docs", True) @@ -72,7 +71,7 @@ LOGGER_FILE = DATA_DIR.joinpath("mealie.log") # DATABASE ENV SQLITE_FILE = None -DATABASE_TYPE = os.getenv("db_type", "sqlite") +DATABASE_TYPE = os.getenv("DB_TYPE", "sqlite") if DATABASE_TYPE == "sqlite": USE_SQL = True SQLITE_FILE = SQLITE_DIR.joinpath(f"mealie_{DB_VERSION}.sqlite") @@ -80,9 +79,28 @@ if DATABASE_TYPE == "sqlite": else: raise Exception("Unable to determine database type. Acceptible options are 'sqlite' ") + +def determine_secrets() -> str: + if not PRODUCTION: + return "shh-secret-test-key" + + secrets_file = DATA_DIR.joinpath(".secret") + if secrets_file.is_file(): + with open(secrets_file, "r") as f: + return f.read() + else: + with open(secrets_file, "w") as f: + f.write(secrets.token_hex(32)) + + +SECRET = "determine_secrets()" + # Mongo Database +DEFAULT_GROUP = os.getenv("DEFAULT_GROUP", "Home") +DEFAULT_PASSWORD = os.getenv("DEFAULT_PASSWORD", "MyPassword") + +# Database MEALIE_DB_NAME = os.getenv("mealie_db_name", "mealie") -DEFAULT_GROUP = os.getenv("default_group", "Home") DB_USERNAME = os.getenv("db_username", "root") DB_PASSWORD = os.getenv("db_password", "example") DB_HOST = os.getenv("db_host", "mongo") diff --git a/mealie/core/security.py b/mealie/core/security.py index 2091e024e..c4380cb25 100644 --- a/mealie/core/security.py +++ b/mealie/core/security.py @@ -17,8 +17,7 @@ def create_access_token(data: dict(), expires_delta: timedelta = None) -> str: else: expire = datetime.utcnow() + timedelta(minutes=120) to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, SECRET, algorithm=ALGORITHM) - return encoded_jwt + return jwt.encode(to_encode, SECRET, algorithm=ALGORITHM) def authenticate_user(session, email: str, password: str) -> UserInDB: diff --git a/mealie/db/database.py b/mealie/db/database.py index a519ed309..5a1a66343 100644 --- a/mealie/db/database.py +++ b/mealie/db/database.py @@ -2,14 +2,14 @@ from mealie.db.db_base import BaseDocument from mealie.db.models.group import Group from mealie.db.models.mealplan import MealPlanModel from mealie.db.models.recipe.recipe import Category, RecipeModel, Tag -from mealie.db.models.settings import SiteSettings +from mealie.db.models.settings import CustomPage, SiteSettings from mealie.db.models.sign_up import SignUp from mealie.db.models.theme import SiteThemeModel from mealie.db.models.users import User from mealie.schema.category import RecipeCategoryResponse, RecipeTagResponse from mealie.schema.meal import MealPlanInDB from mealie.schema.recipe import Recipe -from mealie.schema.settings import SiteSettings as SiteSettingsSchema +from mealie.schema.settings import CustomPageOut, SiteSettings as SiteSettingsSchema from mealie.schema.sign_up import SignUpOut from mealie.schema.theme import SiteTheme from mealie.schema.user import GroupInDB, UserInDB @@ -118,6 +118,13 @@ class _SignUps(BaseDocument): self.orm_mode = True self.schema = SignUpOut +class _CustomPages(BaseDocument): + def __init__(self) -> None: + self.primary_key = "id" + self.sql_model = CustomPage + self.orm_mode = True + self.schema = CustomPageOut + class Database: def __init__(self) -> None: @@ -130,6 +137,7 @@ class Database: self.users = _Users() self.sign_ups = _SignUps() self.groups = _Groups() + self.custom_pages = _CustomPages() db = Database() diff --git a/mealie/db/db_base.py b/mealie/db/db_base.py index 68c06dbeb..c1a302f49 100644 --- a/mealie/db/db_base.py +++ b/mealie/db/db_base.py @@ -40,9 +40,12 @@ class BaseDocument: Returns: list[SqlAlchemyBase]: Returns a list of ORM objects """ - results = session.query(self.sql_model).options(load_only(*fields)).limit(limit).all() - - return results + return ( + session.query(self.sql_model) + .options(load_only(*fields)) + .limit(limit) + .all() + ) def get_all_primary_keys(self, session: Session) -> List[str]: """Queries the database of the selected model and returns a list @@ -69,12 +72,14 @@ class BaseDocument: Returns: Union[Session, SqlAlchemyBase]: Will return both the session and found model """ - if match_key == None: + if match_key is None: match_key = self.primary_key - result = session.query(self.sql_model).filter_by(**{match_key: match_value}).one() - - return result + return ( + session.query(self.sql_model) + .filter_by(**{match_key: match_value}) + .one() + ) def get(self, session: Session, match_value: str, match_key: str = None, limit=1) -> BaseModel or List[BaseModel]: """Retrieves an entry from the database by matching a key/value pair. If no @@ -89,7 +94,7 @@ class BaseDocument: Returns: dict or list[dict]: """ - if match_key == None: + if match_key is None: match_key = self.primary_key result = session.query(self.sql_model).filter_by(**{match_key: match_value}).limit(limit).all() @@ -118,8 +123,7 @@ class BaseDocument: if self.orm_mode: return self.schema.from_orm(new_document) - return_data = new_document.dict() - return return_data + return new_document.dict() def update(self, session: Session, match_value: str, new_data: str) -> BaseModel: """Update a database entry. diff --git a/mealie/db/init_db.py b/mealie/db/init_db.py index 6108d60fd..a1737c622 100644 --- a/mealie/db/init_db.py +++ b/mealie/db/init_db.py @@ -1,5 +1,5 @@ from fastapi.logger import logger -from mealie.core.config import DEFAULT_GROUP +from mealie.core.config import DEFAULT_GROUP, DEFAULT_PASSWORD from mealie.core.security import get_password_hash from mealie.db.database import db from mealie.db.db_setup import create_session, sql_exists @@ -40,14 +40,13 @@ def default_group_init(session: Session): default_group = {"name": DEFAULT_GROUP} logger.info("Generating Default Group") db.groups.create(session, default_group) - pass def default_user_init(session: Session): default_user = { "full_name": "Change Me", "email": "changeme@email.com", - "password": get_password_hash("MyPassword"), + "password": get_password_hash(DEFAULT_PASSWORD), "group": DEFAULT_GROUP, "admin": True, } @@ -62,4 +61,4 @@ if __name__ == "__main__": exit() else: print("Database Doesn't Exists, Initializing...") - init_db() \ No newline at end of file + init_db() diff --git a/mealie/db/models/group.py b/mealie/db/models/group.py index 07b7763f5..2f5760ba1 100644 --- a/mealie/db/models/group.py +++ b/mealie/db/models/group.py @@ -57,12 +57,10 @@ class Group(SqlAlchemyBase, BaseMixins): @staticmethod def get_ref(session: Session, name: str): - item = session.query(Group).filter(Group.name == name).one() - if item: - return item - - else: - return session.query(Group).filter(Group.id == 1).one() + item = session.query(Group).filter(Group.name == name).one_or_none() + if item is None: + item = session.query(Group).filter(Group.id == 1).one() + return item @staticmethod def create_if_not_exist(session, name: str = DEFAULT_GROUP): diff --git a/mealie/db/models/recipe/category.py b/mealie/db/models/recipe/category.py index e954bc02b..a4ee7b0b3 100644 --- a/mealie/db/models/recipe/category.py +++ b/mealie/db/models/recipe/category.py @@ -26,6 +26,13 @@ recipes2categories = sa.Table( sa.Column("category_slug", sa.String, sa.ForeignKey("categories.slug")), ) +custom_pages2categories = sa.Table( + "custom_pages2categories", + SqlAlchemyBase.metadata, + sa.Column("custom_page_id", sa.Integer, sa.ForeignKey("custom_pages.id")), + sa.Column("category_slug", sa.String, sa.ForeignKey("categories.slug")), +) + class Category(SqlAlchemyBase): __tablename__ = "categories" @@ -36,7 +43,7 @@ class Category(SqlAlchemyBase): @validates("name") def validate_name(self, key, name): - assert not name == "" + assert name != "" return name def __init__(self, name) -> None: diff --git a/mealie/db/models/settings.py b/mealie/db/models/settings.py index b26e80ed1..cc40dd787 100644 --- a/mealie/db/models/settings.py +++ b/mealie/db/models/settings.py @@ -1,7 +1,7 @@ import sqlalchemy as sa import sqlalchemy.orm as orm from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase -from mealie.db.models.recipe.category import Category, site_settings2categories +from mealie.db.models.recipe.category import Category, custom_pages2categories, site_settings2categories from sqlalchemy.orm import Session @@ -29,7 +29,29 @@ class SiteSettings(SqlAlchemyBase, BaseMixins): self.language = language self.cards_per_section = cards_per_section self.show_recent = show_recent - self.categories = [Category.get_ref(session=session, name=cat.get("slug")) for cat in categories] + self.categories = [Category.get_ref(session=session, slug=cat.get("slug")) for cat in categories] + + def update(self, *args, **kwarg): + self.__init__(*args, **kwarg) + + +class CustomPage(SqlAlchemyBase, BaseMixins): + __tablename__ = "custom_pages" + id = sa.Column(sa.Integer, primary_key=True) + position = sa.Column(sa.Integer, nullable=False) + name = sa.Column(sa.String, nullable=False) + slug = sa.Column(sa.String, nullable=False) + categories = orm.relationship( + "Category", + secondary=custom_pages2categories, + single_parent=True, + ) + + def __init__(self, session=None, name=None, slug=None, position=0, categories=[], *args, **kwargs) -> None: + self.name = name + self.slug = slug + self.position = position + self.categories = [Category.get_ref(session=session, slug=cat.get("slug")) for cat in categories] def update(self, *args, **kwarg): self.__init__(*args, **kwarg) diff --git a/mealie/routes/backup_routes.py b/mealie/routes/backup_routes.py index 17aa937d2..8c186a737 100644 --- a/mealie/routes/backup_routes.py +++ b/mealie/routes/backup_routes.py @@ -18,14 +18,11 @@ router = APIRouter(prefix="/api/backups", tags=["Backups"]) def available_imports(): """Returns a list of avaiable .zip files for import into Mealie.""" imports = [] - templates = [] for archive in BACKUP_DIR.glob("*.zip"): backup = LocalBackup(name=archive.name, date=archive.stat().st_ctime) imports.append(backup) - for template in TEMPLATE_DIR.glob("*.*"): - templates.append(template.name) - + templates = [template.name for template in TEMPLATE_DIR.glob("*.*")] imports.sort(key=operator.attrgetter("date"), reverse=True) return Imports(imports=imports, templates=templates) @@ -40,6 +37,7 @@ def export_database(data: BackupJob, session: Session = Depends(generate_session templates=data.templates, export_recipes=data.options.recipes, export_settings=data.options.settings, + export_pages=data.options.pages, export_themes=data.options.themes, export_users=data.options.users, export_groups=data.options.groups, @@ -82,11 +80,12 @@ async def upload_nextcloud_zipfile(file_name: str): def import_database(file_name: str, import_data: ImportJob, session: Session = Depends(generate_session)): """ Import a database backup file generated from Mealie. """ - imported = imports.import_database( + return imports.import_database( session=session, archive=import_data.name, import_recipes=import_data.recipes, import_settings=import_data.settings, + import_pages=import_data.pages, import_themes=import_data.themes, import_users=import_data.users, import_groups=import_data.groups, @@ -94,8 +93,6 @@ def import_database(file_name: str, import_data: ImportJob, session: Session = D rebase=import_data.rebase, ) - return imported - @router.delete("/{file_name}/delete", status_code=200) def delete_backup(file_name: str): diff --git a/mealie/routes/users/__init__ copy.py b/mealie/routes/site_settings/__init__.py similarity index 100% rename from mealie/routes/users/__init__ copy.py rename to mealie/routes/site_settings/__init__.py diff --git a/mealie/routes/site_settings/all_settings.py b/mealie/routes/site_settings/all_settings.py new file mode 100644 index 000000000..09da2eda2 --- /dev/null +++ b/mealie/routes/site_settings/all_settings.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter +from mealie.routes.site_settings import custom_pages, site_settings + +router = APIRouter() + +router.include_router(custom_pages.router) +router.include_router(site_settings.router) diff --git a/mealie/routes/site_settings/custom_pages.py b/mealie/routes/site_settings/custom_pages.py new file mode 100644 index 000000000..33bd27598 --- /dev/null +++ b/mealie/routes/site_settings/custom_pages.py @@ -0,0 +1,75 @@ +from typing import Union + +from fastapi import APIRouter, Depends +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.settings import CustomPageBase, CustomPageOut +from mealie.schema.snackbar import SnackResponse +from mealie.schema.user import UserInDB +from sqlalchemy.orm.session import Session + +router = APIRouter(prefix="/api/site-settings/custom-pages", tags=["Settings"]) + + +@router.get("") +def get_custom_pages(session: Session = Depends(generate_session)): + """ Returns the sites custom pages """ + + return db.custom_pages.get_all(session) + + +@router.post("") +async def create_new_page( + new_page: CustomPageBase, + session: Session = Depends(generate_session), + current_user: UserInDB = Depends(get_current_user), +): + """ Creates a new Custom Page """ + + db.custom_pages.create(session, new_page.dict()) + + return SnackResponse.success("New Page Created") + + +@router.put("") +async def update_multiple_pages( + pages: list[CustomPageOut], + session: Session = Depends(generate_session), + current_user: UserInDB = Depends(get_current_user), +): + """ Update multiple custom pages """ + for page in pages: + db.custom_pages.update(session, page.id, page.dict()) + return SnackResponse.success("Pages Updated") + + +@router.get("/{id}") +async def get_single_page( + id: Union[int, str], + session: Session = Depends(generate_session), +): + """ Removes a custom page from the database """ + if isinstance(id, int): + return db.custom_pages.get(session, id) + elif isinstance(id, str): + return db.custom_pages.get(session, id, "slug") + + +@router.put("/{id}") +async def update_single_age(data: CustomPageOut, id: int, session: Session = Depends(generate_session)): + """ Removes a custom page from the database """ + + return db.custom_pages.update(session, id, data.dict()) + + +@router.delete("/{id}") +async def delete_custom_page( + id: int, + session: Session = Depends(generate_session), + current_user: UserInDB = Depends(get_current_user), +): + """ Removes a custom page from the database """ + + db.custom_pages.delete(session, id) + return diff --git a/mealie/routes/setting_routes.py b/mealie/routes/site_settings/site_settings.py similarity index 95% rename from mealie/routes/setting_routes.py rename to mealie/routes/site_settings/site_settings.py index 81cd18d46..4f7bf9772 100644 --- a/mealie/routes/setting_routes.py +++ b/mealie/routes/site_settings/site_settings.py @@ -16,9 +16,7 @@ router = APIRouter(prefix="/api/site-settings", tags=["Settings"]) def get_main_settings(session: Session = Depends(generate_session)): """ Returns basic site settings """ - data = db.settings.get(session, 1) - - return data + return db.settings.get(session, 1) @router.put("") diff --git a/mealie/schema/backup.py b/mealie/schema/backup.py index 12c0f3b3e..9b7b735c0 100644 --- a/mealie/schema/backup.py +++ b/mealie/schema/backup.py @@ -7,6 +7,7 @@ from pydantic import BaseModel class BackupOptions(BaseModel): recipes: bool = True settings: bool = True + pages: bool = True themes: bool = True groups: bool = True users: bool = True diff --git a/mealie/schema/recipe.py b/mealie/schema/recipe.py index ad343be18..54a83d534 100644 --- a/mealie/schema/recipe.py +++ b/mealie/schema/recipe.py @@ -101,11 +101,10 @@ class Recipe(BaseModel): name: str = values["name"] calc_slug: str = slugify(name) - if slug == calc_slug: - return slug - else: + if slug != calc_slug: slug = calc_slug - return slug + + return slug class AllRecipeRequest(BaseModel): diff --git a/mealie/schema/restore.py b/mealie/schema/restore.py index f57fdd3fa..badb590d8 100644 --- a/mealie/schema/restore.py +++ b/mealie/schema/restore.py @@ -27,3 +27,7 @@ class GroupImport(ImportBase): class UserImport(ImportBase): pass + + +class CustomPageImport(ImportBase): + pass diff --git a/mealie/schema/settings.py b/mealie/schema/settings.py index 0e97aa8c9..9f147d16a 100644 --- a/mealie/schema/settings.py +++ b/mealie/schema/settings.py @@ -1,8 +1,9 @@ from typing import Optional from fastapi_camelcase import CamelModel - from mealie.schema.category import CategoryBase +from pydantic import validator +from slugify import slugify class SiteSettings(CamelModel): @@ -25,3 +26,30 @@ class SiteSettings(CamelModel): ], } } + + +class CustomPageBase(CamelModel): + name: str + slug: Optional[str] + position: int + categories: list[CategoryBase] = [] + + class Config: + orm_mode = True + + @validator("slug", always=True, pre=True) + def validate_slug(slug: str, values): + name: str = values["name"] + calc_slug: str = slugify(name) + + if slug != calc_slug: + slug = calc_slug + + return slug + + +class CustomPageOut(CustomPageBase): + id: int + + class Config: + orm_mode = True diff --git a/mealie/services/backups/exports.py b/mealie/services/backups/exports.py index 8d0e5ba04..1753e1140 100644 --- a/mealie/services/backups/exports.py +++ b/mealie/services/backups/exports.py @@ -4,11 +4,11 @@ from datetime import datetime from pathlib import Path from typing import Union +from fastapi.logger import logger +from jinja2 import Template from mealie.core.config import BACKUP_DIR, IMG_DIR, TEMP_DIR, TEMPLATE_DIR from mealie.db.database import db from mealie.db.db_setup import create_session -from fastapi.logger import logger -from jinja2 import Template from pydantic.main import BaseModel @@ -101,6 +101,7 @@ def backup_all( templates=None, export_recipes=True, export_settings=True, + export_pages=True, export_themes=True, export_users=True, export_groups=True, @@ -125,6 +126,10 @@ def backup_all( all_settings = db.settings.get_all(session) db_export.export_items(all_settings, "settings") + if export_pages: + all_pages = db.custom_pages.get_all(session) + db_export.export_items(all_pages, "pages") + if export_themes: all_themes = db.themes.get_all(session) db_export.export_items(all_themes, "themes") @@ -136,10 +141,7 @@ def auto_backup_job(): for backup in BACKUP_DIR.glob("Auto*.zip"): backup.unlink() - templates = [] - for template in TEMPLATE_DIR.iterdir(): - templates.append(template) - + templates = [template for template in TEMPLATE_DIR.iterdir()] session = create_session() backup_all(session=session, tag="Auto", templates=templates) logger.info("Auto Backup Called") diff --git a/mealie/services/backups/imports.py b/mealie/services/backups/imports.py index ca99e8480..97ff3f1ed 100644 --- a/mealie/services/backups/imports.py +++ b/mealie/services/backups/imports.py @@ -7,8 +7,8 @@ from typing import Callable, List from mealie.core.config import BACKUP_DIR, IMG_DIR, TEMP_DIR from mealie.db.database import db from mealie.schema.recipe import Recipe -from mealie.schema.restore import GroupImport, RecipeImport, SettingsImport, ThemeImport, UserImport -from mealie.schema.settings import SiteSettings +from mealie.schema.restore import CustomPageImport, GroupImport, RecipeImport, SettingsImport, ThemeImport, UserImport +from mealie.schema.settings import CustomPageOut, SiteSettings from mealie.schema.theme import SiteTheme from mealie.schema.user import UpdateGroup, UserInDB from pydantic.main import BaseModel @@ -42,7 +42,6 @@ class ImportDatabase: with zipfile.ZipFile(self.archive, "r") as zip_ref: zip_ref.extractall(self.import_dir) - pass else: raise Exception("Import file does not exist") @@ -95,9 +94,7 @@ class ImportDatabase: try: if "" in recipe_dict["categories"]: - recipe_dict["categories"] = [ - cat for cat in recipe_dict["categories"] if cat != "" - ] + recipe_dict["categories"] = [cat for cat in recipe_dict["categories"] if cat != ""] except: pass @@ -149,6 +146,19 @@ class ImportDatabase: return [import_status] + def import_pages(self): + pages_file = self.import_dir.joinpath("pages", "pages.json") + pages = ImportDatabase.read_models_file(pages_file, CustomPageOut) + + page_imports = [] + for page in pages: + import_stats = self.import_model( + db_table=db.custom_pages, model=page, return_model=CustomPageImport, name_attr="name", search_key="slug" + ) + page_imports.append(import_stats) + + return page_imports + def import_groups(self): groups_file = self.import_dir.joinpath("groups", "groups.json") groups = ImportDatabase.read_models_file(groups_file, UpdateGroup) @@ -273,6 +283,7 @@ def import_database( archive, import_recipes=True, import_settings=True, + import_pages=True, import_themes=True, import_users=True, import_groups=True, @@ -293,6 +304,10 @@ def import_database( if import_themes: theme_report = import_session.import_themes() + if import_pages: + print("IMport Pages") + page_report = import_session.import_pages() + group_report = [] if import_groups: group_report = import_session.import_groups() @@ -307,6 +322,7 @@ def import_database( "recipeImports": recipe_report, "settingsImports": settings_report, "themeImports": theme_report, + "pageImports": page_report, "groupImports": group_report, "userImports": user_report, } diff --git a/tests/conftest.py b/tests/conftest.py index 0dfd75af0..913f3e16c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ import json import requests from fastapi.testclient import TestClient from mealie.app import app -from mealie.core.config import SQLITE_DIR +from mealie.core.config import DEFAULT_PASSWORD, SQLITE_DIR from mealie.db.db_setup import generate_session, sql_global_init from mealie.db.init_db import init_db from pytest import fixture @@ -44,7 +44,7 @@ def test_image(): @fixture(scope="session") def token(api_client: requests): - form_data = {"username": "changeme@email.com", "password": "MyPassword"} + form_data = {"username": "changeme@email.com", "password": DEFAULT_PASSWORD} response = api_client.post(TOKEN_URL, form_data) token = json.loads(response.text).get("access_token")