mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-30 17:53:31 -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:
		| @@ -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 | ||||||
		Reference in New Issue
	
	Block a user