mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-06-04 22:25:34 -04:00
feat: mealplan-webhooks (#1403)
* fix type errors on event bus * webhooks fields required for new implementation * db migration * wip: webhook query + tests and stub function * ignore type checker error * type and method cleanup * datetime and time utc validator * update testing code for utc scheduled time * fix file cmp function call * update version_number * add support for translating "time" objects when restoring backup * bump recipe-scrapers * use specific import syntax * generate frontend types * utilize names exports * use utc times * add task to scheduler * implement new scheduler functionality * stub for type annotation * implement meal-plan data getter * add experimental banner
This commit is contained in:
parent
b1256f4ad2
commit
5a053cdcd6
@ -0,0 +1,31 @@
|
|||||||
|
"""add new webhook fields
|
||||||
|
|
||||||
|
|
||||||
|
Revision ID: f30cf048c228
|
||||||
|
Revises: ab0bae02578f
|
||||||
|
Create Date: 2022-06-15 21:05:34.851857
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "f30cf048c228"
|
||||||
|
down_revision = "ab0bae02578f"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column("webhook_urls", sa.Column("webhook_type", sa.String(), nullable=True))
|
||||||
|
op.add_column("webhook_urls", sa.Column("scheduled_time", sa.Time(), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column("webhook_urls", "scheduled_time")
|
||||||
|
op.drop_column("webhook_urls", "webhook_type")
|
||||||
|
# ### end Alembic commands ###
|
84
frontend/components/Domain/Group/GroupWebhookEditor.vue
Normal file
84
frontend/components/Domain/Group/GroupWebhookEditor.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-card-text>
|
||||||
|
<v-switch v-model="webhookCopy.enabled" label="Enabled"></v-switch>
|
||||||
|
<v-text-field v-model="webhookCopy.name" label="Webhook Name"></v-text-field>
|
||||||
|
<v-text-field v-model="webhookCopy.url" label="Webhook Url"></v-text-field>
|
||||||
|
<v-time-picker v-model="scheduledTime" class="elevation-2" ampm-in-title format="ampm"></v-time-picker>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions class="py-0 justify-end">
|
||||||
|
<BaseButtonGroup
|
||||||
|
:buttons="[
|
||||||
|
{
|
||||||
|
icon: $globals.icons.delete,
|
||||||
|
text: $tc('general.delete'),
|
||||||
|
event: 'delete',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: $globals.icons.testTube,
|
||||||
|
text: $tc('general.test'),
|
||||||
|
event: 'test',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: $globals.icons.save,
|
||||||
|
text: $tc('general.save'),
|
||||||
|
event: 'save',
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
@delete="$emit('delete', webhookCopy.id)"
|
||||||
|
@save="handleSave"
|
||||||
|
@test="$emit('test', webhookCopy.id)"
|
||||||
|
/>
|
||||||
|
</v-card-actions>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, computed, ref } from "@nuxtjs/composition-api";
|
||||||
|
import { ReadWebhook } from "~/types/api-types/group";
|
||||||
|
import { timeLocalToUTC, timeUTCToLocal } from "~/composables/use-group-webhooks";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
webhook: {
|
||||||
|
type: Object as () => ReadWebhook,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ["delete", "save", "test"],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const itemUTC = ref(props.webhook.scheduledTime);
|
||||||
|
const itemLocal = ref(timeUTCToLocal(props.webhook.scheduledTime));
|
||||||
|
|
||||||
|
const scheduledTime = computed({
|
||||||
|
get() {
|
||||||
|
return itemLocal.value;
|
||||||
|
},
|
||||||
|
set(v) {
|
||||||
|
itemUTC.value = timeLocalToUTC(v);
|
||||||
|
itemLocal.value = v;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const webhookCopy = ref({ ...props.webhook });
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
webhookCopy.value.scheduledTime = itemLocal.value;
|
||||||
|
emit("save", webhookCopy.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
webhookCopy,
|
||||||
|
scheduledTime,
|
||||||
|
handleSave,
|
||||||
|
itemUTC,
|
||||||
|
itemLocal,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
head() {
|
||||||
|
return {
|
||||||
|
title: this.$tc("settings.webhooks.webhooks"),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
@ -37,7 +37,7 @@ export const useGroupWebhooks = function () {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
name: "New Webhook",
|
name: "New Webhook",
|
||||||
url: "",
|
url: "",
|
||||||
time: "00:00",
|
scheduledTime: "00:00",
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data } = await api.groupWebhooks.createOne(payload);
|
const { data } = await api.groupWebhooks.createOne(payload);
|
||||||
@ -52,8 +52,23 @@ export const useGroupWebhooks = function () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert to UTC time
|
||||||
|
const [hours, minutes] = updateData.scheduledTime.split(":");
|
||||||
|
|
||||||
|
const newDt = new Date();
|
||||||
|
newDt.setHours(Number(hours));
|
||||||
|
newDt.setMinutes(Number(minutes));
|
||||||
|
|
||||||
|
updateData.scheduledTime = `${pad(newDt.getUTCHours(), 2)}:${pad(newDt.getUTCMinutes(), 2)}`;
|
||||||
|
console.log(updateData.scheduledTime);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
...updateData,
|
||||||
|
scheduledTime: updateData.scheduledTime,
|
||||||
|
};
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
const { data } = await api.groupWebhooks.updateOne(updateData.id, updateData);
|
const { data } = await api.groupWebhooks.updateOne(updateData.id, payload);
|
||||||
if (data) {
|
if (data) {
|
||||||
this.refreshAll();
|
this.refreshAll();
|
||||||
}
|
}
|
||||||
@ -73,3 +88,25 @@ export const useGroupWebhooks = function () {
|
|||||||
|
|
||||||
return { webhooks, actions, validForm };
|
return { webhooks, actions, validForm };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function pad(num: number, size: number) {
|
||||||
|
let numStr = num.toString();
|
||||||
|
while (numStr.length < size) numStr = "0" + numStr;
|
||||||
|
return numStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeUTCToLocal(time: string): string {
|
||||||
|
const [hours, minutes] = time.split(":");
|
||||||
|
const dt = new Date();
|
||||||
|
dt.setUTCMinutes(Number(minutes));
|
||||||
|
dt.setUTCHours(Number(hours));
|
||||||
|
return `${pad(dt.getHours(), 2)}:${pad(dt.getMinutes(), 2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeLocalToUTC(time: string) {
|
||||||
|
const [hours, minutes] = time.split(":");
|
||||||
|
const dt = new Date();
|
||||||
|
dt.setHours(Number(hours));
|
||||||
|
dt.setMinutes(Number(minutes));
|
||||||
|
return `${pad(dt.getUTCHours(), 2)}:${pad(dt.getUTCMinutes(), 2)}`;
|
||||||
|
}
|
||||||
|
@ -5,10 +5,16 @@
|
|||||||
<v-img max-height="125" max-width="125" :src="require('~/static/svgs/manage-webhooks.svg')"></v-img>
|
<v-img max-height="125" max-width="125" :src="require('~/static/svgs/manage-webhooks.svg')"></v-img>
|
||||||
</template>
|
</template>
|
||||||
<template #title> Webhooks </template>
|
<template #title> Webhooks </template>
|
||||||
The webhooks defined below will be executed when a meal is defined for the day. At the scheduled time the webhooks
|
<v-card-text class="pb-0">
|
||||||
will be sent with the data from the recipe that is scheduled for the day
|
The webhooks defined below will be executed when a meal is defined for the day. At the scheduled time the
|
||||||
|
webhooks will be sent with the data from the recipe that is scheduled for the day. Note that webhook execution
|
||||||
|
is not exact. The webhooks are executed on a 5 minutes interval so the webhooks will be executed within 5 +/-
|
||||||
|
minutes of the scheduled.
|
||||||
|
</v-card-text>
|
||||||
</BasePageTitle>
|
</BasePageTitle>
|
||||||
|
|
||||||
|
<BannerExperimental />
|
||||||
|
|
||||||
<BaseButton create @click="actions.createOne()" />
|
<BaseButton create @click="actions.createOne()" />
|
||||||
<v-expansion-panels class="mt-2">
|
<v-expansion-panels class="mt-2">
|
||||||
<v-expansion-panel v-for="(webhook, index) in webhooks" :key="index" class="my-2 left-border rounded">
|
<v-expansion-panel v-for="(webhook, index) in webhooks" :key="index" class="my-2 left-border rounded">
|
||||||
@ -17,7 +23,7 @@
|
|||||||
<v-icon large left :color="webhook.enabled ? 'info' : null">
|
<v-icon large left :color="webhook.enabled ? 'info' : null">
|
||||||
{{ $globals.icons.webhook }}
|
{{ $globals.icons.webhook }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
{{ webhook.name }} - {{ webhook.time }}
|
{{ webhook.name }} - {{ timeDisplay(timeUTCToLocal(webhook.scheduledTime)) }}
|
||||||
</div>
|
</div>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<v-btn small icon class="ml-2">
|
<v-btn small icon class="ml-2">
|
||||||
@ -28,35 +34,12 @@
|
|||||||
</template>
|
</template>
|
||||||
</v-expansion-panel-header>
|
</v-expansion-panel-header>
|
||||||
<v-expansion-panel-content>
|
<v-expansion-panel-content>
|
||||||
<v-card-text>
|
<GroupWebhookEditor
|
||||||
<v-switch v-model="webhook.enabled" label="Enabled"></v-switch>
|
:key="webhook.id"
|
||||||
<v-text-field v-model="webhook.name" label="Webhook Name"></v-text-field>
|
:webhook="webhook"
|
||||||
<v-text-field v-model="webhook.url" label="Webhook Url"></v-text-field>
|
@save="actions.updateOne($event)"
|
||||||
<v-time-picker v-model="webhook.time" class="elevation-2" ampm-in-title format="ampm"></v-time-picker>
|
@delete="actions.deleteOne($event)"
|
||||||
</v-card-text>
|
|
||||||
<v-card-actions class="py-0 justify-end">
|
|
||||||
<BaseButtonGroup
|
|
||||||
:buttons="[
|
|
||||||
{
|
|
||||||
icon: $globals.icons.delete,
|
|
||||||
text: $t('general.delete'),
|
|
||||||
event: 'delete',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: $globals.icons.testTube,
|
|
||||||
text: $t('general.test'),
|
|
||||||
event: 'test',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: $globals.icons.save,
|
|
||||||
text: $t('general.save'),
|
|
||||||
event: 'save',
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
@delete="actions.deleteOne(webhook.id)"
|
|
||||||
@save="actions.updateOne(webhook)"
|
|
||||||
/>
|
/>
|
||||||
</v-card-actions>
|
|
||||||
</v-expansion-panel-content>
|
</v-expansion-panel-content>
|
||||||
</v-expansion-panel>
|
</v-expansion-panel>
|
||||||
</v-expansion-panels>
|
</v-expansion-panels>
|
||||||
@ -65,15 +48,28 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from "@nuxtjs/composition-api";
|
import { defineComponent } from "@nuxtjs/composition-api";
|
||||||
import { useGroupWebhooks } from "~/composables/use-group-webhooks";
|
import { useGroupWebhooks, timeLocalToUTC, timeUTCToLocal } from "~/composables/use-group-webhooks";
|
||||||
|
import GroupWebhookEditor from "~/components/Domain/Group/GroupWebhookEditor.vue";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
|
components: { GroupWebhookEditor },
|
||||||
setup() {
|
setup() {
|
||||||
const { actions, webhooks } = useGroupWebhooks();
|
const { actions, webhooks } = useGroupWebhooks();
|
||||||
|
function timeDisplay(time: string): string {
|
||||||
|
// returns the time in the format HH:MM AM/PM
|
||||||
|
const [hours, minutes] = time.split(":");
|
||||||
|
const ampm = Number(hours) < 12 ? "AM" : "PM";
|
||||||
|
const hour = Number(hours) % 12 || 12;
|
||||||
|
const minute = minutes.padStart(2, "0");
|
||||||
|
return `${hour}:${minute} ${ampm}`;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
webhooks,
|
webhooks,
|
||||||
actions,
|
actions,
|
||||||
|
timeLocalToUTC,
|
||||||
|
timeUTCToLocal,
|
||||||
|
timeDisplay,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
head() {
|
head() {
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
/* Do not modify it by hand - just update the pydantic models and then re-run the script
|
/* Do not modify it by hand - just update the pydantic models and then re-run the script
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export type WebhookType = "mealplan";
|
||||||
export type SupportedMigrations = "nextcloud" | "chowdown" | "paprika" | "mealie_alpha";
|
export type SupportedMigrations = "nextcloud" | "chowdown" | "paprika" | "mealie_alpha";
|
||||||
|
|
||||||
export interface CreateGroupPreferences {
|
export interface CreateGroupPreferences {
|
||||||
@ -25,7 +26,8 @@ export interface CreateWebhook {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
time?: string;
|
webhookType?: WebhookType & string;
|
||||||
|
scheduledTime: string;
|
||||||
}
|
}
|
||||||
export interface DataMigrationCreate {
|
export interface DataMigrationCreate {
|
||||||
sourceType: SupportedMigrations;
|
sourceType: SupportedMigrations;
|
||||||
@ -231,7 +233,8 @@ export interface ReadWebhook {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
time?: string;
|
webhookType?: WebhookType & string;
|
||||||
|
scheduledTime: string;
|
||||||
groupId: string;
|
groupId: string;
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
@ -304,7 +307,8 @@ export interface SaveWebhook {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
time?: string;
|
webhookType?: WebhookType & string;
|
||||||
|
scheduledTime: string;
|
||||||
groupId: string;
|
groupId: string;
|
||||||
}
|
}
|
||||||
export interface SeederConfig {
|
export interface SeederConfig {
|
||||||
|
2
frontend/types/components.d.ts
vendored
2
frontend/types/components.d.ts
vendored
@ -18,6 +18,7 @@ import DevDumpJson from "@/components/global/DevDumpJson.vue";
|
|||||||
import LanguageDialog from "@/components/global/LanguageDialog.vue";
|
import LanguageDialog from "@/components/global/LanguageDialog.vue";
|
||||||
import InputQuantity from "@/components/global/InputQuantity.vue";
|
import InputQuantity from "@/components/global/InputQuantity.vue";
|
||||||
import ToggleState from "@/components/global/ToggleState.vue";
|
import ToggleState from "@/components/global/ToggleState.vue";
|
||||||
|
import ContextMenu from "@/components/global/ContextMenu.vue";
|
||||||
import AppButtonCopy from "@/components/global/AppButtonCopy.vue";
|
import AppButtonCopy from "@/components/global/AppButtonCopy.vue";
|
||||||
import CrudTable from "@/components/global/CrudTable.vue";
|
import CrudTable from "@/components/global/CrudTable.vue";
|
||||||
import InputColor from "@/components/global/InputColor.vue";
|
import InputColor from "@/components/global/InputColor.vue";
|
||||||
@ -55,6 +56,7 @@ declare module "vue" {
|
|||||||
LanguageDialog: typeof LanguageDialog;
|
LanguageDialog: typeof LanguageDialog;
|
||||||
InputQuantity: typeof InputQuantity;
|
InputQuantity: typeof InputQuantity;
|
||||||
ToggleState: typeof ToggleState;
|
ToggleState: typeof ToggleState;
|
||||||
|
ContextMenu: typeof ContextMenu;
|
||||||
AppButtonCopy: typeof AppButtonCopy;
|
AppButtonCopy: typeof AppButtonCopy;
|
||||||
CrudTable: typeof CrudTable;
|
CrudTable: typeof CrudTable;
|
||||||
InputColor: typeof InputColor;
|
InputColor: typeof InputColor;
|
||||||
|
@ -57,6 +57,10 @@ async def start_scheduler():
|
|||||||
tasks.purge_group_data_exports,
|
tasks.purge_group_data_exports,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SchedulerRegistry.register_minutely(
|
||||||
|
tasks.post_group_webhooks,
|
||||||
|
)
|
||||||
|
|
||||||
SchedulerRegistry.print_jobs()
|
SchedulerRegistry.print_jobs()
|
||||||
|
|
||||||
await SchedulerService.start()
|
await SchedulerService.start()
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
from sqlalchemy import Boolean, Column, ForeignKey, String, orm
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Column, ForeignKey, String, Time, orm
|
||||||
|
|
||||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||||
from .._model_utils import GUID, auto_init
|
from .._model_utils import GUID, auto_init
|
||||||
@ -14,8 +16,15 @@ class GroupWebhooksModel(SqlAlchemyBase, BaseMixins):
|
|||||||
enabled = Column(Boolean, default=False)
|
enabled = Column(Boolean, default=False)
|
||||||
name = Column(String)
|
name = Column(String)
|
||||||
url = Column(String)
|
url = Column(String)
|
||||||
|
|
||||||
|
# New Fields
|
||||||
|
webhook_type = Column(String, default="") # Future use for different types of webhooks
|
||||||
|
scheduled_time = Column(Time, default=lambda: datetime.now().time())
|
||||||
|
|
||||||
|
# Columne is no longer used but is kept for since it's super annoying to
|
||||||
|
# delete a column in SQLite and it's not a big deal to keep it around
|
||||||
time = Column(String, default="00:00")
|
time = Column(String, default="00:00")
|
||||||
|
|
||||||
@auto_init()
|
@auto_init()
|
||||||
def __init__(self, **_) -> None:
|
def __init__(self, **_) -> None:
|
||||||
pass
|
...
|
||||||
|
@ -8,6 +8,9 @@ from .repository_generic import RepositoryGeneric
|
|||||||
|
|
||||||
|
|
||||||
class RepositoryMeals(RepositoryGeneric[ReadPlanEntry, GroupMealPlan]):
|
class RepositoryMeals(RepositoryGeneric[ReadPlanEntry, GroupMealPlan]):
|
||||||
|
def by_group(self, group_id: UUID) -> "RepositoryMeals":
|
||||||
|
return super().by_group(group_id) # type: ignore
|
||||||
|
|
||||||
def get_slice(self, start: date, end: date, group_id: UUID) -> list[ReadPlanEntry]:
|
def get_slice(self, start: date, end: date, group_id: UUID) -> list[ReadPlanEntry]:
|
||||||
start_str = start.strftime("%Y-%m-%d")
|
start_str = start.strftime("%Y-%m-%d")
|
||||||
end_str = end.strftime("%Y-%m-%d")
|
end_str = end.strftime("%Y-%m-%d")
|
||||||
|
@ -9,4 +9,4 @@ from .group_seeder import *
|
|||||||
from .group_shopping_list import *
|
from .group_shopping_list import *
|
||||||
from .group_statistics import *
|
from .group_statistics import *
|
||||||
from .invite_token import *
|
from .invite_token import *
|
||||||
from .webhook import *
|
from .webhook import * # type: ignore
|
||||||
|
@ -1,15 +1,51 @@
|
|||||||
|
import datetime
|
||||||
|
import enum
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import UUID4
|
from isodate import parse_time
|
||||||
|
from pydantic import UUID4, validator
|
||||||
|
from pydantic.datetime_parse import parse_datetime
|
||||||
|
|
||||||
from mealie.schema._mealie import MealieModel
|
from mealie.schema._mealie import MealieModel
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookType(str, enum.Enum):
|
||||||
|
mealplan = "mealplan"
|
||||||
|
|
||||||
|
|
||||||
class CreateWebhook(MealieModel):
|
class CreateWebhook(MealieModel):
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
name: str = ""
|
name: str = ""
|
||||||
url: str = ""
|
url: str = ""
|
||||||
time: str = "00:00"
|
|
||||||
|
webhook_type: WebhookType = WebhookType.mealplan
|
||||||
|
scheduled_time: datetime.time
|
||||||
|
|
||||||
|
@validator("scheduled_time", pre=True)
|
||||||
|
@classmethod
|
||||||
|
def validate_scheduled_time(cls, v):
|
||||||
|
"""
|
||||||
|
Validator accepts both datetime and time values from external sources.
|
||||||
|
DateTime types are parsed and converted to time objects without timezones
|
||||||
|
|
||||||
|
type: time is treated as a UTC value
|
||||||
|
type: datetime is treated as a value with a timezone
|
||||||
|
"""
|
||||||
|
parser_funcs = [
|
||||||
|
lambda x: parse_datetime(x).astimezone(datetime.timezone.utc).time(),
|
||||||
|
parse_time,
|
||||||
|
]
|
||||||
|
|
||||||
|
if isinstance(v, datetime.time):
|
||||||
|
return v
|
||||||
|
|
||||||
|
for parser_func in parser_funcs:
|
||||||
|
try:
|
||||||
|
return parser_func(v)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise ValueError(f"Invalid scheduled time: {v}")
|
||||||
|
|
||||||
|
|
||||||
class SaveWebhook(CreateWebhook):
|
class SaveWebhook(CreateWebhook):
|
||||||
|
@ -18,10 +18,12 @@ class AlchemyExporter(BaseService):
|
|||||||
|
|
||||||
look_for_datetime = {"created_at", "update_at", "date_updated", "timestamp", "expires_at"}
|
look_for_datetime = {"created_at", "update_at", "date_updated", "timestamp", "expires_at"}
|
||||||
look_for_date = {"date_added", "date"}
|
look_for_date = {"date_added", "date"}
|
||||||
|
look_for_time = {"scheduled_time"}
|
||||||
|
|
||||||
class DateTimeParser(BaseModel):
|
class DateTimeParser(BaseModel):
|
||||||
date: datetime.date = None
|
date: datetime.date = None
|
||||||
time: datetime.datetime = None
|
dt: datetime.datetime = None
|
||||||
|
time: datetime.time = None
|
||||||
|
|
||||||
def __init__(self, connection_str: str) -> None:
|
def __init__(self, connection_str: str) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@ -44,10 +46,11 @@ class AlchemyExporter(BaseService):
|
|||||||
data[key] = [AlchemyExporter.convert_to_datetime(item) for item in value]
|
data[key] = [AlchemyExporter.convert_to_datetime(item) for item in value]
|
||||||
elif isinstance(value, str):
|
elif isinstance(value, str):
|
||||||
if key in AlchemyExporter.look_for_datetime:
|
if key in AlchemyExporter.look_for_datetime:
|
||||||
data[key] = AlchemyExporter.DateTimeParser(time=value).time
|
data[key] = AlchemyExporter.DateTimeParser(dt=value).dt
|
||||||
if key in AlchemyExporter.look_for_date:
|
if key in AlchemyExporter.look_for_date:
|
||||||
data[key] = AlchemyExporter.DateTimeParser(date=value).date
|
data[key] = AlchemyExporter.DateTimeParser(date=value).date
|
||||||
|
if key in AlchemyExporter.look_for_time:
|
||||||
|
data[key] = AlchemyExporter.DateTimeParser(time=value).time
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@ -37,7 +37,7 @@ class EventBusService:
|
|||||||
self.bg = bg
|
self.bg = bg
|
||||||
self._publisher = ApprisePublisher
|
self._publisher = ApprisePublisher
|
||||||
self.session = session
|
self.session = session
|
||||||
self.group_id = None
|
self.group_id: UUID4 | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def publisher(self) -> PublisherLike:
|
def publisher(self) -> PublisherLike:
|
||||||
@ -55,7 +55,7 @@ class EventBusService:
|
|||||||
def dispatch(
|
def dispatch(
|
||||||
self, group_id: UUID4, event_type: EventTypes, msg: str = "", event_source: EventSource = None
|
self, group_id: UUID4, event_type: EventTypes, msg: str = "", event_source: EventSource = None
|
||||||
) -> None:
|
) -> None:
|
||||||
self.group_id = group_id # type: ignore
|
self.group_id = group_id
|
||||||
|
|
||||||
def _dispatch(event_source: EventSource = None):
|
def _dispatch(event_source: EventSource = None):
|
||||||
if urls := self.get_urls(event_type):
|
if urls := self.get_urls(event_type):
|
||||||
|
@ -1,6 +1,14 @@
|
|||||||
from .purge_group_exports import *
|
from .post_webhooks import post_group_webhooks
|
||||||
from .purge_password_reset import *
|
from .purge_group_exports import purge_group_data_exports
|
||||||
from .purge_registration import *
|
from .purge_password_reset import purge_password_reset_tokens
|
||||||
|
from .purge_registration import purge_group_registration
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"post_group_webhooks",
|
||||||
|
"purge_password_reset_tokens",
|
||||||
|
"purge_group_data_exports",
|
||||||
|
"purge_group_registration",
|
||||||
|
]
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Tasks Package
|
Tasks Package
|
||||||
|
54
mealie/services/scheduler/tasks/post_webhooks.py
Normal file
54
mealie/services/scheduler/tasks/post_webhooks.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from pydantic import UUID4
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from mealie.db.db_setup import create_session
|
||||||
|
from mealie.db.models.group.webhooks import GroupWebhooksModel
|
||||||
|
from mealie.repos.all_repositories import get_repositories
|
||||||
|
|
||||||
|
last_ran = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def get_scheduled_webhooks(session: Session, bottom: datetime, top: datetime) -> list[GroupWebhooksModel]:
|
||||||
|
"""
|
||||||
|
get_scheduled_webhooks queries the database for all webhooks scheduled between the bottom and
|
||||||
|
top time ranges. It returns a list of GroupWebhooksModel objects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return (
|
||||||
|
session.query(GroupWebhooksModel)
|
||||||
|
.where(
|
||||||
|
GroupWebhooksModel.enabled == True, # noqa: E712 - required for SQLAlchemy comparison
|
||||||
|
GroupWebhooksModel.scheduled_time > bottom.astimezone(timezone.utc).time(),
|
||||||
|
GroupWebhooksModel.scheduled_time <= top.astimezone(timezone.utc).time(),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def post_group_webhooks() -> None:
|
||||||
|
global last_ran
|
||||||
|
session = create_session()
|
||||||
|
results = get_scheduled_webhooks(session, last_ran, datetime.now())
|
||||||
|
|
||||||
|
last_ran = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
repos = get_repositories(session)
|
||||||
|
|
||||||
|
memo = {}
|
||||||
|
|
||||||
|
def get_meals(group_id: UUID4):
|
||||||
|
if group_id not in memo:
|
||||||
|
memo[group_id] = repos.meals.get_all(group_id=group_id)
|
||||||
|
return memo[group_id]
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
meals = get_meals(result.group_id)
|
||||||
|
|
||||||
|
if not meals:
|
||||||
|
continue
|
||||||
|
|
||||||
|
requests.post(result.url, json=jsonable_encoder(meals))
|
8
poetry.lock
generated
8
poetry.lock
generated
@ -1188,7 +1188,7 @@ rdflib = ">=5.0.0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "recipe-scrapers"
|
name = "recipe-scrapers"
|
||||||
version = "14.3.0"
|
version = "14.3.1"
|
||||||
description = "Python package, scraping recipes from all over the internet"
|
description = "Python package, scraping recipes from all over the internet"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
@ -1545,7 +1545,7 @@ pgsql = ["psycopg2-binary"]
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "1.1"
|
||||||
python-versions = "^3.10"
|
python-versions = "^3.10"
|
||||||
content-hash = "3a4e90f6b5b8a7ff46824949e7269f0d95905aa0351ff8478f096c97113ce855"
|
content-hash = "3eb07af7a1e1a96c0c308f2263258c27332f51454e3d8ba2e8dbb821d46236ca"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
aiofiles = [
|
aiofiles = [
|
||||||
@ -2437,8 +2437,8 @@ rdflib-jsonld = [
|
|||||||
{file = "rdflib_jsonld-0.6.2-py2.py3-none-any.whl", hash = "sha256:011afe67672353ca9978ab9a4bee964dff91f14042f2d8a28c22a573779d2f8b"},
|
{file = "rdflib_jsonld-0.6.2-py2.py3-none-any.whl", hash = "sha256:011afe67672353ca9978ab9a4bee964dff91f14042f2d8a28c22a573779d2f8b"},
|
||||||
]
|
]
|
||||||
recipe-scrapers = [
|
recipe-scrapers = [
|
||||||
{file = "recipe_scrapers-14.3.0-py3-none-any.whl", hash = "sha256:ff3344b741999671ec0aa74482f10aaac63ffc95b02fc6efbc853b3e3cfe6805"},
|
{file = "recipe_scrapers-14.3.1-py3-none-any.whl", hash = "sha256:0f15aee46dfc071627c5af8d068b0b2076f13c4525042e3e64455a5c43b49a80"},
|
||||||
{file = "recipe_scrapers-14.3.0.tar.gz", hash = "sha256:9954e01af9cfe2b3c08e3103ef8b4aae4c24257be2cb37711430365ffe57e055"},
|
{file = "recipe_scrapers-14.3.1.tar.gz", hash = "sha256:547ffd03aa9b8060f6167d7ee5dd5196acf4f5b31dbed3ab921d9051cc74201d"},
|
||||||
]
|
]
|
||||||
requests = [
|
requests = [
|
||||||
{file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},
|
{file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},
|
||||||
|
@ -30,7 +30,7 @@ passlib = "^1.7.4"
|
|||||||
lxml = "^4.7.1"
|
lxml = "^4.7.1"
|
||||||
Pillow = "^8.2.0"
|
Pillow = "^8.2.0"
|
||||||
apprise = "^0.9.6"
|
apprise = "^0.9.6"
|
||||||
recipe-scrapers = "^14.3.0"
|
recipe-scrapers = "^14.3.1"
|
||||||
psycopg2-binary = {version = "^2.9.1", optional = true}
|
psycopg2-binary = {version = "^2.9.1", optional = true}
|
||||||
gunicorn = "^20.1.0"
|
gunicorn = "^20.1.0"
|
||||||
emails = "^0.6"
|
emails = "^0.6"
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
|
import contextlib
|
||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
from pytest import MonkeyPatch, fixture
|
from pytest import MonkeyPatch, fixture
|
||||||
|
|
||||||
mp = MonkeyPatch()
|
mp = MonkeyPatch()
|
||||||
mp.setenv("PRODUCTION", "True")
|
mp.setenv("PRODUCTION", "True")
|
||||||
mp.setenv("TESTING", "True")
|
mp.setenv("TESTING", "True")
|
||||||
|
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@ -34,11 +35,9 @@ def api_client():
|
|||||||
|
|
||||||
yield TestClient(app)
|
yield TestClient(app)
|
||||||
|
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
settings = config.get_app_settings()
|
settings = config.get_app_settings()
|
||||||
settings.DB_PROVIDER.db_path.unlink() # Handle SQLite Provider
|
settings.DB_PROVIDER.db_path.unlink() # Handle SQLite Provider
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@fixture(scope="session")
|
@fixture(scope="session")
|
||||||
@ -52,16 +51,13 @@ def test_image_png():
|
|||||||
|
|
||||||
|
|
||||||
@fixture(scope="session", autouse=True)
|
@fixture(scope="session", autouse=True)
|
||||||
def global_cleanup() -> None:
|
def global_cleanup() -> Generator[None, None, None]:
|
||||||
"""Purges the .temp directory used for testing"""
|
"""Purges the .temp directory used for testing"""
|
||||||
yield None
|
yield None
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
temp_dir = Path(__file__).parent / ".temp"
|
temp_dir = Path(__file__).parent / ".temp"
|
||||||
|
|
||||||
if temp_dir.exists():
|
if temp_dir.exists():
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
@ -1,71 +1,74 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.utils import assert_derserialize, jsonify
|
||||||
from tests.utils.fixture_schemas import TestUser
|
from tests.utils.fixture_schemas import TestUser
|
||||||
|
|
||||||
|
|
||||||
class Routes:
|
class Routes:
|
||||||
base = "/api/groups/webhooks"
|
base = "/api/groups/webhooks"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def item(item_id: int) -> str:
|
def item(item_id: int) -> str:
|
||||||
return f"{Routes.base}/{item_id}"
|
return f"{Routes.base}/{item_id}"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def webhook_data():
|
def webhook_data():
|
||||||
return {"enabled": True, "name": "Test-Name", "url": "https://my-fake-url.com", "time": "00:00"}
|
return {
|
||||||
|
"enabled": True,
|
||||||
|
"name": "Test-Name",
|
||||||
|
"url": "https://my-fake-url.com",
|
||||||
|
"time": "00:00",
|
||||||
|
"scheduledTime": datetime.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_create_webhook(api_client: TestClient, unique_user: TestUser, webhook_data):
|
def test_create_webhook(api_client: TestClient, unique_user: TestUser, webhook_data):
|
||||||
response = api_client.post(Routes.base, json=webhook_data, headers=unique_user.token)
|
response = api_client.post(Routes.base, json=jsonify(webhook_data), headers=unique_user.token)
|
||||||
|
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
def test_read_webhook(api_client: TestClient, unique_user: TestUser, webhook_data):
|
def test_read_webhook(api_client: TestClient, unique_user: TestUser, webhook_data):
|
||||||
response = api_client.post(Routes.base, json=webhook_data, headers=unique_user.token)
|
response = api_client.post(Routes.base, json=jsonify(webhook_data), headers=unique_user.token)
|
||||||
id = response.json()["id"]
|
item_id = response.json()["id"]
|
||||||
|
|
||||||
response = api_client.get(Routes.item(id), headers=unique_user.token)
|
response = api_client.get(Routes.item(item_id), headers=unique_user.token)
|
||||||
|
webhook = assert_derserialize(response, 200)
|
||||||
|
|
||||||
webhook = response.json()
|
assert webhook["id"] == item_id
|
||||||
|
|
||||||
assert webhook["id"]
|
|
||||||
assert webhook["name"] == webhook_data["name"]
|
assert webhook["name"] == webhook_data["name"]
|
||||||
assert webhook["url"] == webhook_data["url"]
|
assert webhook["url"] == webhook_data["url"]
|
||||||
assert webhook["time"] == webhook_data["time"]
|
assert webhook["scheduledTime"] == str(webhook_data["scheduledTime"].astimezone(timezone.utc).time())
|
||||||
assert webhook["enabled"] == webhook_data["enabled"]
|
assert webhook["enabled"] == webhook_data["enabled"]
|
||||||
|
|
||||||
|
|
||||||
def test_update_webhook(api_client: TestClient, webhook_data, unique_user: TestUser):
|
def test_update_webhook(api_client: TestClient, webhook_data, unique_user: TestUser):
|
||||||
response = api_client.post(Routes.base, json=webhook_data, headers=unique_user.token)
|
response = api_client.post(Routes.base, json=jsonify(webhook_data), headers=unique_user.token)
|
||||||
id = response.json()["id"]
|
item_dict = assert_derserialize(response, 201)
|
||||||
|
item_id = item_dict["id"]
|
||||||
|
|
||||||
webhook_data["name"] = "My New Name"
|
webhook_data["name"] = "My New Name"
|
||||||
webhook_data["url"] = "https://my-new-fake-url.com"
|
webhook_data["url"] = "https://my-new-fake-url.com"
|
||||||
webhook_data["time"] = "01:00"
|
|
||||||
webhook_data["enabled"] = False
|
webhook_data["enabled"] = False
|
||||||
|
|
||||||
response = api_client.put(Routes.item(id), json=webhook_data, headers=unique_user.token)
|
response = api_client.put(Routes.item(item_id), json=jsonify(webhook_data), headers=unique_user.token)
|
||||||
|
updated_webhook = assert_derserialize(response, 200)
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
updated_webhook = response.json()
|
|
||||||
assert updated_webhook["name"] == webhook_data["name"]
|
assert updated_webhook["name"] == webhook_data["name"]
|
||||||
assert updated_webhook["url"] == webhook_data["url"]
|
assert updated_webhook["url"] == webhook_data["url"]
|
||||||
assert updated_webhook["time"] == webhook_data["time"]
|
|
||||||
assert updated_webhook["enabled"] == webhook_data["enabled"]
|
assert updated_webhook["enabled"] == webhook_data["enabled"]
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_webhook(api_client: TestClient, webhook_data, unique_user: TestUser):
|
def test_delete_webhook(api_client: TestClient, webhook_data, unique_user: TestUser):
|
||||||
response = api_client.post(Routes.base, json=webhook_data, headers=unique_user.token)
|
response = api_client.post(Routes.base, json=jsonify(webhook_data), headers=unique_user.token)
|
||||||
id = response.json()["id"]
|
item_dict = assert_derserialize(response, 201)
|
||||||
|
item_id = item_dict["id"]
|
||||||
response = api_client.delete(Routes.item(id), headers=unique_user.token)
|
|
||||||
|
|
||||||
|
response = api_client.delete(Routes.item(item_id), headers=unique_user.token)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
response = api_client.get(Routes.item(id), headers=unique_user.token)
|
response = api_client.get(Routes.item(item_id), headers=unique_user.token)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
@ -4,7 +4,7 @@ from mealie.core.config import get_app_settings
|
|||||||
from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter
|
from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter
|
||||||
|
|
||||||
ALEMBIC_VERSIONS = [
|
ALEMBIC_VERSIONS = [
|
||||||
{"version_num": "ab0bae02578f"},
|
{"version_num": "f30cf048c228"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ def match_file_tree(path_a: Path, path_b: Path):
|
|||||||
assert b_file.exists()
|
assert b_file.exists()
|
||||||
match_file_tree(a_file, b_file)
|
match_file_tree(a_file, b_file)
|
||||||
else:
|
else:
|
||||||
assert filecmp(path_a, path_b)
|
assert filecmp.cmp(path_a, path_b)
|
||||||
|
|
||||||
|
|
||||||
def test_database_backup():
|
def test_database_backup():
|
||||||
|
@ -0,0 +1,65 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from pydantic import UUID4
|
||||||
|
|
||||||
|
from mealie.repos.repository_factory import AllRepositories
|
||||||
|
from mealie.schema.group.webhook import SaveWebhook, WebhookType
|
||||||
|
from mealie.services.scheduler.tasks.post_webhooks import get_scheduled_webhooks
|
||||||
|
from tests.utils import random_string
|
||||||
|
from tests.utils.factories import random_bool
|
||||||
|
from tests.utils.fixture_schemas import TestUser
|
||||||
|
|
||||||
|
|
||||||
|
def webhook_factory(
|
||||||
|
group_id: str | UUID4,
|
||||||
|
enabled: bool = True,
|
||||||
|
name: str = "",
|
||||||
|
url: str = "",
|
||||||
|
scheduled_time: datetime | None = None,
|
||||||
|
webhook_type: str = WebhookType.mealplan,
|
||||||
|
) -> SaveWebhook:
|
||||||
|
return SaveWebhook(
|
||||||
|
enabled=enabled,
|
||||||
|
name=name or random_string(),
|
||||||
|
url=url or random_string(),
|
||||||
|
webhook_type=webhook_type,
|
||||||
|
scheduled_time=scheduled_time.time() if scheduled_time else datetime.now().time(),
|
||||||
|
group_id=group_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_scheduled_webhooks_filter_query(database: AllRepositories, unique_user: TestUser):
|
||||||
|
"""
|
||||||
|
get_scheduled_webhooks_test tests the get_scheduled_webhooks function.
|
||||||
|
"""
|
||||||
|
|
||||||
|
expected: list[SaveWebhook] = []
|
||||||
|
|
||||||
|
start = datetime.now()
|
||||||
|
|
||||||
|
for _ in range(5):
|
||||||
|
new_item = webhook_factory(group_id=unique_user.group_id, enabled=random_bool())
|
||||||
|
out_of_range_item = webhook_factory(
|
||||||
|
group_id=unique_user.group_id,
|
||||||
|
enabled=random_bool(),
|
||||||
|
scheduled_time=(start - timedelta(minutes=20)),
|
||||||
|
)
|
||||||
|
|
||||||
|
database.webhooks.create(new_item)
|
||||||
|
database.webhooks.create(out_of_range_item)
|
||||||
|
|
||||||
|
if new_item.enabled:
|
||||||
|
expected.append(new_item)
|
||||||
|
|
||||||
|
results = get_scheduled_webhooks(database.session, start, datetime.now() + timedelta(minutes=5))
|
||||||
|
|
||||||
|
assert len(results) == len(expected)
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
assert result.enabled
|
||||||
|
|
||||||
|
for expected_item in expected:
|
||||||
|
|
||||||
|
if result.name == expected_item.name: # Names are uniquely generated so we can use this to compare
|
||||||
|
assert result.enabled == expected_item.enabled
|
||||||
|
break
|
Loading…
x
Reference in New Issue
Block a user