From 2e9026f9eaff4621cb986e3f70f14b53a9acaa92 Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Thu, 7 Oct 2021 09:39:47 -0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E2=9C=A8=20Fix=20scheduler,?= =?UTF-8?q?=20forgot=20password=20flow,=20and=20minor=20bug=20fixes=20(#72?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(frontend): :lipstick: add recipe title * fix(frontend): :bug: fixes #722 side-bar issue * feat(frontend): :sparkles: Add page titles to all pages * minor cleanup * refactor(backend): :recycle: rewrite scheduler to be more modulare and work * feat(frontend): :sparkles: start password reset functionality * refactor(backend): :recycle: refactor application settings to facilitate dependency injection * refactor(backend): :fire: remove RECIPE_SETTINGS env variables in favor of group settings * formatting * refactor(backend): :recycle: align naming convention * feat(backend): :sparkles: password reset * test(backend): :white_check_mark: password reset * feat(frontend): :sparkles: self-service password reset * purge password schedule * update user creation for tests Co-authored-by: Hayden --- .gitignore | 1 + dev/scripts/api_docs_gen.py | 4 +- .../documentation/getting-started/install.md | 16 -- frontend/api/class-interfaces/email.ts | 28 +-- frontend/api/class-interfaces/users.ts | 16 +- frontend/composables/use-recipe-context.ts | 2 +- frontend/composables/use-router.ts | 24 ++- frontend/lang/messages/en-US.json | 3 +- frontend/layouts/admin.vue | 9 +- frontend/layouts/default.vue | 14 +- frontend/layouts/error.vue | 2 +- frontend/nuxt.config.js | 2 +- frontend/pages/admin/about.vue | 5 + frontend/pages/admin/backups.vue | 5 + frontend/pages/admin/dashboard.vue | 5 + .../pages/admin/manage-users/all-groups.vue | 5 + .../pages/admin/manage-users/all-users.vue | 5 + frontend/pages/admin/migrations.vue | 5 + frontend/pages/admin/site-settings.vue | 5 + frontend/pages/admin/toolbox/categories.vue | 5 + frontend/pages/admin/toolbox/foods.vue | 5 + .../pages/admin/toolbox/notifications.vue | 5 + frontend/pages/admin/toolbox/organize.vue | 5 + frontend/pages/admin/toolbox/tags.vue | 5 + frontend/pages/admin/toolbox/units.vue | 5 + frontend/pages/cookbooks/_slug.vue | 9 +- frontend/pages/forgot-password.vue | 86 ++++++++ frontend/pages/index.vue | 2 - frontend/pages/login.vue | 18 +- frontend/pages/meal-plan/planner.vue | 5 + frontend/pages/meal-plan/this-week.vue | 5 + frontend/pages/recipe/_slug.vue | 28 +++ frontend/pages/recipe/create.vue | 5 + frontend/pages/recipes/all.vue | 5 + frontend/pages/recipes/categories/_slug.vue | 5 + frontend/pages/recipes/categories/index.vue | 5 + frontend/pages/recipes/tags/_slug.vue | 5 + frontend/pages/recipes/tags/index.vue | 5 + frontend/pages/register.vue | 5 + frontend/pages/reset-password.vue | 140 ++++++++++++ frontend/pages/search.vue | 5 + frontend/pages/shopping-list/_id.vue | 5 + frontend/pages/shopping-list/index.vue | 31 +-- frontend/pages/user/_id/favorites.vue | 5 + frontend/pages/user/_id/profile.vue | 13 +- frontend/pages/user/group/cookbooks.vue | 5 + frontend/pages/user/group/index.vue | 5 + frontend/pages/user/group/members.vue | 5 + frontend/pages/user/group/webhooks.vue | 5 + frontend/pages/user/profile/api-tokens.vue | 5 + frontend/pages/user/profile/edit.vue | 7 +- frontend/pages/user/profile/index.vue | 5 + frontend/pages/user/request-signup-link.vue | 16 -- frontend/pages/user/sign-up.vue | 113 ---------- frontend/types/api-types/recipe.ts | 2 +- mealie/app.py | 32 ++- mealie/core/config.py | 200 ++---------------- mealie/core/dependencies/dependencies.py | 4 +- mealie/core/root_logger.py | 8 +- mealie/core/security.py | 30 +-- mealie/core/settings/__init__.py | 2 + mealie/core/settings/db_providers.py | 65 ++++++ mealie/core/settings/directories.py | 34 +++ mealie/core/settings/settings.py | 109 ++++++++++ mealie/core/settings/static.py | 10 + .../data_access_layer/access_model_factory.py | 8 +- .../db/data_access_layer/user_access_model.py | 4 +- mealie/db/data_initialization/init_users.py | 3 +- mealie/db/db_setup.py | 4 +- mealie/db/init_db.py | 4 +- mealie/db/models/group/group.py | 4 +- mealie/db/models/users/__init__.py | 1 + mealie/db/models/users/password_reset.py | 15 ++ mealie/db/models/users/users.py | 8 +- mealie/routes/admin/admin_about.py | 7 +- mealie/routes/admin/admin_email.py | 4 +- mealie/routes/app/app_about.py | 4 +- mealie/routes/backup_routes.py | 4 +- mealie/routes/migration_routes.py | 4 +- mealie/routes/users/__init__.py | 1 + mealie/routes/users/images.py | 4 +- mealie/routes/users/passwords.py | 22 +- mealie/schema/recipe/recipe.py | 4 +- mealie/schema/recipe/recipe_settings.py | 16 +- mealie/schema/user/user.py | 4 +- mealie/schema/user/user_passwords.py | 29 +++ .../_base_http_service/base_http_service.py | 4 +- mealie/services/_base_service/__init__.py | 4 +- mealie/services/backups/exports.py | 18 +- mealie/services/backups/imports.py | 4 +- .../services/group_services/group_service.py | 5 +- mealie/services/image/minify.py | 3 +- mealie/services/migrations/chowdown.py | 4 +- mealie/services/scheduler/__init__.py | 2 + mealie/services/scheduler/global_scheduler.py | 7 - mealie/services/scheduler/scheduled_func.py | 30 +++ mealie/services/scheduler/scheduled_jobs.py | 124 ----------- .../services/scheduler/scheduler_registry.py | 43 ++++ .../services/scheduler/scheduler_service.py | 104 +++++++++ mealie/services/scheduler/scheduler_utils.py | 8 - mealie/services/scheduler/tasks/__init__.py | 14 ++ .../services/scheduler/tasks/auto_backup.py | 22 ++ .../services/scheduler/tasks/purge_events.py | 19 ++ .../scheduler/tasks/purge_password_reset.py | 20 ++ .../scheduler/tasks/purge_registration.py | 20 ++ mealie/services/scheduler/tasks/webhooks.py | 58 +++++ .../scraper/ingredient_nlp/processor.py | 4 +- mealie/services/scraper/open_graph.py | 4 +- mealie/services/scraper/scraper.py | 4 +- .../user_services/password_reset_service.py | 66 ++++++ .../user_services/registration_service.py | 2 +- mealie/utils/unzip.py | 4 +- template.env | 7 - tests/conftest.py | 17 +- .../test_migration_routes.py | 4 +- .../user_tests/test_user_images.py | 4 +- .../test_user_password_reset_service.py | 52 +++++ tests/pre_test.py | 10 +- .../{ => services}/test_email_service.py | 12 +- tests/unit_tests/test_config.py | 47 ++-- tests/utils/fixture_schemas.py | 1 + 121 files changed, 1461 insertions(+), 679 deletions(-) create mode 100644 frontend/pages/forgot-password.vue create mode 100644 frontend/pages/reset-password.vue delete mode 100644 frontend/pages/user/request-signup-link.vue delete mode 100644 frontend/pages/user/sign-up.vue create mode 100644 mealie/core/settings/__init__.py create mode 100644 mealie/core/settings/db_providers.py create mode 100644 mealie/core/settings/directories.py create mode 100644 mealie/core/settings/settings.py create mode 100644 mealie/core/settings/static.py create mode 100644 mealie/db/models/users/password_reset.py create mode 100644 mealie/schema/user/user_passwords.py delete mode 100644 mealie/services/scheduler/global_scheduler.py create mode 100644 mealie/services/scheduler/scheduled_func.py delete mode 100644 mealie/services/scheduler/scheduled_jobs.py create mode 100644 mealie/services/scheduler/scheduler_registry.py create mode 100644 mealie/services/scheduler/scheduler_service.py delete mode 100644 mealie/services/scheduler/scheduler_utils.py create mode 100644 mealie/services/scheduler/tasks/__init__.py create mode 100644 mealie/services/scheduler/tasks/auto_backup.py create mode 100644 mealie/services/scheduler/tasks/purge_events.py create mode 100644 mealie/services/scheduler/tasks/purge_password_reset.py create mode 100644 mealie/services/scheduler/tasks/purge_registration.py create mode 100644 mealie/services/scheduler/tasks/webhooks.py create mode 100644 mealie/services/user_services/password_reset_service.py create mode 100644 tests/integration_tests/user_tests/test_user_password_reset_service.py rename tests/unit_tests/{ => services}/test_email_service.py (84%) diff --git a/.gitignore b/.gitignore index 8be40997bbcd..6c2075780685 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ *__pycache__/ *.py[cod] *$py.class +*.temp # frontend/.env.development docs/site/ diff --git a/dev/scripts/api_docs_gen.py b/dev/scripts/api_docs_gen.py index fb530030639e..df5e4b857cfe 100644 --- a/dev/scripts/api_docs_gen.py +++ b/dev/scripts/api_docs_gen.py @@ -1,7 +1,9 @@ import json from mealie.app import app -from mealie.core.config import DATA_DIR +from mealie.core.config import determine_data_dir + +DATA_DIR = determine_data_dir() """Script to export the ReDoc documentation page into a standalone HTML file.""" diff --git a/docs/docs/documentation/getting-started/install.md b/docs/docs/documentation/getting-started/install.md index df533b947170..3c2072c378b0 100644 --- a/docs/docs/documentation/getting-started/install.md +++ b/docs/docs/documentation/getting-started/install.md @@ -45,14 +45,6 @@ services: PGID: 1000 TZ: America/Anchorage - # Default Recipe Settings - RECIPE_PUBLIC: true - RECIPE_SHOW_NUTRITION: true - RECIPE_SHOW_ASSETS: true - RECIPE_LANDSCAPE_VIEW: true - RECIPE_DISABLE_COMMENTS: false - RECIPE_DISABLE_AMOUNT: false - # Gunicorn WEB_CONCURRENCY: 2 # WORKERS_PER_CORE: 0.5 @@ -89,14 +81,6 @@ services: POSTGRES_PORT: 5432 POSTGRES_DB: mealie - # Default Recipe Settings - RECIPE_PUBLIC: true - RECIPE_SHOW_NUTRITION: true - RECIPE_SHOW_ASSETS: true - RECIPE_LANDSCAPE_VIEW: true - RECIPE_DISABLE_COMMENTS: false - RECIPE_DISABLE_AMOUNT: false - # Gunicorn WEB_CONCURRENCY: 2 # WORKERS_PER_CORE: 0.5 diff --git a/frontend/api/class-interfaces/email.ts b/frontend/api/class-interfaces/email.ts index 667c21c7ed33..005b1276b6a1 100644 --- a/frontend/api/class-interfaces/email.ts +++ b/frontend/api/class-interfaces/email.ts @@ -2,20 +2,17 @@ import { BaseAPI } from "./_base"; const routes = { base: "/api/admin/email", + forgotPassword: "/api/users/forgot-password", invitation: "/api/groups/invitations/email", }; -export interface CheckEmailResponse { - ready: boolean; -} - -export interface TestEmailResponse { +export interface EmailResponse { success: boolean; error: string; } -export interface TestEmailPayload { +export interface EmailPayload { email: string; } @@ -24,21 +21,16 @@ export interface InvitationEmail { token: string; } -export interface InvitationEmailResponse { - success: boolean; - error: string; -} - export class EmailAPI extends BaseAPI { - check() { - return this.requests.get(routes.base); - } - - test(payload: TestEmailPayload) { - return this.requests.post(routes.base, payload); + test(payload: EmailPayload) { + return this.requests.post(routes.base, payload); } sendInvitation(payload: InvitationEmail) { - return this.requests.post(routes.invitation, payload); + return this.requests.post(routes.invitation, payload); + } + + sendForgotPassword(payload: EmailPayload) { + return this.requests.post(routes.forgotPassword, payload); } } diff --git a/frontend/api/class-interfaces/users.ts b/frontend/api/class-interfaces/users.ts index aebfe6827e5f..437b8a71a6ef 100644 --- a/frontend/api/class-interfaces/users.ts +++ b/frontend/api/class-interfaces/users.ts @@ -16,12 +16,20 @@ interface ResponseToken { token: string; } +interface PasswordResetPayload { + token: string; + email: string; + password: string; + passwordConfirm: string; +} + // Code const prefix = "/api"; const routes = { usersSelf: `${prefix}/users/self`, + passwordReset: `${prefix}/users/reset-password`, users: `${prefix}/users`, usersIdImage: (id: string) => `${prefix}/users/${id}/image`, @@ -55,10 +63,6 @@ export class UserApi extends BaseCRUDAPI { return await this.requests.put(routes.usersIdPassword(id), changePassword); } - async resetPassword(id: string) { - return await this.requests.post(routes.usersIdResetPassword(id), {}); - } - async createAPIToken(tokenName: CreateAPIToken) { return await this.requests.post(routes.usersApiTokens, tokenName); } @@ -71,4 +75,8 @@ export class UserApi extends BaseCRUDAPI { if (!id || id === undefined) return; return `/api/users/${id}/image`; } + + async resetPassword(payload: PasswordResetPayload) { + return await this.requests.post(routes.passwordReset, payload); + } } diff --git a/frontend/composables/use-recipe-context.ts b/frontend/composables/use-recipe-context.ts index 767b64f3ffd1..adfd1eb10f76 100644 --- a/frontend/composables/use-recipe-context.ts +++ b/frontend/composables/use-recipe-context.ts @@ -1,4 +1,4 @@ -import { useAsync, ref, reactive } from "@nuxtjs/composition-api"; +import { useAsync, ref } from "@nuxtjs/composition-api"; import { useApiSingleton } from "~/composables/use-api"; import { Recipe } from "~/types/api-types/recipe"; diff --git a/frontend/composables/use-router.ts b/frontend/composables/use-router.ts index 60853b0fa533..4216276c951f 100644 --- a/frontend/composables/use-router.ts +++ b/frontend/composables/use-router.ts @@ -1,4 +1,4 @@ -import { useRoute, WritableComputedRef, computed } from "@nuxtjs/composition-api"; +import { useRoute, WritableComputedRef, computed, nextTick, useRouter } from "@nuxtjs/composition-api"; export function useRouterQuery(query: string) { const router = useRoute(); @@ -6,6 +6,7 @@ export function useRouterQuery(query: string) { const param: WritableComputedRef = computed({ get(): string { + console.log("Get Query Change"); // @ts-ignore return router.value?.query[query] || ""; }, @@ -16,3 +17,24 @@ export function useRouterQuery(query: string) { return param; } + +export function useRouteQuery(name: string, defaultValue?: T) { + const route = useRoute(); + const router = useRouter(); + + return computed({ + get() { + console.log("Getter"); + const data = route.value.query[name]; + if (data == null) return defaultValue ?? null; + return data; + }, + set(v) { + nextTick(() => { + console.log("Setter"); + // @ts-ignore + router.value.replace({ query: { ...route.value.query, [name]: v } }); + }); + }, + }); +} diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json index c430d115d1f4..ebca1014cdaf 100644 --- a/frontend/lang/messages/en-US.json +++ b/frontend/lang/messages/en-US.json @@ -387,7 +387,8 @@ "test-webhooks": "Test Webhooks", "the-urls-listed-below-will-recieve-webhooks-containing-the-recipe-data-for-the-meal-plan-on-its-scheduled-day-currently-webhooks-will-execute-at": "The URLs listed below will receive webhooks containing the recipe data for the meal plan on it's scheduled day. Currently Webhooks will execute at", "webhook-url": "Webhook URL", - "webhooks-caps": "WEBHOOKS" + "webhooks-caps": "WEBHOOKS", + "webhooks": "Webhooks" } }, "shopping-list": { diff --git a/frontend/layouts/admin.vue b/frontend/layouts/admin.vue index a9c1ffa2d6f2..9e13970f98ed 100644 --- a/frontend/layouts/admin.vue +++ b/frontend/layouts/admin.vue @@ -27,7 +27,7 @@ diff --git a/frontend/pages/admin/backups.vue b/frontend/pages/admin/backups.vue index d88d119256e5..18d7be50ecd2 100644 --- a/frontend/pages/admin/backups.vue +++ b/frontend/pages/admin/backups.vue @@ -146,6 +146,11 @@ export default defineComponent({ backupsFileNameDownload, }; }, + head() { + return { + title: this.$t("sidebar.backups") as string, + }; + }, }); diff --git a/frontend/pages/admin/dashboard.vue b/frontend/pages/admin/dashboard.vue index a5fe676bf99f..1b5a7b358cc8 100644 --- a/frontend/pages/admin/dashboard.vue +++ b/frontend/pages/admin/dashboard.vue @@ -146,6 +146,11 @@ export default defineComponent({ return { statistics, events, deleteEvents, deleteEvent }; }, + head() { + return { + title: this.$t("sidebar.dashboard") as string, + }; + }, }); diff --git a/frontend/pages/admin/manage-users/all-groups.vue b/frontend/pages/admin/manage-users/all-groups.vue index 38a43c52799d..48eb6a95ae6d 100644 --- a/frontend/pages/admin/manage-users/all-groups.vue +++ b/frontend/pages/admin/manage-users/all-groups.vue @@ -111,5 +111,10 @@ export default defineComponent({ return { ...toRefs(state), groups, refreshAllGroups, deleteGroup, createGroup }; }, + head() { + return { + title: this.$t("group.manage-groups") as string, + }; + }, }); diff --git a/frontend/pages/admin/manage-users/all-users.vue b/frontend/pages/admin/manage-users/all-users.vue index a28ecc6f412e..2d0a1a19b188 100644 --- a/frontend/pages/admin/manage-users/all-users.vue +++ b/frontend/pages/admin/manage-users/all-users.vue @@ -153,6 +153,11 @@ export default defineComponent({ }, }; }, + head() { + return { + title: this.$t("sidebar.manage-users") as string, + }; + }, methods: { updateUser(userData: any) { this.updateMode = true; diff --git a/frontend/pages/admin/migrations.vue b/frontend/pages/admin/migrations.vue index 25bdfd4df92f..343506cf49e9 100644 --- a/frontend/pages/admin/migrations.vue +++ b/frontend/pages/admin/migrations.vue @@ -17,6 +17,11 @@ export default defineComponent({ setup() { return {}; }, + head() { + return { + title: this.$t("settings.migrations") as string, + }; + }, }); diff --git a/frontend/pages/admin/site-settings.vue b/frontend/pages/admin/site-settings.vue index 23b9ab4f1092..4f16cfaa6343 100644 --- a/frontend/pages/admin/site-settings.vue +++ b/frontend/pages/admin/site-settings.vue @@ -156,6 +156,11 @@ export default defineComponent({ testEmail, }; }, + head() { + return { + title: this.$t("settings.site-settings") as string, + }; + }, }); diff --git a/frontend/pages/admin/toolbox/categories.vue b/frontend/pages/admin/toolbox/categories.vue index 257aeb0c189a..b980f2a44b17 100644 --- a/frontend/pages/admin/toolbox/categories.vue +++ b/frontend/pages/admin/toolbox/categories.vue @@ -12,6 +12,11 @@ export default defineComponent({ setup() { return {}; }, + head() { + return { + title: this.$t("sidebar.categories") as string, + }; + }, }); diff --git a/frontend/pages/admin/toolbox/foods.vue b/frontend/pages/admin/toolbox/foods.vue index 539c21bd8bc8..1e3cc8261d22 100644 --- a/frontend/pages/admin/toolbox/foods.vue +++ b/frontend/pages/admin/toolbox/foods.vue @@ -113,6 +113,11 @@ export default defineComponent({ workingFoodData, }; }, + head() { + return { + title: "Foods", + }; + }, }); diff --git a/frontend/pages/admin/toolbox/notifications.vue b/frontend/pages/admin/toolbox/notifications.vue index 9e4494bd2278..71b3280684fc 100644 --- a/frontend/pages/admin/toolbox/notifications.vue +++ b/frontend/pages/admin/toolbox/notifications.vue @@ -215,6 +215,11 @@ export default defineComponent({ notificationTypes, }; }, + head() { + return { + title: this.$t("events.notification") as string, + }; + }, }); diff --git a/frontend/pages/admin/toolbox/organize.vue b/frontend/pages/admin/toolbox/organize.vue index 83f7201a2ad8..b51217b3928e 100644 --- a/frontend/pages/admin/toolbox/organize.vue +++ b/frontend/pages/admin/toolbox/organize.vue @@ -12,6 +12,11 @@ export default defineComponent({ setup() { return {}; }, + head() { + return { + title: this.$t("settings.organize") as string, + }; + }, }); diff --git a/frontend/pages/admin/toolbox/tags.vue b/frontend/pages/admin/toolbox/tags.vue index 3365539f5085..f1db01e6d55d 100644 --- a/frontend/pages/admin/toolbox/tags.vue +++ b/frontend/pages/admin/toolbox/tags.vue @@ -12,6 +12,11 @@ export default defineComponent({ setup() { return {}; }, + head() { + return { + title: this.$t("sidebar.tags") as string, + }; + }, }); diff --git a/frontend/pages/admin/toolbox/units.vue b/frontend/pages/admin/toolbox/units.vue index 9de0a5a218bd..f2e53a90b98a 100644 --- a/frontend/pages/admin/toolbox/units.vue +++ b/frontend/pages/admin/toolbox/units.vue @@ -115,6 +115,11 @@ export default defineComponent({ workingUnitData, }; }, + head() { + return { + title: "Units", + }; + }, }); diff --git a/frontend/pages/cookbooks/_slug.vue b/frontend/pages/cookbooks/_slug.vue index 6a5de3805662..f4ba2a96098f 100644 --- a/frontend/pages/cookbooks/_slug.vue +++ b/frontend/pages/cookbooks/_slug.vue @@ -23,7 +23,7 @@ diff --git a/frontend/pages/forgot-password.vue b/frontend/pages/forgot-password.vue new file mode 100644 index 000000000000..2fe80afd3279 --- /dev/null +++ b/frontend/pages/forgot-password.vue @@ -0,0 +1,86 @@ + + + + + \ No newline at end of file diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue index 0882800f7f80..a3bf9a53003a 100644 --- a/frontend/pages/index.vue +++ b/frontend/pages/index.vue @@ -18,9 +18,7 @@ export default defineComponent({ components: { RecipeCardSection }, setup() { const { assignSorted } = useRecipes(false); - useStaticRoutes(); - return { recentRecipes, assignSorted }; }, }); diff --git a/frontend/pages/login.vue b/frontend/pages/login.vue index b26878df2b6d..e9e922125ecb 100644 --- a/frontend/pages/login.vue +++ b/frontend/pages/login.vue @@ -1,12 +1,12 @@ @@ -223,10 +226,15 @@ export default defineComponent({ authenticate, }; }, + + head() { + return { + title: this.$t("user.login") as string, + }; + }, }); - \ No newline at end of file diff --git a/frontend/pages/search.vue b/frontend/pages/search.vue index 1f754b35cc8e..b89fe34f7aa7 100644 --- a/frontend/pages/search.vue +++ b/frontend/pages/search.vue @@ -110,6 +110,11 @@ export default defineComponent({ }, }; }, + head() { + return { + title: this.$t("search.search"), + }; + }, computed: { searchString: { set(q) { diff --git a/frontend/pages/shopping-list/_id.vue b/frontend/pages/shopping-list/_id.vue index 411c8fc22c68..9a0911779ac9 100644 --- a/frontend/pages/shopping-list/_id.vue +++ b/frontend/pages/shopping-list/_id.vue @@ -9,6 +9,11 @@ export default defineComponent({ setup() { return {}; }, + head() { + return { + title: this.$t("shopping-list.shopping-list") as string, + }; + }, }); diff --git a/frontend/pages/shopping-list/index.vue b/frontend/pages/shopping-list/index.vue index bb6aebe3f129..fe12f4cd62d1 100644 --- a/frontend/pages/shopping-list/index.vue +++ b/frontend/pages/shopping-list/index.vue @@ -1,16 +1,21 @@ +
+ - - export default defineComponent({ - setup() { - return {} - } - }) - - - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/pages/user/_id/favorites.vue b/frontend/pages/user/_id/favorites.vue index 411c8fc22c68..98c8cfa5fb04 100644 --- a/frontend/pages/user/_id/favorites.vue +++ b/frontend/pages/user/_id/favorites.vue @@ -9,6 +9,11 @@ export default defineComponent({ setup() { return {}; }, + head() { + return { + title: this.$t("general.favorites") as string, + }; + }, }); diff --git a/frontend/pages/user/_id/profile.vue b/frontend/pages/user/_id/profile.vue index dca13c44af16..77ea9841ecdd 100644 --- a/frontend/pages/user/_id/profile.vue +++ b/frontend/pages/user/_id/profile.vue @@ -3,13 +3,18 @@ \ No newline at end of file diff --git a/frontend/pages/user/sign-up.vue b/frontend/pages/user/sign-up.vue deleted file mode 100644 index 0cdf7951bb0d..000000000000 --- a/frontend/pages/user/sign-up.vue +++ /dev/null @@ -1,113 +0,0 @@ - - - - \ No newline at end of file diff --git a/frontend/types/api-types/recipe.ts b/frontend/types/api-types/recipe.ts index 5417eb8382f1..ed585ec83efb 100644 --- a/frontend/types/api-types/recipe.ts +++ b/frontend/types/api-types/recipe.ts @@ -55,7 +55,7 @@ export interface Recipe { id?: number; name: string; slug: string; - image?: unknown; + image: string; description: string; recipeCategory: string[]; tags: string[]; diff --git a/mealie/app.py b/mealie/app.py index 5d5913bb562f..1a8a91c33125 100644 --- a/mealie/app.py +++ b/mealie/app.py @@ -2,15 +2,18 @@ import uvicorn from fastapi import FastAPI from fastapi.middleware.gzip import GZipMiddleware -from mealie.core.config import APP_VERSION, settings +from mealie.core.config import get_app_settings from mealie.core.root_logger import get_logger +from mealie.core.settings.static import APP_VERSION from mealie.routes import backup_routes, migration_routes, router, utility_routes from mealie.routes.about import about_router from mealie.routes.media import media_router from mealie.routes.site_settings import settings_router from mealie.services.events import create_general_event +from mealie.services.scheduler import SchedulerRegistry, SchedulerService, tasks logger = get_logger() +settings = get_app_settings() app = FastAPI( title="Mealie", @@ -24,24 +27,28 @@ app.add_middleware(GZipMiddleware, minimum_size=1000) def start_scheduler(): - return # TODO: Disable Scheduler for now - import mealie.services.scheduler.scheduled_jobs # noqa: F401 + SchedulerService.start() + + SchedulerRegistry.register_daily( + tasks.purge_events_database, + tasks.purge_group_registration, + tasks.auto_backup, + tasks.purge_password_reset_tokens, + ) + + SchedulerRegistry.register_hourly() + SchedulerRegistry.register_minutely(tasks.update_group_webhooks) + + logger.info(SchedulerService.scheduler.print_jobs()) def api_routers(): - # Authentication app.include_router(router) - # Recipes app.include_router(media_router) app.include_router(about_router) - # Meal Routes - # Settings Routes app.include_router(settings_router) - # Backups/Imports Routes app.include_router(backup_routes.router) - # Migration Routes app.include_router(migration_routes.router) - # Debug routes app.include_router(utility_routes.router) @@ -51,6 +58,7 @@ api_routers() @app.on_event("startup") def system_startup(): start_scheduler() + logger.info("-----SYSTEM STARTUP----- \n") logger.info("------APP SETTINGS------") logger.info( @@ -64,9 +72,12 @@ def system_startup(): "DB_URL", # replace by DB_URL_PUBLIC for logs "POSTGRES_USER", "POSTGRES_PASSWORD", + "SMTP_USER", + "SMTP_PASSWORD", }, ) ) + create_general_event("Application Startup", f"Mealie API started on port {settings.API_PORT}") @@ -77,6 +88,7 @@ def main(): port=settings.API_PORT, reload=True, reload_dirs=["mealie"], + reload_delay=2, debug=True, log_level="debug", use_colors=True, diff --git a/mealie/core/config.py b/mealie/core/config.py index 005ad1147b4f..4b87247a9bce 100644 --- a/mealie/core/config.py +++ b/mealie/core/config.py @@ -1,209 +1,39 @@ import os -import secrets from functools import lru_cache from pathlib import Path -from typing import Any, Optional, Union import dotenv -from pydantic import BaseSettings, Field, PostgresDsn, validator -APP_VERSION = "v1.0.0b" -DB_VERSION = "v1.0.0b" +from mealie.core.settings.settings import app_settings_constructor + +from .settings import AppDirectories, AppSettings +from .settings.static import APP_VERSION, DB_VERSION + +APP_VERSION +DB_VERSION CWD = Path(__file__).parent BASE_DIR = CWD.parent.parent - ENV = BASE_DIR.joinpath(".env") dotenv.load_dotenv(ENV) PRODUCTION = os.getenv("PRODUCTION", "True").lower() in ["true", "1"] -def determine_data_dir(production: bool) -> Path: - global CWD - if production: +def determine_data_dir() -> Path: + global PRODUCTION + global BASE_DIR + if PRODUCTION: return Path("/app/data") - return CWD.parent.parent.joinpath("dev", "data") - - -def determine_secrets(data_dir: Path, production: bool) -> 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: - new_secret = secrets.token_hex(32) - f.write(new_secret) - return new_secret - - -# General -DATA_DIR = determine_data_dir(PRODUCTION) - - -class AppDirectories: - def __init__(self, cwd, data_dir) -> None: - self.DATA_DIR: Path = data_dir - self.WEB_PATH: Path = cwd.joinpath("dist") - self.IMG_DIR: Path = data_dir.joinpath("img") - self.BACKUP_DIR: Path = data_dir.joinpath("backups") - self.DEBUG_DIR: Path = data_dir.joinpath("debug") - self.MIGRATION_DIR: Path = data_dir.joinpath("migration") - self.NEXTCLOUD_DIR: Path = self.MIGRATION_DIR.joinpath("nextcloud") - self.CHOWDOWN_DIR: Path = self.MIGRATION_DIR.joinpath("chowdown") - self.TEMPLATE_DIR: Path = data_dir.joinpath("templates") - self.USER_DIR: Path = data_dir.joinpath("users") - self.RECIPE_DATA_DIR: Path = data_dir.joinpath("recipes") - self.TEMP_DIR: Path = data_dir.joinpath(".temp") - - self.ensure_directories() - - def ensure_directories(self): - required_dirs = [ - self.IMG_DIR, - self.BACKUP_DIR, - self.DEBUG_DIR, - self.MIGRATION_DIR, - self.TEMPLATE_DIR, - self.NEXTCLOUD_DIR, - self.CHOWDOWN_DIR, - self.RECIPE_DATA_DIR, - self.USER_DIR, - ] - - for dir in required_dirs: - dir.mkdir(parents=True, exist_ok=True) - - -app_dirs = AppDirectories(CWD, DATA_DIR) - - -def determine_sqlite_path(path=False, suffix=DB_VERSION) -> str: - global app_dirs - db_path = app_dirs.DATA_DIR.joinpath(f"mealie_{suffix}.db") # ! Temporary Until Alembic - - if path: - return db_path - - return "sqlite:///" + str(db_path.absolute()) - - -class AppSettings(BaseSettings): - global DATA_DIR - PRODUCTION: bool = Field(True, env="PRODUCTION") - BASE_URL: str = "http://localhost:8080" - IS_DEMO: bool = False - API_PORT: int = 9000 - API_DOCS: bool = True - - @property - def DOCS_URL(self) -> str: - return "/docs" if self.API_DOCS else None - - @property - def REDOC_URL(self) -> str: - return "/redoc" if self.API_DOCS else None - - SECRET: str = determine_secrets(DATA_DIR, PRODUCTION) - - DB_ENGINE: str = "sqlite" # Optional: 'sqlite', 'postgres' - POSTGRES_USER: str = "mealie" - POSTGRES_PASSWORD: str = "mealie" - POSTGRES_SERVER: str = "postgres" - POSTGRES_PORT: str = 5432 - POSTGRES_DB: str = "mealie" - - DB_URL: Union[str, PostgresDsn] = None # Actual DB_URL is calculated with `assemble_db_connection` - - @validator("DB_URL", pre=True) - def assemble_db_connection(cls, v: Optional[str], values: dict[str, Any]) -> Any: - engine = values.get("DB_ENGINE", "sqlite") - if engine == "postgres": - host = f"{values.get('POSTGRES_SERVER')}:{values.get('POSTGRES_PORT')}" - return PostgresDsn.build( - scheme="postgresql", - user=values.get("POSTGRES_USER"), - password=values.get("POSTGRES_PASSWORD"), - host=host, - path=f"/{values.get('POSTGRES_DB') or ''}", - ) - return determine_sqlite_path() - - DB_URL_PUBLIC: str = "" # hide credentials to show on logs/frontend - - @validator("DB_URL_PUBLIC", pre=True) - def public_db_url(cls, v: Optional[str], values: dict[str, Any]) -> str: - url = values.get("DB_URL") - engine = values.get("DB_ENGINE", "sqlite") - if engine != "postgres": - # sqlite - return url - - user = values.get("POSTGRES_USER") - password = values.get("POSTGRES_PASSWORD") - return url.replace(user, "*****", 1).replace(password, "*****", 1) - - DEFAULT_GROUP: str = "Home" - DEFAULT_EMAIL: str = "changeme@email.com" - DEFAULT_PASSWORD: str = "MyPassword" - - SCHEDULER_DATABASE = f"sqlite:///{app_dirs.DATA_DIR.joinpath('scheduler.db')}" - - TOKEN_TIME: int = 2 # Time in Hours - - # Recipe Default Settings - RECIPE_PUBLIC: bool = True - RECIPE_SHOW_NUTRITION: bool = True - RECIPE_SHOW_ASSETS: bool = True - RECIPE_LANDSCAPE_VIEW: bool = True - RECIPE_DISABLE_COMMENTS: bool = False - RECIPE_DISABLE_AMOUNT: bool = False - - # =============================================== - # Email Configuration - SMTP_HOST: Optional[str] - SMTP_PORT: Optional[str] = "587" - SMTP_FROM_NAME: Optional[str] = "Mealie" - SMTP_TLS: Optional[bool] = True - SMTP_FROM_EMAIL: Optional[str] - SMTP_USER: Optional[str] - SMTP_PASSWORD: Optional[str] - - @property - def SMTP_ENABLE(self) -> bool: - """Validates all SMTP variables are set""" - required = { - self.SMTP_HOST, - self.SMTP_PORT, - self.SMTP_FROM_NAME, - self.SMTP_TLS, - self.SMTP_FROM_EMAIL, - self.SMTP_USER, - self.SMTP_PASSWORD, - } - - return "" not in required and None not in required - - class Config: - env_file = BASE_DIR.joinpath(".env") - env_file_encoding = "utf-8" - - -settings = AppSettings() + return BASE_DIR.joinpath("dev", "data") @lru_cache def get_app_dirs() -> AppDirectories: - global app_dirs - return app_dirs + return AppDirectories(determine_data_dir()) @lru_cache -def get_settings() -> AppSettings: - global settings - return settings +def get_app_settings() -> AppSettings: + return app_settings_constructor(env_file=ENV, production=PRODUCTION, data_dir=determine_data_dir()) diff --git a/mealie/core/dependencies/dependencies.py b/mealie/core/dependencies/dependencies.py index 23182d66284e..c031a10022ed 100644 --- a/mealie/core/dependencies/dependencies.py +++ b/mealie/core/dependencies/dependencies.py @@ -8,7 +8,7 @@ from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt from sqlalchemy.orm.session import Session -from mealie.core.config import app_dirs, settings +from mealie.core.config import get_app_dirs, get_app_settings from mealie.db.database import get_database from mealie.db.db_setup import generate_session from mealie.schema.user import LongLiveTokenInDB, PrivateUser, TokenData @@ -16,6 +16,8 @@ from mealie.schema.user import LongLiveTokenInDB, PrivateUser, TokenData oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token") oauth2_scheme_soft_fail = OAuth2PasswordBearer(tokenUrl="/api/auth/token", auto_error=False) ALGORITHM = "HS256" +app_dirs = get_app_dirs() +settings = get_app_settings() async def is_logged_in(token: str = Depends(oauth2_scheme_soft_fail), session=Depends(generate_session)) -> bool: diff --git a/mealie/core/root_logger.py b/mealie/core/root_logger.py index 2421823fcda5..b006bb195700 100644 --- a/mealie/core/root_logger.py +++ b/mealie/core/root_logger.py @@ -3,9 +3,13 @@ import sys from dataclasses import dataclass from functools import lru_cache -from mealie.core.config import DATA_DIR +from mealie.core.config import determine_data_dir -from .config import settings +DATA_DIR = determine_data_dir() + +from .config import get_app_settings + +settings = get_app_settings() LOGGER_FILE = DATA_DIR.joinpath("mealie.log") DATE_FORMAT = "%d-%b-%y %H:%M:%S" diff --git a/mealie/core/security.py b/mealie/core/security.py index fe974fd6cf52..0a5894e38ffe 100644 --- a/mealie/core/security.py +++ b/mealie/core/security.py @@ -1,13 +1,16 @@ +import secrets from datetime import datetime, timedelta from pathlib import Path from jose import jwt from passlib.context import CryptContext -from mealie.core.config import settings +from mealie.core.config import get_app_settings from mealie.db.database import get_database from mealie.schema.user import PrivateUser +settings = get_app_settings() + pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") ALGORITHM = "HS256" @@ -43,26 +46,15 @@ def authenticate_user(session, email: str, password: str) -> PrivateUser: 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 - """ + """Compares a plain string to a hashed password""" return pwd_context.verify(plain_password, hashed_password) def hash_password(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 - """ + """Takes in a raw password and hashes it. Used prior to saving a new password to the database.""" return pwd_context.hash(password) + + +def url_safe_token() -> str: + """Generates a cryptographic token without embedded data. Used for password reset tokens and invitation tokens""" + return secrets.token_urlsafe(24) diff --git a/mealie/core/settings/__init__.py b/mealie/core/settings/__init__.py new file mode 100644 index 000000000000..fd096a0a2227 --- /dev/null +++ b/mealie/core/settings/__init__.py @@ -0,0 +1,2 @@ +from .directories import * +from .settings import * diff --git a/mealie/core/settings/db_providers.py b/mealie/core/settings/db_providers.py new file mode 100644 index 000000000000..13184840d571 --- /dev/null +++ b/mealie/core/settings/db_providers.py @@ -0,0 +1,65 @@ +from abc import ABC, abstractproperty +from pathlib import Path + +from pydantic import BaseModel, BaseSettings, PostgresDsn + + +class AbstractDBProvider(ABC): + @abstractproperty + def db_url(self) -> str: + pass + + @property + def db_url_public(self) -> str: + pass + + +class SQLiteProvider(AbstractDBProvider, BaseModel): + data_dir: Path + prefix: str = "" + + @property + def db_path(self): + return self.data_dir / f"{self.prefix}mealie.db" + + @property + def db_url(self) -> str: + return "sqlite:///" + str(self.db_path.absolute()) + + @property + def db_url_public(self) -> str: + return self.db_url + + +class PostgresProvider(AbstractDBProvider, BaseSettings): + POSTGRES_USER: str = "mealie" + POSTGRES_PASSWORD: str = "mealie" + POSTGRES_SERVER: str = "postgres" + POSTGRES_PORT: str = 5432 + POSTGRES_DB: str = "mealie" + + @property + def db_url(self) -> str: + host = f"{self.POSTGRES_SERVER}:{self.POSTGRES_PORT}" + return PostgresDsn.build( + scheme="postgresql", + user=self.POSTGRES_USER, + password=self.POSTGRES_PASSWORD, + host=host, + path=f"/{self.POSTGRES_DB or ''}", + ) + + @property + def db_url_public(self) -> str: + user = self.POSTGRES_USER + password = self.POSTGRES_PASSWORD + return self.db_url.replace(user, "*****", 1).replace(password, "*****", 1) + + +def db_provider_factory(provider_name: str, data_dir: Path, env_file: Path, env_encoding="utf-8") -> AbstractDBProvider: + if provider_name == "sqlite": + return SQLiteProvider(data_dir=data_dir) + elif provider_name == "postgres": + return PostgresProvider(_env_file=env_file, _env_file_encoding=env_encoding) + else: + return diff --git a/mealie/core/settings/directories.py b/mealie/core/settings/directories.py new file mode 100644 index 000000000000..75e4a1eef9ef --- /dev/null +++ b/mealie/core/settings/directories.py @@ -0,0 +1,34 @@ +from pathlib import Path + + +class AppDirectories: + def __init__(self, data_dir) -> None: + self.DATA_DIR: Path = data_dir + self.IMG_DIR: Path = data_dir.joinpath("img") + self.BACKUP_DIR: Path = data_dir.joinpath("backups") + self.DEBUG_DIR: Path = data_dir.joinpath("debug") + self.MIGRATION_DIR: Path = data_dir.joinpath("migration") + self.NEXTCLOUD_DIR: Path = self.MIGRATION_DIR.joinpath("nextcloud") + self.CHOWDOWN_DIR: Path = self.MIGRATION_DIR.joinpath("chowdown") + self.TEMPLATE_DIR: Path = data_dir.joinpath("templates") + self.USER_DIR: Path = data_dir.joinpath("users") + self.RECIPE_DATA_DIR: Path = data_dir.joinpath("recipes") + self.TEMP_DIR: Path = data_dir.joinpath(".temp") + + self.ensure_directories() + + def ensure_directories(self): + required_dirs = [ + self.IMG_DIR, + self.BACKUP_DIR, + self.DEBUG_DIR, + self.MIGRATION_DIR, + self.TEMPLATE_DIR, + self.NEXTCLOUD_DIR, + self.CHOWDOWN_DIR, + self.RECIPE_DATA_DIR, + self.USER_DIR, + ] + + for dir in required_dirs: + dir.mkdir(parents=True, exist_ok=True) diff --git a/mealie/core/settings/settings.py b/mealie/core/settings/settings.py new file mode 100644 index 000000000000..34114dcd1d4e --- /dev/null +++ b/mealie/core/settings/settings.py @@ -0,0 +1,109 @@ +import secrets +from pathlib import Path +from typing import Optional + +from pydantic import BaseSettings + +from .db_providers import AbstractDBProvider, db_provider_factory + + +def determine_secrets(data_dir: Path, production: bool) -> 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: + new_secret = secrets.token_hex(32) + f.write(new_secret) + return new_secret + + +class AppSettings(BaseSettings): + PRODUCTION: bool + BASE_URL: str = "http://localhost:8080" + IS_DEMO: bool = False + API_PORT: int = 9000 + API_DOCS: bool = True + TOKEN_TIME: int = 48 # Time in Hours + SECRET: str + + @property + def DOCS_URL(self) -> str: + return "/docs" if self.API_DOCS else None + + @property + def REDOC_URL(self) -> str: + return "/redoc" if self.API_DOCS else None + + # =============================================== + # Database Configuration + + DB_ENGINE: str = "sqlite" # Options: 'sqlite', 'postgres' + DB_PROVIDER: AbstractDBProvider = None + + @property + def DB_URL(self) -> str: + return self.DB_PROVIDER.db_url + + @property + def DB_URL_PUBLIC(self) -> str: + return self.DB_PROVIDER.db_url_public + + DEFAULT_GROUP: str = "Home" + DEFAULT_EMAIL: str = "changeme@email.com" + DEFAULT_PASSWORD: str = "MyPassword" + + # =============================================== + # Email Configuration + + SMTP_HOST: Optional[str] + SMTP_PORT: Optional[str] = "587" + SMTP_FROM_NAME: Optional[str] = "Mealie" + SMTP_TLS: Optional[bool] = True + SMTP_FROM_EMAIL: Optional[str] + SMTP_USER: Optional[str] + SMTP_PASSWORD: Optional[str] + + @property + def SMTP_ENABLE(self) -> bool: + """Validates all SMTP variables are set""" + required = { + self.SMTP_HOST, + self.SMTP_PORT, + self.SMTP_FROM_NAME, + self.SMTP_TLS, + self.SMTP_FROM_EMAIL, + self.SMTP_USER, + self.SMTP_PASSWORD, + } + + return "" not in required and None not in required + + class Config: + arbitrary_types_allowed = True + + +def app_settings_constructor(data_dir: Path, production: bool, env_file: Path, env_encoding="utf-8") -> AppSettings: + """ + app_settings_constructor is a factory function that returns an AppSettings object. It is used to inject the + required dependencies into the AppSettings object and nested child objects. AppSettings should not be substantiated + directly, but rather through this factory function. + """ + app_settings = AppSettings( + _env_file=env_file, + _env_file_encoding=env_encoding, + **{"SECRET": determine_secrets(data_dir, production)}, + ) + + app_settings.DB_PROVIDER = db_provider_factory( + app_settings.DB_ENGINE or "sqlite", + data_dir, + env_file=env_file, + env_encoding=env_encoding, + ) + + return app_settings diff --git a/mealie/core/settings/static.py b/mealie/core/settings/static.py new file mode 100644 index 000000000000..51e5b5a152e6 --- /dev/null +++ b/mealie/core/settings/static.py @@ -0,0 +1,10 @@ +import os +from pathlib import Path + +APP_VERSION = "v1.0.0b" +DB_VERSION = "v1.0.0b" + +CWD = Path(__file__).parent +BASE_DIR = CWD.parent.parent.parent + +PRODUCTION = os.getenv("PRODUCTION", "True").lower() in ["true", "1"] diff --git a/mealie/db/data_access_layer/access_model_factory.py b/mealie/db/data_access_layer/access_model_factory.py index 8e54f0c945d3..cce480414448 100644 --- a/mealie/db/data_access_layer/access_model_factory.py +++ b/mealie/db/data_access_layer/access_model_factory.py @@ -16,6 +16,7 @@ from mealie.db.models.recipe.tag import Tag from mealie.db.models.settings import SiteSettings from mealie.db.models.sign_up import SignUp from mealie.db.models.users import LongLiveToken, User +from mealie.db.models.users.password_reset import PasswordResetModel from mealie.schema.admin import SiteSettings as SiteSettingsSchema from mealie.schema.cookbook.cookbook import ReadCookBook from mealie.schema.events import Event as EventSchema @@ -27,6 +28,7 @@ from mealie.schema.meal_plan.new_meal import ReadPlanEntry from mealie.schema.recipe import CommentOut, Recipe, RecipeCategoryResponse, RecipeTagResponse from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit from mealie.schema.user import GroupInDB, LongLiveTokenInDB, PrivateUser, SignUpOut +from mealie.schema.user.user_passwords import PrivatePasswordResetToken from ._access_model import AccessModel from .group_access_model import GroupDataAccessModel @@ -117,6 +119,10 @@ class Database: def api_tokens(self) -> AccessModel: return AccessModel(self.session, pk_id, LongLiveToken, LongLiveTokenInDB) + @cached_property + def tokens_pw_reset(self) -> AccessModel[PrivatePasswordResetToken, PasswordResetModel]: + return AccessModel(self.session, pk_token, PasswordResetModel, PrivatePasswordResetToken) + # ================================================================ # Group Items @@ -126,7 +132,7 @@ class Database: @cached_property def group_invite_tokens(self) -> AccessModel: - return AccessModel(self.session, "token", GroupInviteToken, ReadInviteToken) + return AccessModel(self.session, pk_token, GroupInviteToken, ReadInviteToken) @cached_property def group_preferences(self) -> AccessModel: diff --git a/mealie/db/data_access_layer/user_access_model.py b/mealie/db/data_access_layer/user_access_model.py index f9d5ddb1f059..32e897834188 100644 --- a/mealie/db/data_access_layer/user_access_model.py +++ b/mealie/db/data_access_layer/user_access_model.py @@ -5,9 +5,9 @@ from ._access_model import AccessModel class UserDataAccessModel(AccessModel[PrivateUser, User]): - def update_password(self, session, id, password: str): + def update_password(self, id, password: str): entry = self._query_one(match_value=id) entry.update_password(password) - session.commit() + self.session.commit() return self.schema.from_orm(entry) diff --git a/mealie/db/data_initialization/init_users.py b/mealie/db/data_initialization/init_users.py index 979404d97657..dd8a2cd95d1e 100644 --- a/mealie/db/data_initialization/init_users.py +++ b/mealie/db/data_initialization/init_users.py @@ -1,9 +1,10 @@ from mealie.core import root_logger -from mealie.core.config import settings +from mealie.core.config import get_app_settings from mealie.core.security import hash_password from mealie.db.data_access_layer.access_model_factory import Database logger = root_logger.get_logger("init_users") +settings = get_app_settings() def dev_users() -> list[dict]: diff --git a/mealie/db/db_setup.py b/mealie/db/db_setup.py index d731ee4050fc..2339aaa13fff 100644 --- a/mealie/db/db_setup.py +++ b/mealie/db/db_setup.py @@ -2,7 +2,9 @@ import sqlalchemy as sa from sqlalchemy.orm import sessionmaker from sqlalchemy.orm.session import Session -from mealie.core.config import settings +from mealie.core.config import get_app_settings + +settings = get_app_settings() def sql_global_init(db_url: str): diff --git a/mealie/db/init_db.py b/mealie/db/init_db.py index 3435de9c8c22..5f960eb12dff 100644 --- a/mealie/db/init_db.py +++ b/mealie/db/init_db.py @@ -1,5 +1,5 @@ from mealie.core import root_logger -from mealie.core.config import settings +from mealie.core.config import get_app_settings from mealie.db.data_access_layer.access_model_factory import Database from mealie.db.data_initialization.init_units_foods import default_recipe_unit_init from mealie.db.data_initialization.init_users import default_user_init @@ -13,6 +13,8 @@ from mealie.services.group_services.group_utils import create_new_group logger = root_logger.get_logger("init_db") +settings = get_app_settings() + def create_all_models(): import mealie.db.models._all_models # noqa: F401 diff --git a/mealie/db/models/group/group.py b/mealie/db/models/group/group.py index c2aae0a3a600..3aceb685a72f 100644 --- a/mealie/db/models/group/group.py +++ b/mealie/db/models/group/group.py @@ -2,7 +2,7 @@ import sqlalchemy as sa import sqlalchemy.orm as orm from sqlalchemy.orm.session import Session -from mealie.core.config import settings +from mealie.core.config import get_app_settings from mealie.db.models.group.invite_tokens import GroupInviteToken from .._model_base import BaseMixins, SqlAlchemyBase @@ -13,6 +13,8 @@ from .cookbook import CookBook from .mealplan import GroupMealPlan from .preferences import GroupPreferencesModel +settings = get_app_settings() + class Group(SqlAlchemyBase, BaseMixins): __tablename__ = "groups" diff --git a/mealie/db/models/users/__init__.py b/mealie/db/models/users/__init__.py index 1b4636eb762b..586c7516a7c2 100644 --- a/mealie/db/models/users/__init__.py +++ b/mealie/db/models/users/__init__.py @@ -1,2 +1,3 @@ +from .password_reset import * from .user_to_favorite import * from .users import * diff --git a/mealie/db/models/users/password_reset.py b/mealie/db/models/users/password_reset.py new file mode 100644 index 000000000000..e428456d3484 --- /dev/null +++ b/mealie/db/models/users/password_reset.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, ForeignKey, Integer, String, orm + +from .._model_base import BaseMixins, SqlAlchemyBase + + +class PasswordResetModel(SqlAlchemyBase, BaseMixins): + __tablename__ = "password_reset_tokens" + + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + user = orm.relationship("User", back_populates="password_reset_tokens", uselist=False) + token = Column(String(64), unique=True, nullable=False) + + def __init__(self, user_id, token, **_): + self.user_id = user_id + self.token = token diff --git a/mealie/db/models/users/users.py b/mealie/db/models/users/users.py index c9f937fe68bd..77b4f09f1367 100644 --- a/mealie/db/models/users/users.py +++ b/mealie/db/models/users/users.py @@ -1,11 +1,13 @@ from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm -from mealie.core.config import settings +from mealie.core.config import get_app_settings from .._model_base import BaseMixins, SqlAlchemyBase from ..group import Group from .user_to_favorite import users_to_favorites +settings = get_app_settings() + class LongLiveToken(SqlAlchemyBase, BaseMixins): __tablename__ = "long_live_tokens" @@ -48,6 +50,10 @@ class User(SqlAlchemyBase, BaseMixins): "RecipeComment", back_populates="user", cascade="all, delete, delete-orphan", single_parent=True ) + password_reset_tokens = orm.relationship( + "PasswordResetModel", back_populates="user", cascade="all, delete, delete-orphan", single_parent=True + ) + owned_recipes_id = Column(Integer, ForeignKey("recipes.id")) owned_recipes = orm.relationship("RecipeModel", single_parent=True, foreign_keys=[owned_recipes_id]) diff --git a/mealie/routes/admin/admin_about.py b/mealie/routes/admin/admin_about.py index cc285570eedd..ff22a650b552 100644 --- a/mealie/routes/admin/admin_about.py +++ b/mealie/routes/admin/admin_about.py @@ -1,7 +1,8 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm.session import Session -from mealie.core.config import APP_VERSION, get_settings +from mealie.core.config import get_app_settings +from mealie.core.settings.static import APP_VERSION from mealie.db.database import get_database from mealie.db.db_setup import generate_session from mealie.schema.admin.about import AdminAboutInfo, AppStatistics, CheckAppConfig @@ -12,7 +13,7 @@ router = APIRouter(prefix="/about") @router.get("", response_model=AdminAboutInfo) async def get_app_info(): """ Get general application information """ - settings = get_settings() + settings = get_app_settings() return AdminAboutInfo( production=settings.PRODUCTION, @@ -40,7 +41,7 @@ async def get_app_statistics(session: Session = Depends(generate_session)): @router.get("/check", response_model=CheckAppConfig) async def check_app_config(): - settings = get_settings() + settings = get_app_settings() url_set = settings.BASE_URL != "http://localhost:8080" diff --git a/mealie/routes/admin/admin_email.py b/mealie/routes/admin/admin_email.py index 1485d8d8a42b..b3fe20f8c475 100644 --- a/mealie/routes/admin/admin_email.py +++ b/mealie/routes/admin/admin_email.py @@ -1,7 +1,7 @@ from fastapi import APIRouter from fastapi_camelcase import CamelModel -from mealie.core.config import get_settings +from mealie.core.config import get_app_settings from mealie.core.root_logger import get_logger from mealie.services.email import EmailService @@ -26,7 +26,7 @@ class EmailTest(CamelModel): @router.get("", response_model=EmailReady) async def check_email_config(): """ Get general application information """ - settings = get_settings() + settings = get_app_settings() return EmailReady(ready=settings.SMTP_ENABLE) diff --git a/mealie/routes/app/app_about.py b/mealie/routes/app/app_about.py index c459fc01ceb3..488f7c9f12b5 100644 --- a/mealie/routes/app/app_about.py +++ b/mealie/routes/app/app_about.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from mealie.core.config import APP_VERSION, get_settings +from mealie.core.config import APP_VERSION, get_app_settings from mealie.schema.admin.about import AppInfo router = APIRouter(prefix="/about") @@ -9,7 +9,7 @@ router = APIRouter(prefix="/about") @router.get("", response_model=AppInfo) async def get_app_info(): """ Get general application information """ - settings = get_settings() + settings = get_app_settings() return AppInfo( version=APP_VERSION, diff --git a/mealie/routes/backup_routes.py b/mealie/routes/backup_routes.py index 23896b3a76f7..3528f39e39dd 100644 --- a/mealie/routes/backup_routes.py +++ b/mealie/routes/backup_routes.py @@ -5,7 +5,9 @@ from pathlib import Path from fastapi import BackgroundTasks, Depends, File, HTTPException, UploadFile, status from sqlalchemy.orm.session import Session -from mealie.core.config import app_dirs +from mealie.core.config import get_app_dirs + +app_dirs = get_app_dirs() from mealie.core.dependencies import get_current_user from mealie.core.root_logger import get_logger from mealie.core.security import create_file_token diff --git a/mealie/routes/migration_routes.py b/mealie/routes/migration_routes.py index 156e3404a434..6729e4886bd2 100644 --- a/mealie/routes/migration_routes.py +++ b/mealie/routes/migration_routes.py @@ -5,7 +5,9 @@ from typing import List from fastapi import Depends, File, HTTPException, UploadFile, status from sqlalchemy.orm.session import Session -from mealie.core.config import app_dirs +from mealie.core.config import get_app_dirs + +app_dirs = get_app_dirs() from mealie.db.db_setup import generate_session from mealie.routes.routers import AdminAPIRouter from mealie.routes.users.crud import get_logged_in_user diff --git a/mealie/routes/users/__init__.py b/mealie/routes/users/__init__.py index 32ce31e3d2e0..dc4171671c54 100644 --- a/mealie/routes/users/__init__.py +++ b/mealie/routes/users/__init__.py @@ -13,6 +13,7 @@ router.include_router(crud.user_router, prefix=user_prefix, tags=["Users: CRUD"] router.include_router(crud.admin_router, prefix=user_prefix, tags=["Users: CRUD"]) router.include_router(passwords.user_router, prefix=user_prefix, tags=["Users: Passwords"]) +router.include_router(passwords.public_router, prefix=user_prefix, tags=["Users: Passwords"]) router.include_router(images.public_router, prefix=user_prefix, tags=["Users: Images"]) router.include_router(images.user_router, prefix=user_prefix, tags=["Users: Images"]) diff --git a/mealie/routes/users/images.py b/mealie/routes/users/images.py index 5256841c4533..6c6d4e33e6dc 100644 --- a/mealie/routes/users/images.py +++ b/mealie/routes/users/images.py @@ -4,7 +4,9 @@ from fastapi import Depends, File, HTTPException, UploadFile, status from fastapi.responses import FileResponse from fastapi.routing import APIRouter -from mealie.core.config import app_dirs +from mealie.core.config import get_app_dirs + +app_dirs = get_app_dirs() from mealie.core.dependencies import get_current_user from mealie.routes.routers import UserAPIRouter from mealie.routes.users._helpers import assert_user_change_allowed diff --git a/mealie/routes/users/passwords.py b/mealie/routes/users/passwords.py index bc8a991114be..7986a517fe7e 100644 --- a/mealie/routes/users/passwords.py +++ b/mealie/routes/users/passwords.py @@ -1,15 +1,19 @@ -from fastapi import Depends +from fastapi import APIRouter, Depends from sqlalchemy.orm.session import Session -from mealie.core.config import settings +from mealie.core.config import get_app_settings from mealie.core.security import hash_password from mealie.db.database import get_database from mealie.db.db_setup import generate_session from mealie.routes.routers import UserAPIRouter from mealie.schema.user import ChangePassword +from mealie.schema.user.user_passwords import ForgotPassword, ResetPassword from mealie.services.user_services import UserService +from mealie.services.user_services.password_reset_service import PasswordResetService user_router = UserAPIRouter(prefix="") +public_router = APIRouter(prefix="") +settings = get_app_settings() @user_router.put("/{id}/reset-password") @@ -25,3 +29,17 @@ def update_password(password_change: ChangePassword, user_service: UserService = """ Resets the User Password""" return user_service.change_password(password_change) + + +@public_router.post("/forgot-password") +def forgot_password(email: ForgotPassword, session: Session = Depends(generate_session)): + """ Sends an email with a reset link to the user""" + f_service = PasswordResetService(session) + return f_service.send_reset_email(email.email) + + +@public_router.post("/reset-password") +def reset_password(reset_password: ResetPassword, session: Session = Depends(generate_session)): + """ Resets the user password""" + f_service = PasswordResetService(session) + return f_service.reset_password(reset_password.token, reset_password.password) diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py index 864bc9f75b28..05d980e9fd0c 100644 --- a/mealie/schema/recipe/recipe.py +++ b/mealie/schema/recipe/recipe.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field, validator from pydantic.utils import GetterDict from slugify import slugify -from mealie.core.config import app_dirs +from mealie.core.config import get_app_dirs from mealie.db.models.recipe.recipe import RecipeModel from .recipe_asset import RecipeAsset @@ -18,6 +18,8 @@ from .recipe_nutrition import Nutrition from .recipe_settings import RecipeSettings from .recipe_step import RecipeStep +app_dirs = get_app_dirs() + class CreateRecipeByURL(BaseModel): url: str diff --git a/mealie/schema/recipe/recipe_settings.py b/mealie/schema/recipe/recipe_settings.py index bebb51f2250a..77fe07c390e9 100644 --- a/mealie/schema/recipe/recipe_settings.py +++ b/mealie/schema/recipe/recipe_settings.py @@ -1,15 +1,17 @@ from fastapi_camelcase import CamelModel -from mealie.core.config import settings +from mealie.core.config import get_app_settings + +settings = get_app_settings() class RecipeSettings(CamelModel): - public: bool = settings.RECIPE_PUBLIC - show_nutrition: bool = settings.RECIPE_SHOW_NUTRITION - show_assets: bool = settings.RECIPE_SHOW_ASSETS - landscape_view: bool = settings.RECIPE_LANDSCAPE_VIEW - disable_comments: bool = settings.RECIPE_DISABLE_COMMENTS - disable_amount: bool = settings.RECIPE_DISABLE_AMOUNT + public: bool = False + show_nutrition: bool = False + show_assets: bool = False + landscape_view: bool = False + disable_comments: bool = True + disable_amount: bool = True class Config: orm_mode = True diff --git a/mealie/schema/user/user.py b/mealie/schema/user/user.py index b99ba93a38c6..7d51c47beb3d 100644 --- a/mealie/schema/user/user.py +++ b/mealie/schema/user/user.py @@ -5,7 +5,7 @@ from fastapi_camelcase import CamelModel from pydantic.types import constr from pydantic.utils import GetterDict -from mealie.core.config import settings +from mealie.core.config import get_app_settings from mealie.db.models.users import User from mealie.schema.group.group_preferences import ReadGroupPreferences from mealie.schema.recipe import RecipeSummary @@ -13,6 +13,8 @@ from mealie.schema.recipe import RecipeSummary from ..meal_plan import ShoppingListOut from ..recipe import CategoryBase +settings = get_app_settings() + class LoingLiveTokenIn(CamelModel): name: str diff --git a/mealie/schema/user/user_passwords.py b/mealie/schema/user/user_passwords.py new file mode 100644 index 000000000000..eeb1bb3d2850 --- /dev/null +++ b/mealie/schema/user/user_passwords.py @@ -0,0 +1,29 @@ +from fastapi_camelcase import CamelModel + +from .user import PrivateUser + + +class ForgotPassword(CamelModel): + email: str + + +class ValidateResetToken(CamelModel): + token: str + + +class ResetPassword(ValidateResetToken): + email: str + password: str + passwordConfirm: str + + +class SavePasswordResetToken(CamelModel): + user_id: int + token: str + + +class PrivatePasswordResetToken(SavePasswordResetToken): + user: PrivateUser + + class Config: + orm_mode = True diff --git a/mealie/services/_base_http_service/base_http_service.py b/mealie/services/_base_http_service/base_http_service.py index a48dcbddf236..f90a0f9c9e6a 100644 --- a/mealie/services/_base_http_service/base_http_service.py +++ b/mealie/services/_base_http_service/base_http_service.py @@ -5,7 +5,7 @@ from fastapi import BackgroundTasks, Depends, HTTPException, status from pydantic import BaseModel from sqlalchemy.orm.session import Session -from mealie.core.config import get_app_dirs, get_settings +from mealie.core.config import get_app_dirs, get_app_settings from mealie.core.root_logger import get_logger from mealie.db.database import get_database from mealie.db.db_setup import SessionLocal @@ -63,7 +63,7 @@ class BaseHttpService(Generic[T, D], ABC): # Static Globals Dependency Injection self.db = get_database(session) self.app_dirs = get_app_dirs() - self.settings = get_settings() + self.settings = get_app_settings() def _existing_factory(dependency: Type[CLS_DEP]) -> classmethod: def cls_method(cls, item_id: T, deps: CLS_DEP = Depends(dependency)): diff --git a/mealie/services/_base_service/__init__.py b/mealie/services/_base_service/__init__.py index 433f6d7d3be4..334e85977aee 100644 --- a/mealie/services/_base_service/__init__.py +++ b/mealie/services/_base_service/__init__.py @@ -1,7 +1,7 @@ -from mealie.core.config import get_app_dirs, get_settings +from mealie.core.config import get_app_dirs, get_app_settings class BaseService: def __init__(self) -> None: self.app_dirs = get_app_dirs() - self.settings = get_settings() + self.settings = get_app_settings() diff --git a/mealie/services/backups/exports.py b/mealie/services/backups/exports.py index a78d83946a13..dc9da32b1b41 100644 --- a/mealie/services/backups/exports.py +++ b/mealie/services/backups/exports.py @@ -9,10 +9,10 @@ from pathvalidate import sanitize_filename from pydantic.main import BaseModel from mealie.core import root_logger -from mealie.core.config import app_dirs +from mealie.core.config import get_app_dirs + +app_dirs = get_app_dirs() from mealie.db.database import get_database -from mealie.db.db_setup import create_session -from mealie.services.events import create_backup_event logger = root_logger.get_logger() @@ -141,15 +141,3 @@ def backup_all( db_export.export_items(all_notifications, "notifications") return db_export.finish_export() - - -def auto_backup_job(): - for backup in app_dirs.BACKUP_DIR.glob("Auto*.zip"): - backup.unlink() - - templates = [template for template in app_dirs.TEMPLATE_DIR.iterdir()] - session = create_session() - backup_all(session=session, tag="Auto", templates=templates) - logger.info("Auto Backup Called") - create_backup_event("Automated Backup", "Automated backup created", session) - session.close() diff --git a/mealie/services/backups/imports.py b/mealie/services/backups/imports.py index 30cebf72a72e..bf511e3f39d4 100644 --- a/mealie/services/backups/imports.py +++ b/mealie/services/backups/imports.py @@ -7,7 +7,9 @@ from typing import Callable from pydantic.main import BaseModel from sqlalchemy.orm.session import Session -from mealie.core.config import app_dirs +from mealie.core.config import get_app_dirs + +app_dirs = get_app_dirs() from mealie.db.database import get_database from mealie.schema.admin import ( CommentImport, diff --git a/mealie/services/group_services/group_service.py b/mealie/services/group_services/group_service.py index 745e64dd84e8..29ec2acb6619 100644 --- a/mealie/services/group_services/group_service.py +++ b/mealie/services/group_services/group_service.py @@ -1,11 +1,10 @@ from __future__ import annotations -from uuid import uuid4 - from fastapi import Depends, HTTPException, status from mealie.core.dependencies.grouped import UserDeps from mealie.core.root_logger import get_logger +from mealie.core.security import url_safe_token from mealie.schema.group.group_permissions import SetPermissions from mealie.schema.group.group_preferences import UpdateGroupPreferences from mealie.schema.group.invite_token import EmailInitationResponse, EmailInvitation, ReadInviteToken, SaveInviteToken @@ -86,7 +85,7 @@ class GroupSelfService(UserHttpService[int, str]): if not self.user.can_invite: raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User is not allowed to create invite tokens") - token = SaveInviteToken(uses_left=uses, group_id=self.group_id, token=uuid4().hex) + token = SaveInviteToken(uses_left=uses, group_id=self.group_id, token=url_safe_token()) return self.db.group_invite_tokens.create(token) def get_invite_tokens(self) -> list[ReadInviteToken]: diff --git a/mealie/services/image/minify.py b/mealie/services/image/minify.py index 8ae22c0170b2..0708a211818f 100644 --- a/mealie/services/image/minify.py +++ b/mealie/services/image/minify.py @@ -5,10 +5,11 @@ from pathlib import Path from PIL import Image from mealie.core import root_logger -from mealie.core.config import app_dirs +from mealie.core.config import get_app_dirs from mealie.schema.recipe import Recipe logger = root_logger.get_logger() +app_dirs = get_app_dirs() @dataclass diff --git a/mealie/services/migrations/chowdown.py b/mealie/services/migrations/chowdown.py index c54e0370c61f..001db66a5bf2 100644 --- a/mealie/services/migrations/chowdown.py +++ b/mealie/services/migrations/chowdown.py @@ -3,7 +3,9 @@ from typing import Optional from sqlalchemy.orm.session import Session -from mealie.core.config import app_dirs +from mealie.core.config import get_app_dirs + +app_dirs = get_app_dirs() from mealie.schema.admin import MigrationImport from mealie.schema.user.user import PrivateUser from mealie.services.migrations import helpers diff --git a/mealie/services/scheduler/__init__.py b/mealie/services/scheduler/__init__.py index e69de29bb2d1..82fd07360538 100644 --- a/mealie/services/scheduler/__init__.py +++ b/mealie/services/scheduler/__init__.py @@ -0,0 +1,2 @@ +from .scheduler_registry import * +from .scheduler_service import * diff --git a/mealie/services/scheduler/global_scheduler.py b/mealie/services/scheduler/global_scheduler.py deleted file mode 100644 index ec0341d55d6f..000000000000 --- a/mealie/services/scheduler/global_scheduler.py +++ /dev/null @@ -1,7 +0,0 @@ -from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore -from apscheduler.schedulers.background import BackgroundScheduler - -from mealie.core.config import app_dirs, settings - -app_dirs.DATA_DIR.joinpath("scheduler.db").unlink(missing_ok=True) -scheduler = BackgroundScheduler(jobstores={"default": SQLAlchemyJobStore(settings.SCHEDULER_DATABASE)}) diff --git a/mealie/services/scheduler/scheduled_func.py b/mealie/services/scheduler/scheduled_func.py new file mode 100644 index 000000000000..54804986ceb9 --- /dev/null +++ b/mealie/services/scheduler/scheduled_func.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Tuple + +from pydantic import BaseModel + + +@dataclass +class Cron: + hours: int + minutes: int + + @classmethod + def parse(cls, time_str: str) -> Cron: + time = time_str.split(":") + return Cron(hours=int(time[0]), minutes=int(time[1])) + + +@dataclass +class ScheduledFunc(BaseModel): + id: Tuple[str, int] + name: str + hour: int + minutes: int + callback: Callable + + max_instances: int = 1 + replace_existing: bool = True + args: list = [] diff --git a/mealie/services/scheduler/scheduled_jobs.py b/mealie/services/scheduler/scheduled_jobs.py deleted file mode 100644 index 9aac602ed02b..000000000000 --- a/mealie/services/scheduler/scheduled_jobs.py +++ /dev/null @@ -1,124 +0,0 @@ -import datetime - -from apscheduler.schedulers.background import BackgroundScheduler - -from mealie.core import root_logger -from mealie.db.database import get_database -from mealie.db.db_setup import create_session -from mealie.db.models.event import Event -from mealie.schema.user import GroupInDB -from mealie.services.backups.exports import auto_backup_job -from mealie.services.scheduler.global_scheduler import scheduler -from mealie.services.scheduler.scheduler_utils import Cron, cron_parser -from mealie.utils.post_webhooks import post_webhooks - -logger = root_logger.get_logger() - -# TODO Fix Scheduler - - -@scheduler.scheduled_job(trigger="interval", minutes=1440) -def purge_events_database(): - """ - Ran daily. Purges all events after 100 - """ - logger.info("Purging Events in Database") - expiration_days = 7 - limit = datetime.datetime.now() - datetime.timedelta(days=expiration_days) - session = create_session() - session.query(Event).filter(Event.time_stamp <= limit).delete() - session.commit() - session.close() - logger.info("Events Purges") - - -@scheduler.scheduled_job(trigger="interval", minutes=30) -def update_webhook_schedule(): - """ - A scheduled background job that runs every 30 minutes to - poll the database for changes and reschedule the webhook time - """ - session = create_session() - db = get_database(session) - all_groups: list[GroupInDB] = db.groups.get_all() - - for group in all_groups: - - time = cron_parser(group.webhook_time) - job = JOB_STORE.get(group.name) - - if not job: - logger.error(f"No job found for group: {group.name}") - logger.info(f"Creating scheduled task for {group.name}") - JOB_STORE.update(add_group_to_schedule(scheduler, group)) - continue - - scheduler.reschedule_job( - job.scheduled_task.id, - trigger="cron", - hour=time.hours, - minute=time.minutes, - ) - - session.close() - logger.info(scheduler.print_jobs()) - - -class ScheduledFunction: - def __init__( - self, - scheduler: BackgroundScheduler, - function, - cron: Cron, - name: str, - args: list = None, - ) -> None: - self.scheduled_task = scheduler.add_job( - function, - trigger="cron", - name=name, - hour=cron.hours, - minute=cron.minutes, - max_instances=1, - replace_existing=True, - args=args, - ) - - -def add_group_to_schedule(scheduler, group: GroupInDB): - cron = cron_parser(group.webhook_time) - - return { - group.name: ScheduledFunction( - scheduler, - post_webhooks, - cron=cron, - name=group.name, - args=[group.id], - ) - } - - -def init_webhook_schedule(scheduler, job_store: dict): - session = create_session() - db = get_database(session) - all_groups: list[GroupInDB] = db.groups.get_all() - - for group in all_groups: - job_store.update(add_group_to_schedule(scheduler, group)) - - session.close() - - return job_store - - -logger.info("----INIT SCHEDULE OBJECT-----") - -JOB_STORE = { - "backup_job": ScheduledFunction(scheduler, auto_backup_job, Cron(hours=00, minutes=00), "backups"), -} - -JOB_STORE = init_webhook_schedule(scheduler=scheduler, job_store=JOB_STORE) - -logger.info(scheduler.print_jobs()) -scheduler.start() diff --git a/mealie/services/scheduler/scheduler_registry.py b/mealie/services/scheduler/scheduler_registry.py new file mode 100644 index 000000000000..82161980f560 --- /dev/null +++ b/mealie/services/scheduler/scheduler_registry.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import Callable + +from mealie.core import root_logger + +logger = root_logger.get_logger() + + +class SchedulerRegistry: + """ + A container class for registring and removing callbacks for the scheduler. + """ + + _daily: list[Callable] = [] + _hourly: list[Callable] = [] + _minutely: list[Callable] = [] + + def _register(name: str, callbacks: list[Callable], callback: Callable): + for cb in callback: + logger.info(f"Registering {name} callback: {cb.__name__}") + callbacks.append(cb) + + def register_daily(*callbacks: Callable): + SchedulerRegistry._register("daily", SchedulerRegistry._daily, callbacks) + + def remove_daily(callback: Callable): + logger.info(f"Removing daily callback: {callback.__name__}") + SchedulerRegistry._daily.remove(callback) + + def register_hourly(*callbacks: Callable): + SchedulerRegistry._register("daily", SchedulerRegistry._hourly, callbacks) + + def remove_hourly(callback: Callable): + logger.info(f"Removing hourly callback: {callback.__name__}") + SchedulerRegistry._hourly.remove(callback) + + def register_minutely(*callbacks: Callable): + SchedulerRegistry._register("minutely", SchedulerRegistry._minutely, callbacks) + + def remove_minutely(callback: Callable): + logger.info(f"Removing minutely callback: {callback.__name__}") + SchedulerRegistry._minutely.remove(callback) diff --git a/mealie/services/scheduler/scheduler_service.py b/mealie/services/scheduler/scheduler_service.py new file mode 100644 index 000000000000..67dfc82d772a --- /dev/null +++ b/mealie/services/scheduler/scheduler_service.py @@ -0,0 +1,104 @@ +from pathlib import Path + +from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore +from apscheduler.schedulers.background import BackgroundScheduler + +from mealie.core import root_logger +from mealie.core.config import get_app_dirs + +from .scheduled_func import ScheduledFunc +from .scheduler_registry import SchedulerRegistry + +logger = root_logger.get_logger() + +CWD = Path(__file__).parent + +app_dirs = get_app_dirs() +TEMP_DATA = app_dirs.DATA_DIR / ".temp" +SCHEDULER_DB = TEMP_DATA / "scheduler.db" +SCHEDULER_DATABASE = f"sqlite:///{SCHEDULER_DB}" + +MINUTES_DAY = 1440 +MINUTES_15 = 15 +MINUTES_HOUR = 60 + + +class SchedulerService: + """ + SchedulerService is a wrapper class around the APScheduler library. It is resonpseible for interacting with the scheduler + and scheduling events. This includes the interval events that are registered in the SchedulerRegistry as well as cron events + that are used for sending webhooks. In most cases, unless the the schedule is dynamic, events should be registered with the + SchedulerRegistry. See app.py for examples. + """ + + _scheduler: BackgroundScheduler = None + # Not Sure if this is still needed? + # _job_store: dict[str, ScheduledFunc] = {} + + def start(): + # Preclean + SCHEDULER_DB.unlink(missing_ok=True) + + # Scaffold + TEMP_DATA.mkdir(parents=True, exist_ok=True) + + # Register Interval Jobs and Start Scheduler + SchedulerService._scheduler = BackgroundScheduler(jobstores={"default": SQLAlchemyJobStore(SCHEDULER_DATABASE)}) + SchedulerService._scheduler.add_job(run_daily, "interval", minutes=MINUTES_DAY, id="Daily Interval Jobs") + SchedulerService._scheduler.add_job(run_hourly, "interval", minutes=MINUTES_HOUR, id="Hourly Interval Jobs") + SchedulerService._scheduler.add_job(run_minutely, "interval", minutes=MINUTES_15, id="Regular Interval Jobs") + SchedulerService._scheduler.start() + + @classmethod + @property + def scheduler(cls) -> BackgroundScheduler: + return SchedulerService._scheduler + + def add_cron_job(job_func: ScheduledFunc): + SchedulerService.scheduler.add_job( + job_func.callback, + trigger="cron", + name=job_func.id, + hour=job_func.hour, + minute=job_func.minutes, + max_instances=job_func.max_instances, + replace_existing=job_func.replace_existing, + args=job_func.args, + ) + + # SchedulerService._job_store[job_func.id] = job_func + + def update_cron_job(job_func: ScheduledFunc): + SchedulerService.scheduler.reschedule_job( + job_func.id, + trigger="cron", + hour=job_func.hour, + minute=job_func.minutes, + ) + + # SchedulerService._job_store[job_func.id] = job_func + + +def _scheduled_task_wrapper(callable): + try: + callable() + except Exception as e: + logger.error(f"Error in scheduled task func='{callable.__name__}': exception='{e}'") + + +def run_daily(): + logger.info("Running daily callbacks") + for func in SchedulerRegistry._daily: + _scheduled_task_wrapper(func) + + +def run_hourly(): + logger.info("Running hourly callbacks") + for func in SchedulerRegistry._hourly: + _scheduled_task_wrapper(func) + + +def run_minutely(): + logger.info("Running minutely callbacks") + for func in SchedulerRegistry._minutely: + _scheduled_task_wrapper(func) diff --git a/mealie/services/scheduler/scheduler_utils.py b/mealie/services/scheduler/scheduler_utils.py deleted file mode 100644 index e6bb65fc5e3c..000000000000 --- a/mealie/services/scheduler/scheduler_utils.py +++ /dev/null @@ -1,8 +0,0 @@ -import collections - -Cron = collections.namedtuple("Cron", "hours minutes") - - -def cron_parser(time_str: str) -> Cron: - time = time_str.split(":") - return Cron(hours=int(time[0]), minutes=int(time[1])) diff --git a/mealie/services/scheduler/tasks/__init__.py b/mealie/services/scheduler/tasks/__init__.py new file mode 100644 index 000000000000..12974994a6bc --- /dev/null +++ b/mealie/services/scheduler/tasks/__init__.py @@ -0,0 +1,14 @@ +from .auto_backup import * +from .purge_events import * +from .purge_password_reset import * +from .purge_registration import * +from .webhooks import * + +""" +Tasks Package + +Common recurring tasks for the server to perform. Tasks here are registered to the SchedulerRegistry class +in the app.py file as a post-startup task. This is done to ensure that the tasks are run after the server has +started up and the Scheduler object is only avaiable to a single worker. + +""" diff --git a/mealie/services/scheduler/tasks/auto_backup.py b/mealie/services/scheduler/tasks/auto_backup.py new file mode 100644 index 000000000000..3cda20e8228e --- /dev/null +++ b/mealie/services/scheduler/tasks/auto_backup.py @@ -0,0 +1,22 @@ +from mealie.core import root_logger +from mealie.core.config import get_app_dirs + +app_dirs = get_app_dirs() +from mealie.db.db_setup import create_session +from mealie.services.backups.exports import backup_all +from mealie.services.events import create_backup_event + +logger = root_logger.get_logger() + + +def auto_backup(): + for backup in app_dirs.BACKUP_DIR.glob("Auto*.zip"): + backup.unlink() + + templates = [template for template in app_dirs.TEMPLATE_DIR.iterdir()] + session = create_session() + backup_all(session=session, tag="Auto", templates=templates) + logger.info("generating automated backup") + create_backup_event("Automated Backup", "Automated backup created", session) + session.close() + logger.info("automated backup generated") diff --git a/mealie/services/scheduler/tasks/purge_events.py b/mealie/services/scheduler/tasks/purge_events.py new file mode 100644 index 000000000000..c5d4f88c5044 --- /dev/null +++ b/mealie/services/scheduler/tasks/purge_events.py @@ -0,0 +1,19 @@ +import datetime + +from mealie.core import root_logger +from mealie.db.db_setup import create_session +from mealie.db.models.event import Event + +logger = root_logger.get_logger() + + +def purge_events_database(): + """Purges all events after 100""" + logger.info("purging events in database") + expiration_days = 7 + limit = datetime.datetime.now() - datetime.timedelta(days=expiration_days) + session = create_session() + session.query(Event).filter(Event.time_stamp <= limit).delete() + session.commit() + session.close() + logger.info("events purges") diff --git a/mealie/services/scheduler/tasks/purge_password_reset.py b/mealie/services/scheduler/tasks/purge_password_reset.py new file mode 100644 index 000000000000..fdbeacfa863a --- /dev/null +++ b/mealie/services/scheduler/tasks/purge_password_reset.py @@ -0,0 +1,20 @@ +import datetime + +from mealie.core import root_logger +from mealie.db.db_setup import create_session +from mealie.db.models.users.password_reset import PasswordResetModel + +logger = root_logger.get_logger() + +MAX_DAYS_OLD = 2 + + +def purge_password_reset_tokens(): + """Purges all events after x days""" + logger.info("purging password reset tokens") + limit = datetime.datetime.now() - datetime.timedelta(days=MAX_DAYS_OLD) + session = create_session() + session.query(PasswordResetModel).filter(PasswordResetModel.created_at <= limit).delete() + session.commit() + session.close() + logger.info("password reset tokens purges") diff --git a/mealie/services/scheduler/tasks/purge_registration.py b/mealie/services/scheduler/tasks/purge_registration.py new file mode 100644 index 000000000000..8a093eee5906 --- /dev/null +++ b/mealie/services/scheduler/tasks/purge_registration.py @@ -0,0 +1,20 @@ +import datetime + +from mealie.core import root_logger +from mealie.db.db_setup import create_session +from mealie.db.models.group import GroupInviteToken + +logger = root_logger.get_logger() + +MAX_DAYS_OLD = 4 + + +def purge_group_registration(): + """Purges all events after x days""" + logger.info("purging expired registration tokens") + limit = datetime.datetime.now() - datetime.timedelta(days=MAX_DAYS_OLD) + session = create_session() + session.query(GroupInviteToken).filter(GroupInviteToken.created_at <= limit).delete() + session.commit() + session.close() + logger.info("registration token purged") diff --git a/mealie/services/scheduler/tasks/webhooks.py b/mealie/services/scheduler/tasks/webhooks.py new file mode 100644 index 000000000000..4ea6b69414cd --- /dev/null +++ b/mealie/services/scheduler/tasks/webhooks.py @@ -0,0 +1,58 @@ +import json + +import requests +from sqlalchemy.orm.session import Session + +from mealie.core import root_logger +from mealie.db.database import get_database +from mealie.db.db_setup import create_session +from mealie.schema.group.webhook import ReadWebhook + +from ..scheduled_func import Cron, ScheduledFunc +from ..scheduler_service import SchedulerService + +logger = root_logger.get_logger() + + +def post_webhooks(webhook_id: int, session: Session = None): + session = session or create_session() + db = get_database(session) + webhook: ReadWebhook = db.webhooks.get_one(webhook_id) + + if not webhook.enabled: + logger.info(f"Skipping webhook {webhook_id}. reasons: is disabled") + return + + todays_recipe = db.meals.get_today(webhook.group_id) + + if not todays_recipe: + return + + payload = json.loads([x.json(by_alias=True) for x in todays_recipe]) + response = requests.post(webhook.url, json=payload) + + if response.status_code != 200: + logger.error(f"Error posting webhook to {webhook.url} ({response.status_code})") + + session.close() + + +def update_group_webhooks(): + session = create_session() + db = get_database(session) + + webhooks: list[ReadWebhook] = db.webhooks.get_all() + + for webhook in webhooks: + cron = Cron.parse(webhook.time) + + job_func = ScheduledFunc( + id=webhook.id, + name=f"Group {webhook.group_id} webhook", + callback=post_webhooks, + hour=cron.hours, + minute=cron.minutes, + args=(webhook.id), + ) + + SchedulerService.add_cron_job(job_func) diff --git a/mealie/services/scraper/ingredient_nlp/processor.py b/mealie/services/scraper/ingredient_nlp/processor.py index 1e371e1153d5..c879f3a9c9f1 100644 --- a/mealie/services/scraper/ingredient_nlp/processor.py +++ b/mealie/services/scraper/ingredient_nlp/processor.py @@ -6,7 +6,7 @@ from typing import Optional from pydantic import BaseModel, validator -from mealie.core.config import settings +from mealie.core.config import get_app_settings from mealie.schema.recipe import RecipeIngredient from mealie.schema.recipe.recipe_ingredient import CreateIngredientFood, CreateIngredientUnit @@ -15,6 +15,8 @@ from .pre_processor import pre_process_string CWD = Path(__file__).parent MODEL_PATH = CWD / "model.crfmodel" +settings = get_app_settings() + INGREDIENT_TEXT = [ "2 tablespoons honey", diff --git a/mealie/services/scraper/open_graph.py b/mealie/services/scraper/open_graph.py index 529948dc6645..09a258266ecf 100644 --- a/mealie/services/scraper/open_graph.py +++ b/mealie/services/scraper/open_graph.py @@ -4,7 +4,9 @@ import extruct from slugify import slugify from w3lib.html import get_base_url -from mealie.core.config import app_dirs +from mealie.core.config import get_app_dirs + +app_dirs = get_app_dirs() LAST_JSON = app_dirs.DEBUG_DIR.joinpath("last_recipe.json") diff --git a/mealie/services/scraper/scraper.py b/mealie/services/scraper/scraper.py index 491e90090c29..e8152ecb19a6 100644 --- a/mealie/services/scraper/scraper.py +++ b/mealie/services/scraper/scraper.py @@ -8,7 +8,9 @@ from fastapi import HTTPException, status from recipe_scrapers import NoSchemaFoundInWildMode, SchemaScraperFactory, WebsiteNotImplementedError, scrape_me from slugify import slugify -from mealie.core.config import app_dirs +from mealie.core.config import get_app_dirs + +app_dirs = get_app_dirs() from mealie.core.root_logger import get_logger from mealie.schema.recipe import Recipe, RecipeStep from mealie.services.image.image import scrape_image diff --git a/mealie/services/user_services/password_reset_service.py b/mealie/services/user_services/password_reset_service.py new file mode 100644 index 000000000000..ab70577239b4 --- /dev/null +++ b/mealie/services/user_services/password_reset_service.py @@ -0,0 +1,66 @@ +from fastapi import HTTPException, status +from sqlalchemy.orm.session import Session + +from mealie.core.root_logger import get_logger +from mealie.core.security import hash_password, url_safe_token +from mealie.db.database import get_database +from mealie.schema.user.user_passwords import SavePasswordResetToken +from mealie.services._base_service import BaseService +from mealie.services.email import EmailService + +logger = get_logger(__name__) + + +class PasswordResetService(BaseService): + def __init__(self, session: Session) -> None: + self.db = get_database(session) + super().__init__() + + def generate_reset_token(self, email: str) -> SavePasswordResetToken: + user = self.db.users.get_one(email, "email") + + if user is None: + logger.error(f"failed to create password reset for {email=}: user doesn't exists") + # Do not raise exception here as we don't want to confirm to the client that the Email doens't exists + return + + # Create Reset Token + token = url_safe_token() + + save_token = SavePasswordResetToken(user_id=user.id, token=token) + + return self.db.tokens_pw_reset.create(save_token) + + def send_reset_email(self, email: str): + token_entry = self.generate_reset_token(email) + + # Send Email + email_servive = EmailService() + reset_url = f"{self.settings.BASE_URL}/reset-password?token={token_entry.token}" + + try: + email_servive.send_forgot_password(email, reset_url) + except Exception as e: + logger.error(f"failed to send reset email: {e}") + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to send reset email") + + def reset_password(self, token: str, new_password: str): + # Validate Token + token_entry = self.db.tokens_pw_reset.get_one(token, "token") + + if token_entry is None: + logger.error("failed to reset password: invalid token") + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid token") + + user = self.db.users.get_one(token_entry.user_id) + # Update Password + password_hash = hash_password(new_password) + + new_user = self.db.users.update_password(user.id, password_hash) + # Confirm Password + if new_user.password != password_hash: + logger.error("failed to reset password: invalid password") + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid password") + + # Delete Token from DB + self.db.tokens_pw_reset.delete(token_entry.token) diff --git a/mealie/services/user_services/registration_service.py b/mealie/services/user_services/registration_service.py index f9ac2cd80636..84aea85d5fc3 100644 --- a/mealie/services/user_services/registration_service.py +++ b/mealie/services/user_services/registration_service.py @@ -30,7 +30,7 @@ class RegistrationService(PublicHttpService[int, str]): group = self._register_new_group() elif registration.group_token and registration.group_token != "": - token_entry = self.db.group_invite_tokens.get(registration.group_token) + token_entry = self.db.group_invite_tokens.get_one(registration.group_token) if not token_entry: raise HTTPException(status.HTTP_400_BAD_REQUEST, {"message": "Invalid group token"}) group = self.db.groups.get(token_entry.group_id) diff --git a/mealie/utils/unzip.py b/mealie/utils/unzip.py index 017d38dba7cb..1a2c9dd886b8 100644 --- a/mealie/utils/unzip.py +++ b/mealie/utils/unzip.py @@ -2,7 +2,9 @@ import tempfile import zipfile from pathlib import Path -from mealie.core.config import app_dirs +from mealie.core.config import get_app_dirs + +app_dirs = get_app_dirs() def unpack_zip(selection: Path) -> tempfile.TemporaryDirectory: diff --git a/template.env b/template.env index b98cde18f8de..e2e66e8599b0 100644 --- a/template.env +++ b/template.env @@ -32,10 +32,3 @@ TOKEN_TIME=24 # SMTP_USER="" # SMTP_PASSWORD="" -# Default Recipe Settings -RECIPE_PUBLIC=False -RECIPE_SHOW_NUTRITION=False -RECIPE_SHOW_ASSETS=False -RECIPE_LANDSCAPE_VIEW=False -RECIPE_DISABLE_COMMENTS=False -RECIPE_DISABLE_AMOUNT=False \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 47f9aec00a12..fb20756bc990 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,4 @@ -from tests.pre_test import DB_URL, settings # isort:skip +from tests.pre_test import settings # isort:skip import json @@ -33,7 +33,10 @@ def api_client(): yield TestClient(app) - DB_URL.unlink(missing_ok=True) + try: + settings.DB_PROVIDER.db_path.unlink() # Handle SQLite Provider + except Exception: + pass @fixture(scope="session") @@ -94,7 +97,7 @@ def g2_user(admin_token, api_client: requests, api_routes: AppRoutes): user_id = json.loads(self_response.text).get("id") group_id = json.loads(self_response.text).get("groupId") - return TestUser(user_id=user_id, group_id=group_id, token=token) + return TestUser(user_id=user_id, group_id=group_id, token=token, email=create_data["email"]) @fixture(scope="session") @@ -149,7 +152,9 @@ def unique_user(api_client: TestClient, api_routes: AppRoutes): assert token is not None try: - yield TestUser(group_id=user_data.get("groupId"), user_id=user_data.get("id"), token=token) + yield TestUser( + group_id=user_data.get("groupId"), user_id=user_data.get("id"), email=user_data.get("email"), token=token + ) finally: # TODO: Delete User after test pass @@ -170,7 +175,9 @@ def admin_user(api_client: TestClient, api_routes: AppRoutes): assert user_data.get("id") is not None try: - yield TestUser(group_id=user_data.get("groupId"), user_id=user_data.get("id"), token=token) + yield TestUser( + group_id=user_data.get("groupId"), user_id=user_data.get("id"), email=user_data.get("email"), token=token + ) finally: # TODO: Delete User after test pass diff --git a/tests/integration_tests/test_migration_routes.py b/tests/integration_tests/test_migration_routes.py index dd407fa66386..2de636cafdca 100644 --- a/tests/integration_tests/test_migration_routes.py +++ b/tests/integration_tests/test_migration_routes.py @@ -5,7 +5,9 @@ from pathlib import Path import pytest from fastapi.testclient import TestClient -from mealie.core.config import app_dirs +from mealie.core.config import get_app_dirs + +app_dirs = get_app_dirs() from tests.app_routes import AppRoutes from tests.test_config import TEST_CHOWDOWN_DIR, TEST_NEXTCLOUD_DIR diff --git a/tests/integration_tests/user_tests/test_user_images.py b/tests/integration_tests/user_tests/test_user_images.py index 1f9d6c5014c7..782ed9b86934 100644 --- a/tests/integration_tests/user_tests/test_user_images.py +++ b/tests/integration_tests/user_tests/test_user_images.py @@ -2,7 +2,9 @@ from pathlib import Path from fastapi.testclient import TestClient -from mealie.core.config import app_dirs +from mealie.core.config import get_app_dirs + +app_dirs = get_app_dirs() from tests.app_routes import AppRoutes diff --git a/tests/integration_tests/user_tests/test_user_password_reset_service.py b/tests/integration_tests/user_tests/test_user_password_reset_service.py new file mode 100644 index 000000000000..664e5d0ddd6b --- /dev/null +++ b/tests/integration_tests/user_tests/test_user_password_reset_service.py @@ -0,0 +1,52 @@ +import json + +from fastapi.testclient import TestClient + +from mealie.db.db_setup import create_session +from mealie.services.user_services.password_reset_service import PasswordResetService +from tests.utils.factories import random_string +from tests.utils.fixture_schemas import TestUser + + +class Routes: + base = "/api/users/reset-password" + + login = "/api/auth/token" + self = "/api/users/self" + + +def test_password_reset(api_client: TestClient, unique_user: TestUser): + session = create_session() + + service = PasswordResetService(session) + token = service.generate_reset_token(unique_user.email) + + new_password = random_string(15) + + payload = { + "token": token.token, + "email": unique_user.email, + "password": new_password, + "passwordConfirm": new_password, + } + + # Test successful password reset + response = api_client.post(Routes.base, json=payload) + assert response.status_code == 200 + + # Test Login + form_data = {"username": unique_user.email, "password": new_password} + response = api_client.post(Routes.login, form_data) + assert response.status_code == 200 + + # Test Token + new_token = json.loads(response.text).get("access_token") + token = {"Authorization": f"Bearer {new_token}"} + response = api_client.get(Routes.self, headers=token) + assert response.status_code == 200 + + session.close() + + # Test successful password reset + response = api_client.post(Routes.base, json=payload) + assert response.status_code == 400 diff --git a/tests/pre_test.py b/tests/pre_test.py index e665cdaec06f..714d48412cce 100644 --- a/tests/pre_test.py +++ b/tests/pre_test.py @@ -1,8 +1,10 @@ -from mealie.core.config import determine_sqlite_path, settings +from mealie.core.config import get_app_dirs, get_app_settings +from mealie.core.settings.db_providers import SQLiteProvider -DB_URL = determine_sqlite_path(path=True, suffix="test") -DB_URL.unlink(missing_ok=True) +settings = get_app_settings() +app_dirs = get_app_dirs() +settings.DB_PROVIDER = SQLiteProvider(data_dir=app_dirs.DATA_DIR, prefix="test_") if settings.DB_ENGINE != "postgres": # Monkeypatch Database Testing - settings.DB_URL = determine_sqlite_path(path=False, suffix="test") + settings.DB_PROVIDER = SQLiteProvider(data_dir=app_dirs.DATA_DIR, prefix="test_") diff --git a/tests/unit_tests/test_email_service.py b/tests/unit_tests/services/test_email_service.py similarity index 84% rename from tests/unit_tests/test_email_service.py rename to tests/unit_tests/services/test_email_service.py index ea15477e31a4..c5fb29c94d41 100644 --- a/tests/unit_tests/test_email_service.py +++ b/tests/unit_tests/services/test_email_service.py @@ -1,6 +1,6 @@ import pytest -from mealie.core.config import AppSettings +from mealie.core.config import get_app_settings from mealie.services.email import EmailService from mealie.services.email.email_senders import ABCEmailSender @@ -27,8 +27,8 @@ class TestEmailSender(ABCEmailSender): def patch_env(monkeypatch): monkeypatch.setenv("SMTP_HOST", "email.mealie.io") - monkeypatch.setenv("SMTP_PORT", 587) - monkeypatch.setenv("SMTP_TLS", True) + monkeypatch.setenv("SMTP_PORT", "587") + monkeypatch.setenv("SMTP_TLS", "True") monkeypatch.setenv("SMTP_FROM_NAME", "Mealie") monkeypatch.setenv("SMTP_FROM_EMAIL", "mealie@mealie.io") monkeypatch.setenv("SMTP_USER", "mealie@mealie.io") @@ -39,13 +39,15 @@ def patch_env(monkeypatch): def email_service(monkeypatch) -> EmailService: patch_env(monkeypatch) email_service = EmailService(TestEmailSender()) - email_service.settings = AppSettings() + get_app_settings.cache_clear() + email_service.settings = get_app_settings() return email_service def test_email_disabled(): email_service = EmailService(TestEmailSender()) - email_service.settings = AppSettings() + get_app_settings.cache_clear() + email_service.settings = get_app_settings() success = email_service.send_test_email(FAKE_ADDRESS) assert not success diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py index 837e0fc36471..eba46d5f0a91 100644 --- a/tests/unit_tests/test_config.py +++ b/tests/unit_tests/test_config.py @@ -1,7 +1,6 @@ import re -from pathlib import Path -from mealie.core.config import CWD, DATA_DIR, AppDirectories, AppSettings, determine_data_dir, determine_secrets +from mealie.core.config import get_app_settings def test_default_settings(monkeypatch): @@ -14,12 +13,11 @@ def test_default_settings(monkeypatch): monkeypatch.delenv("API_DOCS", raising=False) monkeypatch.delenv("IS_DEMO", raising=False) - app_settings = AppSettings() + get_app_settings.cache_clear() + app_settings = get_app_settings() assert app_settings.DEFAULT_GROUP == "Home" assert app_settings.DEFAULT_PASSWORD == "MyPassword" - assert app_settings.POSTGRES_USER == "mealie" - assert app_settings.POSTGRES_PASSWORD == "mealie" assert app_settings.API_PORT == 9000 assert app_settings.API_DOCS is True assert app_settings.IS_DEMO is False @@ -31,17 +29,14 @@ def test_default_settings(monkeypatch): def test_non_default_settings(monkeypatch): monkeypatch.setenv("DEFAULT_GROUP", "Test Group") monkeypatch.setenv("DEFAULT_PASSWORD", "Test Password") - monkeypatch.setenv("POSTGRES_USER", "mealie-test") - monkeypatch.setenv("POSTGRES_PASSWORD", "mealie-test") monkeypatch.setenv("API_PORT", "8000") monkeypatch.setenv("API_DOCS", "False") - app_settings = AppSettings() + get_app_settings.cache_clear() + app_settings = get_app_settings() assert app_settings.DEFAULT_GROUP == "Test Group" assert app_settings.DEFAULT_PASSWORD == "Test Password" - assert app_settings.POSTGRES_USER == "mealie-test" - assert app_settings.POSTGRES_PASSWORD == "mealie-test" assert app_settings.API_PORT == 8000 assert app_settings.API_DOCS is False @@ -51,36 +46,22 @@ def test_non_default_settings(monkeypatch): def test_default_connection_args(monkeypatch): monkeypatch.setenv("DB_ENGINE", "sqlite") - app_settings = AppSettings() - assert re.match(r"sqlite:////.*mealie/dev/data/mealie_v1.0.0b.db", app_settings.DB_URL) + get_app_settings.cache_clear() + app_settings = get_app_settings() + assert re.match(r"sqlite:////.*mealie/dev/data/*mealie*.db", app_settings.DB_URL) def test_pg_connection_args(monkeypatch): monkeypatch.setenv("DB_ENGINE", "postgres") monkeypatch.setenv("POSTGRES_SERVER", "postgres") - app_settings = AppSettings() + get_app_settings.cache_clear() + app_settings = get_app_settings() assert app_settings.DB_URL == "postgresql://mealie:mealie@postgres:5432/mealie" -def test_secret_generation(tmp_path): - app_dirs = AppDirectories(CWD, DATA_DIR) - assert determine_secrets(app_dirs.DATA_DIR, False) == "shh-secret-test-key" - assert determine_secrets(app_dirs.DATA_DIR, True) != "shh-secret-test-key" - - assert determine_secrets(tmp_path, True) != "shh-secret-test-key" - - -def test_set_data_dir(): - global CWD - PROD_DIR = Path("/app/data") - DEV_DIR = CWD.parent.parent.joinpath("dev", "data") - - assert determine_data_dir(True) == PROD_DIR - assert determine_data_dir(False) == DEV_DIR - - def test_smtp_enable(monkeypatch): - app_settings = AppSettings() + get_app_settings.cache_clear() + app_settings = get_app_settings() assert app_settings.SMTP_ENABLE is False monkeypatch.setenv("SMTP_HOST", "email.mealie.io") @@ -91,5 +72,7 @@ def test_smtp_enable(monkeypatch): monkeypatch.setenv("SMTP_USER", "mealie@mealie.io") monkeypatch.setenv("SMTP_PASSWORD", "mealie-password") - app_settings = AppSettings() + get_app_settings.cache_clear() + app_settings = get_app_settings() + assert app_settings.SMTP_ENABLE is True diff --git a/tests/utils/fixture_schemas.py b/tests/utils/fixture_schemas.py index 856876017722..931848622c8e 100644 --- a/tests/utils/fixture_schemas.py +++ b/tests/utils/fixture_schemas.py @@ -4,6 +4,7 @@ from typing import Any @dataclass class TestUser: + email: str user_id: int group_id: int token: Any