mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-09 03:04:54 -04:00
feat(frontend): ✨ Fix scheduler, forgot password flow, and minor bug fixes (#725)
* feat(frontend): 💄 add recipe title * fix(frontend): 🐛 fixes #722 side-bar issue * feat(frontend): ✨ Add page titles to all pages * minor cleanup * refactor(backend): ♻️ rewrite scheduler to be more modulare and work * feat(frontend): ✨ start password reset functionality * refactor(backend): ♻️ refactor application settings to facilitate dependency injection * refactor(backend): 🔥 remove RECIPE_SETTINGS env variables in favor of group settings * formatting * refactor(backend): ♻️ align naming convention * feat(backend): ✨ password reset * test(backend): ✅ password reset * feat(frontend): ✨ self-service password reset * purge password schedule * update user creation for tests Co-authored-by: Hayden <hay-kot@pm.me>
This commit is contained in:
parent
d1f0441252
commit
2e9026f9ea
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,6 +3,7 @@
|
||||
*__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.temp
|
||||
|
||||
# frontend/.env.development
|
||||
docs/site/
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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
|
||||
|
@ -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<CheckEmailResponse>(routes.base);
|
||||
}
|
||||
|
||||
test(payload: TestEmailPayload) {
|
||||
return this.requests.post<TestEmailResponse>(routes.base, payload);
|
||||
test(payload: EmailPayload) {
|
||||
return this.requests.post<EmailResponse>(routes.base, payload);
|
||||
}
|
||||
|
||||
sendInvitation(payload: InvitationEmail) {
|
||||
return this.requests.post<InvitationEmailResponse>(routes.invitation, payload);
|
||||
return this.requests.post<EmailResponse>(routes.invitation, payload);
|
||||
}
|
||||
|
||||
sendForgotPassword(payload: EmailPayload) {
|
||||
return this.requests.post<EmailResponse>(routes.forgotPassword, payload);
|
||||
}
|
||||
}
|
||||
|
@ -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<UserOut, UserIn> {
|
||||
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<ResponseToken>(routes.usersApiTokens, tokenName);
|
||||
}
|
||||
@ -71,4 +75,8 @@ export class UserApi extends BaseCRUDAPI<UserOut, UserIn> {
|
||||
if (!id || id === undefined) return;
|
||||
return `/api/users/${id}/image`;
|
||||
}
|
||||
|
||||
async resetPassword(payload: PasswordResetPayload) {
|
||||
return await this.requests.post(routes.passwordReset, payload);
|
||||
}
|
||||
}
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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<string> = 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<T extends string | string[]>(name: string, defaultValue?: T) {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
return computed<any>({
|
||||
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 } });
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -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": {
|
||||
|
@ -27,7 +27,7 @@
|
||||
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
|
||||
import { defineComponent, ref, useContext, onMounted } from "@nuxtjs/composition-api";
|
||||
import AppHeader from "@/components/Layout/AppHeader.vue";
|
||||
import AppSidebar from "@/components/Layout/AppSidebar.vue";
|
||||
import TheSnackbar from "~/components/Layout/TheSnackbar.vue";
|
||||
@ -38,9 +38,12 @@ export default defineComponent({
|
||||
auth: true,
|
||||
setup() {
|
||||
// @ts-ignore - $globals not found in type definition
|
||||
const { $globals, i18n } = useContext();
|
||||
const { $globals, i18n, $vuetify } = useContext();
|
||||
|
||||
const sidebar = ref(null);
|
||||
const sidebar = ref<boolean | null>(null);
|
||||
onMounted(() => {
|
||||
sidebar.value = !$vuetify.breakpoint.md;
|
||||
});
|
||||
|
||||
const topLinks = [
|
||||
{
|
||||
|
@ -9,9 +9,8 @@
|
||||
secondary-header="Cookbooks"
|
||||
:secondary-links="cookbookLinks || []"
|
||||
:bottom-links="isAdmin ? bottomLink : []"
|
||||
@input="sidebar = !sidebar"
|
||||
>
|
||||
<v-menu offset-y nudge-bottom="5" open-on-hover close-delay="50" nudge-right="15">
|
||||
<v-menu offset-y nudge-bottom="5" close-delay="50" nudge-right="15">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn rounded large class="ml-2 mt-3" v-bind="attrs" v-on="on">
|
||||
<v-icon left large color="primary">
|
||||
@ -62,7 +61,7 @@
|
||||
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
import { computed, defineComponent, onMounted, ref, useContext } from "@nuxtjs/composition-api";
|
||||
import AppHeader from "@/components/Layout/AppHeader.vue";
|
||||
import AppSidebar from "@/components/Layout/AppSidebar.vue";
|
||||
import TheSnackbar from "@/components/Layout/TheSnackbar.vue";
|
||||
@ -84,6 +83,12 @@ export default defineComponent({
|
||||
console.log("toggleDark");
|
||||
}
|
||||
|
||||
const sidebar = ref<Boolean | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
sidebar.value = !$vuetify.breakpoint.md;
|
||||
});
|
||||
|
||||
const cookbookLinks = computed(() => {
|
||||
if (!cookbooks.value) return [];
|
||||
return cookbooks.value.map((cookbook) => {
|
||||
@ -94,11 +99,10 @@ export default defineComponent({
|
||||
};
|
||||
});
|
||||
});
|
||||
return { cookbookLinks, isAdmin, toggleDark };
|
||||
return { cookbookLinks, isAdmin, toggleDark, sidebar };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
sidebar: null,
|
||||
createLinks: [
|
||||
{
|
||||
icon: this.$globals.icons.link,
|
||||
|
@ -29,7 +29,7 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
layout: "empty",
|
||||
layout: "basic",
|
||||
props: {
|
||||
error: {
|
||||
type: Object,
|
||||
|
@ -1,7 +1,7 @@
|
||||
export default {
|
||||
// Global page headers: https://go.nuxtjs.dev/config-head
|
||||
head: {
|
||||
titleTemplate: "%s - Mealie",
|
||||
titleTemplate: "%s | Mealie",
|
||||
title: "Home",
|
||||
meta: [
|
||||
{ charset: "utf-8" },
|
||||
|
@ -99,6 +99,11 @@ export default defineComponent({
|
||||
appInfo,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("about.about") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -146,6 +146,11 @@ export default defineComponent({
|
||||
backupsFileNameDownload,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("sidebar.backups") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -146,6 +146,11 @@ export default defineComponent({
|
||||
|
||||
return { statistics, events, deleteEvents, deleteEvent };
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("sidebar.dashboard") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -111,5 +111,10 @@ export default defineComponent({
|
||||
|
||||
return { ...toRefs(state), groups, refreshAllGroups, deleteGroup, createGroup };
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("group.manage-groups") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
@ -153,6 +153,11 @@ export default defineComponent({
|
||||
},
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("sidebar.manage-users") as string,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
updateUser(userData: any) {
|
||||
this.updateMode = true;
|
||||
|
@ -17,6 +17,11 @@ export default defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("settings.migrations") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -156,6 +156,11 @@ export default defineComponent({
|
||||
testEmail,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("settings.site-settings") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -12,6 +12,11 @@ export default defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("sidebar.categories") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -113,6 +113,11 @@ export default defineComponent({
|
||||
workingFoodData,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: "Foods",
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -215,6 +215,11 @@ export default defineComponent({
|
||||
notificationTypes,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("events.notification") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -12,6 +12,11 @@ export default defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("settings.organize") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -12,6 +12,11 @@ export default defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("sidebar.tags") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -115,6 +115,11 @@ export default defineComponent({
|
||||
workingUnitData,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: "Units",
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -23,7 +23,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, useRoute, ref } from "@nuxtjs/composition-api";
|
||||
import { defineComponent, useRoute, ref, useMeta } from "@nuxtjs/composition-api";
|
||||
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
|
||||
import { useCookbook } from "~/composables/use-group-cookbooks";
|
||||
export default defineComponent({
|
||||
@ -37,11 +37,18 @@ export default defineComponent({
|
||||
|
||||
const book = getOne(slug);
|
||||
|
||||
useMeta(() => {
|
||||
return {
|
||||
title: book?.value?.name || "Cookbook",
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
book,
|
||||
tab,
|
||||
};
|
||||
},
|
||||
head: {}, // Must include for useMeta
|
||||
});
|
||||
</script>
|
||||
|
||||
|
86
frontend/pages/forgot-password.vue
Normal file
86
frontend/pages/forgot-password.vue
Normal file
@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<v-container fill-height fluid class="d-flex justify-center align-center">
|
||||
<v-card color="background d-flex flex-column align-center" flat width="600px">
|
||||
<v-card-title class="headline justify-center"> Forgot Password </v-card-title>
|
||||
<BaseDivider />
|
||||
<v-card-text>
|
||||
<v-form @submit.prevent="requestLink()">
|
||||
<v-text-field
|
||||
v-model="email"
|
||||
filled
|
||||
rounded
|
||||
autofocus
|
||||
class="rounded-lg"
|
||||
name="login"
|
||||
:label="$t('user.email')"
|
||||
type="text"
|
||||
/>
|
||||
<p class="text-center">Please enter your email address and we will send you a link to reset your password.</p>
|
||||
<v-card-actions class="justify-center">
|
||||
<div class="max-button">
|
||||
<v-btn :loading="loading" color="primary" type="submit" large rounded class="rounded-xl" block>
|
||||
<v-icon left>
|
||||
{{ $globals.icons.email }}
|
||||
</v-icon>
|
||||
{{ $t("user.reset-password") }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-btn class="mx-auto" text nuxt to="/login"> {{ $t("user.login") }} </v-btn>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, toRefs, reactive } from "@nuxtjs/composition-api";
|
||||
import { useApiSingleton } from "~/composables/use-api";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
export default defineComponent({
|
||||
layout: "basic",
|
||||
|
||||
setup() {
|
||||
const state = reactive({
|
||||
email: "",
|
||||
loading: false,
|
||||
error: false,
|
||||
});
|
||||
|
||||
const api = useApiSingleton();
|
||||
|
||||
async function requestLink() {
|
||||
state.loading = true;
|
||||
// TODO: Fix Response to send meaningful error
|
||||
const { response } = await api.email.sendForgotPassword({ email: state.email });
|
||||
|
||||
if (response?.status === 200) {
|
||||
state.loading = false;
|
||||
state.error = false;
|
||||
alert.success("Link successfully sent");
|
||||
} else {
|
||||
state.loading = false;
|
||||
state.error = true;
|
||||
alert.error("Email failure");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestLink,
|
||||
...toRefs(state),
|
||||
};
|
||||
},
|
||||
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("user.login") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="css">
|
||||
.max-button {
|
||||
width: 300px;
|
||||
}
|
||||
</style>
|
@ -18,9 +18,7 @@ export default defineComponent({
|
||||
components: { RecipeCardSection },
|
||||
setup() {
|
||||
const { assignSorted } = useRecipes(false);
|
||||
|
||||
useStaticRoutes();
|
||||
|
||||
return { recentRecipes, assignSorted };
|
||||
},
|
||||
});
|
||||
|
@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<v-container fill-height fluid class="d-flex justify-center align-center">
|
||||
<v-card color="background d-flex flex-column align-center" flat width="600px">
|
||||
<v-card tag="section" color="background d-flex flex-column align-center" flat width="600px">
|
||||
<svg
|
||||
id="bbc88faa-5a3b-49cf-bdbb-6c9ab11be594"
|
||||
data-name="Layer 1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 728 754.88525"
|
||||
style="max-height: 200px"
|
||||
style="max-height: 100px"
|
||||
class="mt-2"
|
||||
>
|
||||
<rect
|
||||
@ -182,8 +182,11 @@
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-btn v-if="allowSignup" rounded class="mx-auto" text to="/register"> {{ $t("user.register") }} </v-btn>
|
||||
<v-btn v-else class="mx-auto" text disabled> {{ $t("user.invite-only") }} </v-btn>
|
||||
<v-card-actions>
|
||||
<v-btn v-if="allowSignup" text to="/register"> {{ $t("user.register") }} </v-btn>
|
||||
<v-btn v-else text disabled> {{ $t("user.invite-only") }} </v-btn>
|
||||
<v-btn class="mr-auto" text to="/forgot-password"> {{ $t("user.reset-password") }} </v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
@ -223,10 +226,15 @@ export default defineComponent({
|
||||
authenticate,
|
||||
};
|
||||
},
|
||||
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("user.login") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="css">
|
||||
.max-button {
|
||||
width: 300px;
|
||||
|
@ -309,6 +309,11 @@ export default defineComponent({
|
||||
days,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("meal-plan.dinner-this-week") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -9,6 +9,11 @@ export default defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("meal-plan.dinner-this-week") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -397,6 +397,34 @@ export default defineComponent({
|
||||
scale: 1,
|
||||
});
|
||||
|
||||
// ===============================================================
|
||||
// Metadata
|
||||
|
||||
const structuredData = computed(() => {
|
||||
return {
|
||||
"@context": "http://schema.org",
|
||||
"@type": "Recipe",
|
||||
...recipe.value,
|
||||
};
|
||||
});
|
||||
|
||||
useMeta(() => {
|
||||
return {
|
||||
title: recipe?.value?.name || "Recipe",
|
||||
// @ts-ignore
|
||||
mainImage: recipeImage(recipe?.value?.image),
|
||||
meta: [
|
||||
{
|
||||
hid: "description",
|
||||
name: "description",
|
||||
content: recipe?.value?.description || "",
|
||||
},
|
||||
],
|
||||
__dangerouslyDisableSanitizers: ["script"],
|
||||
script: [{ innerHTML: JSON.stringify(structuredData), type: "application/ld+json" }],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
scaledYield,
|
||||
...toRefs(state),
|
||||
|
@ -214,6 +214,11 @@ export default defineComponent({
|
||||
validators,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("general.create") as string,
|
||||
};
|
||||
},
|
||||
// Computed State is used because of the limitation of vue-composition-api in v2.0
|
||||
computed: {
|
||||
tab: {
|
||||
|
@ -47,6 +47,11 @@ export default defineComponent({
|
||||
|
||||
return { recipes, infiniteScroll, loading };
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("page.all-recipes") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
@ -29,6 +29,11 @@ export default defineComponent({
|
||||
}, slug);
|
||||
return { category };
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("sidebar.categories") as string,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
assignSorted(val: Array<Recipe>) {
|
||||
if (this.category) {
|
||||
|
@ -59,6 +59,11 @@ export default defineComponent({
|
||||
|
||||
return { categories, api, categoriesByLetter };
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("sidebar.categories") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -29,6 +29,11 @@ export default defineComponent({
|
||||
}, slug);
|
||||
return { tag };
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("sidebar.tags") as string,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
assignSorted(val: Array<Recipe>) {
|
||||
if (this.tag) {
|
||||
|
@ -59,6 +59,11 @@ export default defineComponent({
|
||||
|
||||
return { tags, api, tagsByLetter };
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("sidebar.tags") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -174,5 +174,10 @@ export default defineComponent({
|
||||
register,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("user.register") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
140
frontend/pages/reset-password.vue
Normal file
140
frontend/pages/reset-password.vue
Normal file
@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<v-container fill-height fluid class="d-flex justify-center align-center">
|
||||
<v-card color="background d-flex flex-column align-center" flat width="600px">
|
||||
<v-card-title class="headline justify-center"> Reset Password </v-card-title>
|
||||
<BaseDivider />
|
||||
<v-card-text>
|
||||
<v-form @submit.prevent="requestLink()">
|
||||
<v-text-field
|
||||
v-model="email"
|
||||
:prepend-icon="$globals.icons.email"
|
||||
filled
|
||||
rounded
|
||||
autofocus
|
||||
class="rounded-lg"
|
||||
name="login"
|
||||
:label="$t('user.email')"
|
||||
type="text"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="password"
|
||||
filled
|
||||
rounded
|
||||
class="rounded-lg"
|
||||
:prepend-icon="$globals.icons.lock"
|
||||
name="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
:rules="[validators.required]"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="passwordConfirm"
|
||||
filled
|
||||
rounded
|
||||
validate-on-blur
|
||||
class="rounded-lg"
|
||||
:prepend-icon="$globals.icons.lock"
|
||||
name="password"
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
:rules="[validators.required, passwordMatch]"
|
||||
/>
|
||||
<p class="text-center">Please enter your new password.</p>
|
||||
<v-card-actions class="justify-center">
|
||||
<div class="max-button">
|
||||
<v-btn
|
||||
:loading="loading"
|
||||
color="primary"
|
||||
:disabled="token === ''"
|
||||
type="submit"
|
||||
large
|
||||
rounded
|
||||
class="rounded-xl"
|
||||
block
|
||||
>
|
||||
<v-icon left>
|
||||
{{ $globals.icons.lock }}
|
||||
</v-icon>
|
||||
{{ token === "" ? "Token Required" : $t("user.reset-password") }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-btn class="mx-auto" text nuxt to="/login"> {{ $t("user.login") }} </v-btn>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, toRefs, reactive } from "@nuxtjs/composition-api";
|
||||
import { useApiSingleton } from "~/composables/use-api";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import { validators } from "@/composables/use-validators";
|
||||
import { useRouteQuery } from "~/composables/use-router";
|
||||
export default defineComponent({
|
||||
layout: "basic",
|
||||
|
||||
setup() {
|
||||
const state = reactive({
|
||||
email: "",
|
||||
password: "",
|
||||
passwordConfirm: "",
|
||||
loading: false,
|
||||
error: false,
|
||||
});
|
||||
|
||||
const passwordMatch = () => state.password === state.passwordConfirm || "Passwords do not match";
|
||||
|
||||
// ===================
|
||||
// Token Getter
|
||||
const token = useRouteQuery("token", "");
|
||||
|
||||
// ===================
|
||||
// API
|
||||
const api = useApiSingleton();
|
||||
async function requestLink() {
|
||||
state.loading = true;
|
||||
// TODO: Fix Response to send meaningful error
|
||||
const { response } = await api.users.resetPassword({
|
||||
token: token.value,
|
||||
email: state.email,
|
||||
password: state.password,
|
||||
passwordConfirm: state.passwordConfirm,
|
||||
});
|
||||
|
||||
state.loading = false;
|
||||
|
||||
if (response?.status === 200) {
|
||||
state.loading = false;
|
||||
state.error = false;
|
||||
alert.success("Password Reset Successful");
|
||||
} else {
|
||||
state.loading = false;
|
||||
state.error = true;
|
||||
alert.error("Something Went Wrong");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
passwordMatch,
|
||||
token,
|
||||
requestLink,
|
||||
validators,
|
||||
...toRefs(state),
|
||||
};
|
||||
},
|
||||
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("user.login") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="css">
|
||||
.max-button {
|
||||
width: 300px;
|
||||
}
|
||||
</style>
|
@ -110,6 +110,11 @@ export default defineComponent({
|
||||
},
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("search.search"),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
searchString: {
|
||||
set(q) {
|
||||
|
@ -9,6 +9,11 @@ export default defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("shopping-list.shopping-list") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -1,16 +1,21 @@
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("shopping-list.shopping-list") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
<style scoped>
|
||||
</style>
|
@ -9,6 +9,11 @@ export default defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("general.favorites") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -3,13 +3,18 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
return {};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("settings.profile") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
@ -63,6 +63,11 @@ export default defineComponent({
|
||||
actions,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("settings.pages") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -133,6 +133,11 @@ export default defineComponent({
|
||||
allDays,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("group.group") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -109,5 +109,10 @@ export default defineComponent({
|
||||
|
||||
return { members, headers, setPermissions };
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: "Members",
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
@ -64,5 +64,10 @@ export default defineComponent({
|
||||
actions,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("settings.webhooks.webhooks") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
@ -126,6 +126,11 @@ export default defineComponent({
|
||||
|
||||
return { createToken, deleteToken, copyToken, createdToken, loading, name, user, resetCreate };
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("settings.token.api-tokens") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -7,8 +7,6 @@
|
||||
<template #title> Your Profile Settings </template>
|
||||
</BasePageTitle>
|
||||
|
||||
|
||||
|
||||
<section>
|
||||
<ToggleState tag="article">
|
||||
<template #activator="{ toggle, state }">
|
||||
@ -161,6 +159,11 @@ export default defineComponent({
|
||||
loading: false,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("settings.profile") as string,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
async changePassword() {
|
||||
|
@ -193,6 +193,11 @@ export default defineComponent({
|
||||
...toRefs(state),
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("settings.profile") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -1,16 +0,0 @@
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
@ -1,113 +0,0 @@
|
||||
<template>
|
||||
<v-container fill-height fluid class="d-flex justify-center align-start">
|
||||
<v-card color="background d-flex flex-column align-center " flat width="600px">
|
||||
<svg
|
||||
id="b76bd6b3-ad77-41ff-b778-1d1d054fe577"
|
||||
data-name="Layer 1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style="max-height: 300px"
|
||||
viewBox="0 0 570 511.67482"
|
||||
>
|
||||
<path
|
||||
d="M879.99927,389.83741a.99678.99678,0,0,1-.5708-.1792L602.86963,197.05469a5.01548,5.01548,0,0,0-5.72852.00977L322.57434,389.65626a1.00019,1.00019,0,0,1-1.14868-1.6377l274.567-192.5918a7.02216,7.02216,0,0,1,8.02-.01318l276.55883,192.603a1.00019,1.00019,0,0,1-.57226,1.8208Z"
|
||||
transform="translate(-315 -194.16259)"
|
||||
fill="#3f3d56"
|
||||
/>
|
||||
<polygon
|
||||
points="23.264 202.502 285.276 8.319 549.276 216.319 298.776 364.819 162.776 333.819 23.264 202.502"
|
||||
fill="#e6e6e6"
|
||||
/>
|
||||
<path
|
||||
d="M489.25553,650.70367H359.81522a6.04737,6.04737,0,1,1,0-12.09473H489.25553a6.04737,6.04737,0,1,1,0,12.09473Z"
|
||||
transform="translate(-315 -194.16259)"
|
||||
fill="#e58325"
|
||||
/>
|
||||
<path
|
||||
d="M406.25553,624.70367H359.81522a6.04737,6.04737,0,1,1,0-12.09473h46.44031a6.04737,6.04737,0,1,1,0,12.09473Z"
|
||||
transform="translate(-315 -194.16259)"
|
||||
fill="#e58325"
|
||||
/>
|
||||
<path
|
||||
d="M603.96016,504.82207a7.56366,7.56366,0,0,1-2.86914-.562L439.5002,437.21123v-209.874a7.00817,7.00817,0,0,1,7-7h310a7.00818,7.00818,0,0,1,7,7v210.0205l-.30371.12989L606.91622,504.22734A7.61624,7.61624,0,0,1,603.96016,504.82207Z"
|
||||
transform="translate(-315 -194.16259)"
|
||||
fill="#fff"
|
||||
/>
|
||||
<path
|
||||
d="M603.96016,505.32158a8.07177,8.07177,0,0,1-3.05957-.59863L439.0002,437.54521v-210.208a7.50851,7.50851,0,0,1,7.5-7.5h310a7.50851,7.50851,0,0,1,7.5,7.5V437.68779l-156.8877,66.999A8.10957,8.10957,0,0,1,603.96016,505.32158Zm-162.96-69.1123,160.66309,66.66455a6.1182,6.1182,0,0,0,4.668-.02784l155.669-66.47851V227.33721a5.50653,5.50653,0,0,0-5.5-5.5h-310a5.50653,5.50653,0,0,0-5.5,5.5Z"
|
||||
transform="translate(-315 -194.16259)"
|
||||
fill="#3f3d56"
|
||||
/>
|
||||
<path
|
||||
d="M878,387.83741h-.2002L763,436.85743l-157.06982,67.07a5.06614,5.06614,0,0,1-3.88038.02L440,436.71741l-117.62012-48.8-.17968-.08H322a7.00778,7.00778,0,0,0-7,7v304a7.00779,7.00779,0,0,0,7,7H878a7.00779,7.00779,0,0,0,7-7v-304A7.00778,7.00778,0,0,0,878,387.83741Zm5,311a5.002,5.002,0,0,1-5,5H322a5.002,5.002,0,0,1-5-5v-304a5.01106,5.01106,0,0,1,4.81006-5L440,438.87739l161.28027,66.92a7.12081,7.12081,0,0,0,5.43994-.03L763,439.02741l115.2002-49.19a5.01621,5.01621,0,0,1,4.7998,5Z"
|
||||
transform="translate(-315 -194.16259)"
|
||||
fill="#3f3d56"
|
||||
/>
|
||||
<path
|
||||
d="M602.345,445.30958a27.49862,27.49862,0,0,1-16.5459-5.4961l-.2959-.22217-62.311-47.70752a27.68337,27.68337,0,1,1,33.67407-43.94921l40.36035,30.94775,95.37793-124.38672a27.68235,27.68235,0,0,1,38.81323-5.12353l-.593.80517.6084-.79346a27.71447,27.71447,0,0,1,5.12353,38.81348L624.36938,434.50586A27.69447,27.69447,0,0,1,602.345,445.30958Z"
|
||||
transform="translate(-315 -194.16259)"
|
||||
fill="#e58325"
|
||||
/>
|
||||
</svg>
|
||||
<v-card-title class="headline justify-center"> Request Secure Link </v-card-title>
|
||||
<BaseDivider />
|
||||
<v-card-text>
|
||||
<v-form @submit.prevent="authenticate()">
|
||||
<v-text-field
|
||||
v-model="form.email"
|
||||
filled
|
||||
rounded
|
||||
autofocus
|
||||
class="rounded-lg"
|
||||
prepend-icon="mdi-account"
|
||||
name="login"
|
||||
label="Email"
|
||||
type="text"
|
||||
/>
|
||||
<v-btn :loading="loggingIn" color="primary" type="submit" large rounded class="rounded-xl" block>
|
||||
Submit
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-btn v-if="allowSignup" class="mx-auto" text to="/login"> Login </v-btn>
|
||||
</v-card>
|
||||
<!-- <v-col class="fill-height"> </v-col> -->
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
|
||||
export default defineComponent({
|
||||
layout: "basic",
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loggingIn: false,
|
||||
form: {
|
||||
email: "changeme@email.com",
|
||||
password: "MyPassword",
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
allowSignup(): boolean {
|
||||
// @ts-ignore
|
||||
return process.env.ALLOW_SIGNUP;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async authenticate() {
|
||||
this.loggingIn = true;
|
||||
const formData = new FormData();
|
||||
formData.append("username", this.form.email);
|
||||
formData.append("password", this.form.password);
|
||||
|
||||
await this.$auth.loginWith("local", { data: formData });
|
||||
this.loggingIn = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
@ -55,7 +55,7 @@ export interface Recipe {
|
||||
id?: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
image?: unknown;
|
||||
image: string;
|
||||
description: string;
|
||||
recipeCategory: string[];
|
||||
tags: string[];
|
||||
|
@ -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,
|
||||
|
@ -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())
|
||||
|
@ -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:
|
||||
|
@ -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"
|
||||
|
@ -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)
|
||||
|
2
mealie/core/settings/__init__.py
Normal file
2
mealie/core/settings/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .directories import *
|
||||
from .settings import *
|
65
mealie/core/settings/db_providers.py
Normal file
65
mealie/core/settings/db_providers.py
Normal file
@ -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
|
34
mealie/core/settings/directories.py
Normal file
34
mealie/core/settings/directories.py
Normal file
@ -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)
|
109
mealie/core/settings/settings.py
Normal file
109
mealie/core/settings/settings.py
Normal file
@ -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
|
10
mealie/core/settings/static.py
Normal file
10
mealie/core/settings/static.py
Normal file
@ -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"]
|
@ -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:
|
||||
|
@ -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)
|
||||
|
@ -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]:
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -1,2 +1,3 @@
|
||||
from .password_reset import *
|
||||
from .user_to_favorite import *
|
||||
from .users import *
|
||||
|
15
mealie/db/models/users/password_reset.py
Normal file
15
mealie/db/models/users/password_reset.py
Normal file
@ -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
|
@ -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])
|
||||
|
||||
|
@ -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"
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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"])
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
29
mealie/schema/user/user_passwords.py
Normal file
29
mealie/schema/user/user_passwords.py
Normal file
@ -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
|
@ -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)):
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
@ -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,
|
||||
|
@ -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]:
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -0,0 +1,2 @@
|
||||
from .scheduler_registry import *
|
||||
from .scheduler_service import *
|
@ -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)})
|
30
mealie/services/scheduler/scheduled_func.py
Normal file
30
mealie/services/scheduler/scheduled_func.py
Normal file
@ -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 = []
|
@ -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()
|
43
mealie/services/scheduler/scheduler_registry.py
Normal file
43
mealie/services/scheduler/scheduler_registry.py
Normal file
@ -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)
|
104
mealie/services/scheduler/scheduler_service.py
Normal file
104
mealie/services/scheduler/scheduler_service.py
Normal file
@ -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)
|
@ -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]))
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user