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:
Hayden 2021-10-07 09:39:47 -08:00 committed by GitHub
parent d1f0441252
commit 2e9026f9ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
121 changed files with 1461 additions and 679 deletions

1
.gitignore vendored
View File

@ -3,6 +3,7 @@
*__pycache__/
*.py[cod]
*$py.class
*.temp
# frontend/.env.development
docs/site/

View File

@ -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."""

View 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

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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";

View File

@ -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 } });
});
},
});
}

View File

@ -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": {

View File

@ -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 = [
{

View File

@ -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,

View File

@ -29,7 +29,7 @@
<script>
export default {
layout: "empty",
layout: "basic",
props: {
error: {
type: Object,

View File

@ -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" },

View File

@ -99,6 +99,11 @@ export default defineComponent({
appInfo,
};
},
head() {
return {
title: this.$t("about.about") as string,
};
},
});
</script>

View File

@ -146,6 +146,11 @@ export default defineComponent({
backupsFileNameDownload,
};
},
head() {
return {
title: this.$t("sidebar.backups") as string,
};
},
});
</script>

View File

@ -146,6 +146,11 @@ export default defineComponent({
return { statistics, events, deleteEvents, deleteEvent };
},
head() {
return {
title: this.$t("sidebar.dashboard") as string,
};
},
});
</script>

View File

@ -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>

View File

@ -153,6 +153,11 @@ export default defineComponent({
},
};
},
head() {
return {
title: this.$t("sidebar.manage-users") as string,
};
},
methods: {
updateUser(userData: any) {
this.updateMode = true;

View File

@ -17,6 +17,11 @@ export default defineComponent({
setup() {
return {};
},
head() {
return {
title: this.$t("settings.migrations") as string,
};
},
});
</script>

View File

@ -156,6 +156,11 @@ export default defineComponent({
testEmail,
};
},
head() {
return {
title: this.$t("settings.site-settings") as string,
};
},
});
</script>

View File

@ -12,6 +12,11 @@ export default defineComponent({
setup() {
return {};
},
head() {
return {
title: this.$t("sidebar.categories") as string,
};
},
});
</script>

View File

@ -113,6 +113,11 @@ export default defineComponent({
workingFoodData,
};
},
head() {
return {
title: "Foods",
};
},
});
</script>

View File

@ -215,6 +215,11 @@ export default defineComponent({
notificationTypes,
};
},
head() {
return {
title: this.$t("events.notification") as string,
};
},
});
</script>

View File

@ -12,6 +12,11 @@ export default defineComponent({
setup() {
return {};
},
head() {
return {
title: this.$t("settings.organize") as string,
};
},
});
</script>

View File

@ -12,6 +12,11 @@ export default defineComponent({
setup() {
return {};
},
head() {
return {
title: this.$t("sidebar.tags") as string,
};
},
});
</script>

View File

@ -115,6 +115,11 @@ export default defineComponent({
workingUnitData,
};
},
head() {
return {
title: "Units",
};
},
});
</script>

View File

@ -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>

View 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>

View File

@ -18,9 +18,7 @@ export default defineComponent({
components: { RecipeCardSection },
setup() {
const { assignSorted } = useRecipes(false);
useStaticRoutes();
return { recentRecipes, assignSorted };
},
});

View File

@ -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;

View File

@ -309,6 +309,11 @@ export default defineComponent({
days,
};
},
head() {
return {
title: this.$t("meal-plan.dinner-this-week") as string,
};
},
});
</script>

View File

@ -9,6 +9,11 @@ export default defineComponent({
setup() {
return {};
},
head() {
return {
title: this.$t("meal-plan.dinner-this-week") as string,
};
},
});
</script>

View File

@ -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),

View File

@ -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: {

View File

@ -47,6 +47,11 @@ export default defineComponent({
return { recipes, infiniteScroll, loading };
},
head() {
return {
title: this.$t("page.all-recipes") as string,
};
},
});
</script>

View File

@ -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) {

View File

@ -59,6 +59,11 @@ export default defineComponent({
return { categories, api, categoriesByLetter };
},
head() {
return {
title: this.$t("sidebar.categories") as string,
};
},
});
</script>

View File

@ -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) {

View File

@ -59,6 +59,11 @@ export default defineComponent({
return { tags, api, tagsByLetter };
},
head() {
return {
title: this.$t("sidebar.tags") as string,
};
},
});
</script>

View File

@ -174,5 +174,10 @@ export default defineComponent({
register,
};
},
head() {
return {
title: this.$t("user.register") as string,
};
},
});
</script>

View 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>

View File

@ -110,6 +110,11 @@ export default defineComponent({
},
};
},
head() {
return {
title: this.$t("search.search"),
};
},
computed: {
searchString: {
set(q) {

View File

@ -9,6 +9,11 @@ export default defineComponent({
setup() {
return {};
},
head() {
return {
title: this.$t("shopping-list.shopping-list") as string,
};
},
});
</script>

View File

@ -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("shopping-list.shopping-list") as string,
};
},
});
</script>
<style scoped>

View File

@ -9,6 +9,11 @@ export default defineComponent({
setup() {
return {};
},
head() {
return {
title: this.$t("general.favorites") as string,
};
},
});
</script>

View File

@ -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>

View File

@ -63,6 +63,11 @@ export default defineComponent({
actions,
};
},
head() {
return {
title: this.$t("settings.pages") as string,
};
},
});
</script>

View File

@ -133,6 +133,11 @@ export default defineComponent({
allDays,
};
},
head() {
return {
title: this.$t("group.group") as string,
};
},
});
</script>

View File

@ -109,5 +109,10 @@ export default defineComponent({
return { members, headers, setPermissions };
},
head() {
return {
title: "Members",
};
},
});
</script>

View File

@ -64,5 +64,10 @@ export default defineComponent({
actions,
};
},
head() {
return {
title: this.$t("settings.webhooks.webhooks") as string,
};
},
});
</script>

View File

@ -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>

View File

@ -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() {

View File

@ -193,6 +193,11 @@ export default defineComponent({
...toRefs(state),
};
},
head() {
return {
title: this.$t("settings.profile") as string,
};
},
});
</script>

View File

@ -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>

View File

@ -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>

View File

@ -55,7 +55,7 @@ export interface Recipe {
id?: number;
name: string;
slug: string;
image?: unknown;
image: string;
description: string;
recipeCategory: string[];
tags: string[];

View File

@ -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,

View File

@ -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())

View File

@ -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:

View File

@ -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"

View File

@ -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)

View File

@ -0,0 +1,2 @@
from .directories import *
from .settings import *

View 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

View 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)

View 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

View 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"]

View File

@ -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:

View File

@ -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)

View File

@ -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]:

View File

@ -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):

View File

@ -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

View File

@ -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"

View File

@ -1,2 +1,3 @@
from .password_reset import *
from .user_to_favorite import *
from .users import *

View 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

View File

@ -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])

View File

@ -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"

View File

@ -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)

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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"])

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View 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

View File

@ -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)):

View File

@ -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()

View File

@ -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()

View File

@ -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,

View File

@ -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]:

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,2 @@
from .scheduler_registry import *
from .scheduler_service import *

View File

@ -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)})

View 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 = []

View File

@ -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()

View 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)

View 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)

View File

@ -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