1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-07-25 08:09:41 +02:00

refactor: webhook events (#1661)

* refactored EventBusService to work outside FastAPI

* extended event models

* refactored webhooks to run through event bus

* added basic webhook test route

* changed get_all to page_all

* fixed incorrectly implemented Vue variables

* fixed broken webhook test

* changed factory from staticmethod to classmethod

* made query boundary definitions easier to read
This commit is contained in:
Michael Genson 2022-09-27 21:55:20 -05:00 committed by GitHub
parent 025f1bc603
commit 796e55b7d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 175 additions and 54 deletions

View file

@ -1,9 +1,9 @@
<template> <template>
<div> <div>
<v-card-text> <v-card-text>
<v-switch v-model="webhookCopy.enabled" label="$t('general.enabled')"></v-switch> <v-switch v-model="webhookCopy.enabled" :label="$t('general.enabled')"></v-switch>
<v-text-field v-model="webhookCopy.name" label="$t('settings.webhooks.webhook-name')"></v-text-field> <v-text-field v-model="webhookCopy.name" :label="$t('settings.webhooks.webhook-name')"></v-text-field>
<v-text-field v-model="webhookCopy.url" label="$t('settings.webhooks.webhook-url')"></v-text-field> <v-text-field v-model="webhookCopy.url" :label="$t('settings.webhooks.webhook-url')"></v-text-field>
<v-time-picker v-model="scheduledTime" class="elevation-2" ampm-in-title format="ampm"></v-time-picker> <v-time-picker v-model="scheduledTime" class="elevation-2" ampm-in-title format="ampm"></v-time-picker>
</v-card-text> </v-card-text>
<v-card-actions class="py-0 justify-end"> <v-card-actions class="py-0 justify-end">

View file

@ -122,7 +122,7 @@ class BaseCrudController(BaseUserController):
Base class for all CRUD controllers to facilitate common CRUD functions. Base class for all CRUD controllers to facilitate common CRUD functions.
""" """
event_bus: EventBusService = Depends(EventBusService) event_bus: EventBusService = Depends(EventBusService.create)
def publish_event(self, event_type: EventTypes, document_data: EventDocumentDataBase, message: str = "") -> None: def publish_event(self, event_type: EventTypes, document_data: EventDocumentDataBase, message: str = "") -> None:
self.event_bus.dispatch( self.event_bus.dispatch(

View file

@ -35,7 +35,7 @@ router = APIRouter(
@controller(router) @controller(router)
class GroupEventsNotifierController(BaseUserController): class GroupEventsNotifierController(BaseUserController):
event_bus: EventBusService = Depends(EventBusService) event_bus: EventBusService = Depends(EventBusService.create)
@cached_property @cached_property
def repo(self): def repo(self):

View file

@ -1,3 +1,4 @@
from datetime import datetime
from functools import cached_property from functools import cached_property
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
@ -9,6 +10,7 @@ from mealie.routes._base.mixins import HttpRepo
from mealie.schema import mapper from mealie.schema import mapper
from mealie.schema.group.webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination from mealie.schema.group.webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination
from mealie.schema.response.pagination import PaginationQuery from mealie.schema.response.pagination import PaginationQuery
from mealie.services.scheduler.tasks.post_webhooks import post_group_webhooks
router = APIRouter(prefix="/groups/webhooks", tags=["Groups: Webhooks"]) router = APIRouter(prefix="/groups/webhooks", tags=["Groups: Webhooks"])
@ -38,6 +40,14 @@ class ReadWebhookController(BaseUserController):
save = mapper.cast(data, SaveWebhook, group_id=self.group.id) save = mapper.cast(data, SaveWebhook, group_id=self.group.id)
return self.mixins.create_one(save) return self.mixins.create_one(save)
@router.post("/rerun")
def rerun_webhooks(self):
"""Manually re-fires all previously scheduled webhooks for today"""
start_time = datetime.min.time()
start_dt = datetime.combine(datetime.utcnow().date(), start_time)
post_group_webhooks(start_dt=start_dt, group_id=self.group.id)
@router.get("/{item_id}", response_model=ReadWebhook) @router.get("/{item_id}", response_model=ReadWebhook)
def get_one(self, item_id: UUID4): def get_one(self, item_id: UUID4):
return self.mixins.get_one(item_id) return self.mixins.get_one(item_id)

View file

@ -13,6 +13,9 @@ class GroupEventNotifierOptions(MealieModel):
If you modify this, make sure to update the EventBusService as well. If you modify this, make sure to update the EventBusService as well.
""" """
test_message: bool = False
webhook_task: bool = False
recipe_created: bool = False recipe_created: bool = False
recipe_updated: bool = False recipe_updated: bool = False
recipe_deleted: bool = False recipe_deleted: bool = False

View file

@ -1,15 +1,21 @@
import json import json
from datetime import datetime, timezone
from typing import cast
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from pydantic import UUID4 from pydantic import UUID4
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from mealie.db.models.group.webhooks import GroupWebhooksModel
from mealie.repos.all_repositories import get_repositories
from mealie.repos.repository_factory import AllRepositories from mealie.repos.repository_factory import AllRepositories
from mealie.schema.group.group_events import GroupEventNotifierPrivate from mealie.schema.group.group_events import GroupEventNotifierPrivate
from mealie.schema.group.webhook import ReadWebhook
from mealie.schema.response.pagination import PaginationQuery
from .event_types import Event from .event_types import Event, EventDocumentType, EventTypes, EventWebhookData
from .publisher import ApprisePublisher, PublisherLike from .publisher import ApprisePublisher, PublisherLike, WebhookPublisher
class EventListenerBase: class EventListenerBase:
@ -78,3 +84,48 @@ class AppriseEventListener(EventListenerBase):
@staticmethod @staticmethod
def is_custom_url(url: str): def is_custom_url(url: str):
return url.split(":", 1)[0].lower() in ["form", "forms", "json", "jsons", "xml", "xmls"] return url.split(":", 1)[0].lower() in ["form", "forms", "json", "jsons", "xml", "xmls"]
class WebhookEventListener(EventListenerBase):
def __init__(self, session: Session, group_id: UUID4) -> None:
super().__init__(session, group_id, WebhookPublisher())
self.repos = get_repositories(session)
def get_subscribers(self, event: Event) -> list[ReadWebhook]:
# we only care about events that contain webhook information
if not (event.event_type == EventTypes.webhook_task and isinstance(event.document_data, EventWebhookData)):
return []
scheduled_webhooks = self.get_scheduled_webhooks(
event.document_data.webhook_start_dt, event.document_data.webhook_end_dt
)
return scheduled_webhooks
def publish_to_subscribers(self, event: Event, subscribers: list[ReadWebhook]) -> None:
match event.document_data.document_type:
case EventDocumentType.mealplan:
# TODO: limit mealplan data to a date range instead of returning all mealplans
meal_repo = self.repos.meals.by_group(self.group_id)
meal_pagination_data = meal_repo.page_all(pagination=PaginationQuery(page=1, per_page=-1))
meal_data = meal_pagination_data.items
if meal_data:
webhook_data = cast(EventWebhookData, event.document_data)
webhook_data.webhook_body = meal_data
self.publisher.publish(event, [webhook.url for webhook in subscribers])
case _:
# if the document type is not supported, do nothing
pass
def get_scheduled_webhooks(self, start_dt: datetime, end_dt: datetime) -> list[ReadWebhook]:
"""Fetches all scheduled webhooks from the database"""
return (
self.session.query(GroupWebhooksModel)
.where(
GroupWebhooksModel.enabled == True, # noqa: E712 - required for SQLAlchemy comparison
GroupWebhooksModel.scheduled_time > start_dt.astimezone(timezone.utc).time(),
GroupWebhooksModel.scheduled_time <= end_dt.astimezone(timezone.utc).time(),
)
.all()
)

View file

@ -2,10 +2,15 @@ from typing import Optional
from fastapi import BackgroundTasks, Depends from fastapi import BackgroundTasks, Depends
from pydantic import UUID4 from pydantic import UUID4
from sqlalchemy.orm.session import Session
from mealie.core.config import get_app_settings from mealie.core.config import get_app_settings
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.services.event_bus_service.event_bus_listeners import AppriseEventListener, EventListenerBase from mealie.services.event_bus_service.event_bus_listeners import (
AppriseEventListener,
EventListenerBase,
WebhookEventListener,
)
from .event_types import Event, EventBusMessage, EventDocumentDataBase, EventTypes from .event_types import Event, EventBusMessage, EventDocumentDataBase, EventTypes
@ -35,12 +40,20 @@ class EventSource:
class EventBusService: class EventBusService:
def __init__(self, bg: BackgroundTasks, session=Depends(generate_session)) -> None: def __init__(
self, bg: Optional[BackgroundTasks] = None, session: Optional[Session] = None, group_id: UUID4 | None = None
) -> None:
if not session:
session = next(generate_session())
self.bg = bg self.bg = bg
self.session = session self.session = session
self.group_id: UUID4 | None = None self.group_id = group_id
self.listeners: list[EventListenerBase] = [AppriseEventListener(self.session, self.group_id)] self.listeners: list[EventListenerBase] = [
AppriseEventListener(self.session, self.group_id),
WebhookEventListener(self.session, self.group_id),
]
def dispatch( def dispatch(
self, self,
@ -59,10 +72,18 @@ class EventBusService:
document_data=document_data, document_data=document_data,
) )
self.bg.add_task(self.publish_event, event=event) if self.bg:
self.bg.add_task(self.publish_event, event=event)
else:
self.publish_event(event)
def publish_event(self, event: Event) -> None: def publish_event(self, event: Event) -> None:
"""Publishes the event to all listeners""" """Publishes the event to all listeners"""
for listener in self.listeners: for listener in self.listeners:
if subscribers := listener.get_subscribers(event): if subscribers := listener.get_subscribers(event):
listener.publish_to_subscribers(event, subscribers) listener.publish_to_subscribers(event, subscribers)
@classmethod
def create(cls, bg: BackgroundTasks, session=Depends(generate_session), group_id: UUID4 | None = None):
return cls(bg, session, group_id)

View file

@ -1,11 +1,14 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from enum import Enum, auto from enum import Enum, auto
from typing import Any
from pydantic import UUID4 from pydantic import UUID4
from ...schema._mealie.mealie_model import MealieModel from ...schema._mealie.mealie_model import MealieModel
INTERNAL_INTEGRATION_ID = "mealie_generic_user"
class EventTypes(Enum): class EventTypes(Enum):
""" """
@ -18,7 +21,9 @@ class EventTypes(Enum):
(like shopping list items), modify the event document type instead (which is not tied to a database entry). (like shopping list items), modify the event document type instead (which is not tied to a database entry).
""" """
# used internally and cannot be subscribed to
test_message = auto() test_message = auto()
webhook_task = auto()
recipe_created = auto() recipe_created = auto()
recipe_updated = auto() recipe_updated = auto()
@ -54,6 +59,7 @@ class EventDocumentType(Enum):
category = "category" category = "category"
cookbook = "cookbook" cookbook = "cookbook"
mealplan = "mealplan"
shopping_list = "shopping_list" shopping_list = "shopping_list"
shopping_list_item = "shopping_list_item" shopping_list_item = "shopping_list_item"
recipe = "recipe" recipe = "recipe"
@ -122,6 +128,12 @@ class EventTagData(EventDocumentDataBase):
tag_id: UUID4 tag_id: UUID4
class EventWebhookData(EventDocumentDataBase):
webhook_start_dt: datetime
webhook_end_dt: datetime
webhook_body: Any
class EventBusMessage(MealieModel): class EventBusMessage(MealieModel):
title: str title: str
body: str = "" body: str = ""

View file

@ -1,6 +1,8 @@
from typing import Protocol from typing import Protocol
import apprise import apprise
import requests
from fastapi.encoders import jsonable_encoder
from mealie.services.event_bus_service.event_types import Event from mealie.services.event_bus_service.event_types import Event
@ -34,3 +36,15 @@ class ApprisePublisher:
raise Exception("Apprise URL Add Failed") raise Exception("Apprise URL Add Failed")
self.apprise.notify(title=event.message.title, body=event.message.body, tag=tags) self.apprise.notify(title=event.message.title, body=event.message.body, tag=tags)
class WebhookPublisher:
def __init__(self, hard_fail=False) -> None:
self.hard_fail = hard_fail
def publish(self, event: Event, notification_urls: list[str]):
event_payload = jsonable_encoder(event)
for url in notification_urls:
r = requests.post(url, json=event_payload, timeout=15)
if self.hard_fail:
r.raise_for_status()

View file

@ -1,54 +1,63 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional
import requests
from fastapi.encoders import jsonable_encoder
from pydantic import UUID4 from pydantic import UUID4
from sqlalchemy.orm import Session
from mealie.db.db_setup import create_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 from mealie.repos.all_repositories import get_repositories
from mealie.schema.response.pagination import PaginationQuery
from mealie.services.event_bus_service.event_bus_service import EventBusService
from mealie.services.event_bus_service.event_types import (
INTERNAL_INTEGRATION_ID,
EventDocumentType,
EventOperation,
EventTypes,
EventWebhookData,
)
last_ran = datetime.now(timezone.utc) last_ran = datetime.now(timezone.utc)
def get_scheduled_webhooks(session: Session, bottom: datetime, top: datetime) -> list[GroupWebhooksModel]: def post_group_webhooks(start_dt: Optional[datetime] = None, group_id: Optional[UUID4] = None) -> None:
"""Post webhook events to specified group, or all groups"""
global last_ran
# if not specified, start the query at the last time the service ran
start_dt = start_dt or last_ran
# end the query at the current time
last_ran = end_dt = datetime.now(timezone.utc)
if group_id is None:
# publish the webhook event to each group's event bus
session = create_session()
repos = get_repositories(session)
groups_data = repos.groups.page_all(PaginationQuery(page=1, per_page=-1))
group_ids = [group.id for group in groups_data.items]
else:
group_ids = [group_id]
""" """
get_scheduled_webhooks queries the database for all webhooks scheduled between the bottom and At this time only mealplan webhooks are supported. To add support for more types,
top time ranges. It returns a list of GroupWebhooksModel objects. add a dispatch event for that type here (e.g. EventDocumentType.recipe_bulk_report) and
handle the webhook data in the webhook event bus listener
""" """
return ( event_type = EventTypes.webhook_task
session.query(GroupWebhooksModel) event_document_data = EventWebhookData(
.where( document_type=EventDocumentType.mealplan,
GroupWebhooksModel.enabled == True, # noqa: E712 - required for SQLAlchemy comparison operation=EventOperation.info,
GroupWebhooksModel.scheduled_time > bottom.astimezone(timezone.utc).time(), webhook_start_dt=start_dt,
GroupWebhooksModel.scheduled_time <= top.astimezone(timezone.utc).time(), webhook_end_dt=end_dt,
)
.all()
) )
for group_id in group_ids:
def post_group_webhooks() -> None: event_bus = EventBusService(group_id=group_id)
global last_ran event_bus.dispatch(
session = create_session() integration_id=INTERNAL_INTEGRATION_ID,
results = get_scheduled_webhooks(session, last_ran, datetime.now()) group_id=group_id,
event_type=event_type,
last_ran = datetime.now(timezone.utc) document_data=event_document_data,
)
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))

View file

@ -4,7 +4,7 @@ from pydantic import UUID4
from mealie.repos.repository_factory import AllRepositories from mealie.repos.repository_factory import AllRepositories
from mealie.schema.group.webhook import SaveWebhook, WebhookType from mealie.schema.group.webhook import SaveWebhook, WebhookType
from mealie.services.scheduler.tasks.post_webhooks import get_scheduled_webhooks from mealie.services.event_bus_service.event_bus_listeners import WebhookEventListener
from tests.utils import random_string from tests.utils import random_string
from tests.utils.factories import random_bool from tests.utils.factories import random_bool
from tests.utils.fixture_schemas import TestUser from tests.utils.fixture_schemas import TestUser
@ -30,7 +30,7 @@ def webhook_factory(
def test_get_scheduled_webhooks_filter_query(database: AllRepositories, unique_user: TestUser): def test_get_scheduled_webhooks_filter_query(database: AllRepositories, unique_user: TestUser):
""" """
get_scheduled_webhooks_test tests the get_scheduled_webhooks function. get_scheduled_webhooks_test tests the get_scheduled_webhooks function on the webhook event bus listener.
""" """
expected: list[SaveWebhook] = [] expected: list[SaveWebhook] = []
@ -51,7 +51,8 @@ def test_get_scheduled_webhooks_filter_query(database: AllRepositories, unique_u
if new_item.enabled: if new_item.enabled:
expected.append(new_item) expected.append(new_item)
results = get_scheduled_webhooks(database.session, start, datetime.now() + timedelta(minutes=5)) event_bus_listener = WebhookEventListener(database.session, unique_user.group_id) # type: ignore
results = event_bus_listener.get_scheduled_webhooks(start, datetime.now() + timedelta(minutes=5))
assert len(results) == len(expected) assert len(results) == len(expected)