mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-03 06:23:10 -05: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:
@@ -57,6 +57,10 @@ async def start_scheduler():
|
||||
tasks.purge_group_data_exports,
|
||||
)
|
||||
|
||||
SchedulerRegistry.register_minutely(
|
||||
tasks.post_group_webhooks,
|
||||
)
|
||||
|
||||
SchedulerRegistry.print_jobs()
|
||||
|
||||
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_utils import GUID, auto_init
|
||||
@@ -14,8 +16,15 @@ class GroupWebhooksModel(SqlAlchemyBase, BaseMixins):
|
||||
enabled = Column(Boolean, default=False)
|
||||
name = 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")
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
pass
|
||||
...
|
||||
|
||||
@@ -8,6 +8,9 @@ from .repository_generic import RepositoryGeneric
|
||||
|
||||
|
||||
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]:
|
||||
start_str = start.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_statistics 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 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
|
||||
|
||||
|
||||
class WebhookType(str, enum.Enum):
|
||||
mealplan = "mealplan"
|
||||
|
||||
|
||||
class CreateWebhook(MealieModel):
|
||||
enabled: bool = True
|
||||
name: 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):
|
||||
|
||||
@@ -18,10 +18,12 @@ class AlchemyExporter(BaseService):
|
||||
|
||||
look_for_datetime = {"created_at", "update_at", "date_updated", "timestamp", "expires_at"}
|
||||
look_for_date = {"date_added", "date"}
|
||||
look_for_time = {"scheduled_time"}
|
||||
|
||||
class DateTimeParser(BaseModel):
|
||||
date: datetime.date = None
|
||||
time: datetime.datetime = None
|
||||
dt: datetime.datetime = None
|
||||
time: datetime.time = None
|
||||
|
||||
def __init__(self, connection_str: str) -> None:
|
||||
super().__init__()
|
||||
@@ -44,10 +46,11 @@ class AlchemyExporter(BaseService):
|
||||
data[key] = [AlchemyExporter.convert_to_datetime(item) for item in value]
|
||||
elif isinstance(value, str):
|
||||
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:
|
||||
data[key] = AlchemyExporter.DateTimeParser(date=value).date
|
||||
|
||||
if key in AlchemyExporter.look_for_time:
|
||||
data[key] = AlchemyExporter.DateTimeParser(time=value).time
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -37,7 +37,7 @@ class EventBusService:
|
||||
self.bg = bg
|
||||
self._publisher = ApprisePublisher
|
||||
self.session = session
|
||||
self.group_id = None
|
||||
self.group_id: UUID4 | None = None
|
||||
|
||||
@property
|
||||
def publisher(self) -> PublisherLike:
|
||||
@@ -55,7 +55,7 @@ class EventBusService:
|
||||
def dispatch(
|
||||
self, group_id: UUID4, event_type: EventTypes, msg: str = "", event_source: EventSource = None
|
||||
) -> None:
|
||||
self.group_id = group_id # type: ignore
|
||||
self.group_id = group_id
|
||||
|
||||
def _dispatch(event_source: EventSource = None):
|
||||
if urls := self.get_urls(event_type):
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
from .purge_group_exports import *
|
||||
from .purge_password_reset import *
|
||||
from .purge_registration import *
|
||||
from .post_webhooks import post_group_webhooks
|
||||
from .purge_group_exports import purge_group_data_exports
|
||||
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
|
||||
|
||||
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))
|
||||
Reference in New Issue
Block a user