mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-04 23:13:12 -05:00
Feature/group based notifications (#918)
* fix group page * setup group notification for backend * update type generators * script to auto-generate schema exports * setup frontend CRUD interface * remove old notifications UI * drop old events api * add test functionality * update naming for fields * add event dispatcher functionality * bump to python 3.10 * bump python version * purge old event code * use-async apprise * set mealie logo as image * unify styles for buttons rows * add links to banners
This commit is contained in:
@@ -62,4 +62,4 @@ def db_provider_factory(provider_name: str, data_dir: Path, env_file: Path, env_
|
||||
elif provider_name == "sqlite":
|
||||
return SQLiteProvider(data_dir=data_dir)
|
||||
else:
|
||||
return
|
||||
return SQLiteProvider(data_dir=data_dir)
|
||||
|
||||
@@ -1,31 +1,10 @@
|
||||
from sqlalchemy import Boolean, Column, DateTime, Integer, String
|
||||
from sqlalchemy import Column, DateTime, Integer, String
|
||||
|
||||
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
||||
|
||||
from ._model_utils import auto_init
|
||||
|
||||
|
||||
class EventNotification(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "event_notifications"
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String)
|
||||
type = Column(String)
|
||||
notification_url = Column(String)
|
||||
|
||||
# Event Types
|
||||
general = Column(Boolean, default=False)
|
||||
recipe = Column(Boolean, default=False)
|
||||
backup = Column(Boolean, default=False)
|
||||
scheduled = Column(Boolean, default=False)
|
||||
migration = Column(Boolean, default=False)
|
||||
group = Column(Boolean, default=False)
|
||||
user = Column(Boolean, default=False)
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class Event(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "events"
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from .cookbook import *
|
||||
from .events import *
|
||||
from .exports import *
|
||||
from .group import *
|
||||
from .invite_tokens import *
|
||||
|
||||
61
mealie/db/models/group/events.py
Normal file
61
mealie/db/models/group/events.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, String, orm
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import GUID, auto_init
|
||||
|
||||
|
||||
class GroupEventNotifierOptionsModel(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "group_events_notifier_options"
|
||||
|
||||
id = Column(GUID, primary_key=True, default=GUID.generate)
|
||||
event_notifier_id = Column(GUID, ForeignKey("group_events_notifiers.id"), nullable=False)
|
||||
|
||||
recipe_created = Column(Boolean, default=False, nullable=False)
|
||||
recipe_updated = Column(Boolean, default=False, nullable=False)
|
||||
recipe_deleted = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
user_signup = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
data_migrations = Column(Boolean, default=False, nullable=False)
|
||||
data_export = Column(Boolean, default=False, nullable=False)
|
||||
data_import = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
mealplan_entry_created = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
shopping_list_created = Column(Boolean, default=False, nullable=False)
|
||||
shopping_list_updated = Column(Boolean, default=False, nullable=False)
|
||||
shopping_list_deleted = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
cookbook_created = Column(Boolean, default=False, nullable=False)
|
||||
cookbook_updated = Column(Boolean, default=False, nullable=False)
|
||||
cookbook_deleted = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
tag_created = Column(Boolean, default=False, nullable=False)
|
||||
tag_updated = Column(Boolean, default=False, nullable=False)
|
||||
tag_deleted = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
category_created = Column(Boolean, default=False, nullable=False)
|
||||
category_updated = Column(Boolean, default=False, nullable=False)
|
||||
category_deleted = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class GroupEventNotifierModel(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "group_events_notifiers"
|
||||
|
||||
id = Column(GUID, primary_key=True, default=GUID.generate)
|
||||
name = Column(String, nullable=False)
|
||||
enabled = Column(String, default=True, nullable=False)
|
||||
apprise_url = Column(String, nullable=False)
|
||||
|
||||
group = orm.relationship("Group", back_populates="group_event_notifiers", single_parent=True)
|
||||
group_id = Column(GUID, ForeignKey("groups.id"), index=True)
|
||||
|
||||
options = orm.relationship(GroupEventNotifierOptionsModel, uselist=False, cascade="all, delete-orphan")
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
pass
|
||||
@@ -1,5 +1,3 @@
|
||||
import uuid
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlalchemy.orm as orm
|
||||
from sqlalchemy.orm.session import Session
|
||||
@@ -22,7 +20,7 @@ settings = get_app_settings()
|
||||
|
||||
class Group(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "groups"
|
||||
id = sa.Column(GUID, primary_key=True, default=uuid.uuid4)
|
||||
id = sa.Column(GUID, primary_key=True, default=GUID.generate)
|
||||
name = sa.Column(sa.String, index=True, nullable=False, unique=True)
|
||||
users = orm.relationship("User", back_populates="group")
|
||||
categories = orm.relationship(Category, secondary=group2categories, single_parent=True, uselist=True)
|
||||
@@ -57,6 +55,7 @@ class Group(SqlAlchemyBase, BaseMixins):
|
||||
data_exports = orm.relationship("GroupDataExportsModel", **common_args)
|
||||
shopping_lists = orm.relationship("ShoppingList", **common_args)
|
||||
group_reports = orm.relationship("ReportModel", **common_args)
|
||||
group_event_notifiers = orm.relationship("GroupEventNotifierModel", **common_args)
|
||||
|
||||
class Config:
|
||||
exclude = {
|
||||
|
||||
@@ -2,9 +2,10 @@ from functools import cached_property
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from mealie.db.models.event import Event, EventNotification
|
||||
from mealie.db.models.event import Event
|
||||
from mealie.db.models.group import Group, GroupMealPlan, ReportEntryModel, ReportModel
|
||||
from mealie.db.models.group.cookbook import CookBook
|
||||
from mealie.db.models.group.events import GroupEventNotifierModel
|
||||
from mealie.db.models.group.exports import GroupDataExportsModel
|
||||
from mealie.db.models.group.invite_tokens import GroupInviteToken
|
||||
from mealie.db.models.group.preferences import GroupPreferencesModel
|
||||
@@ -24,7 +25,7 @@ from mealie.db.models.users import LongLiveToken, User
|
||||
from mealie.db.models.users.password_reset import PasswordResetModel
|
||||
from mealie.schema.cookbook.cookbook import ReadCookBook
|
||||
from mealie.schema.events import Event as EventSchema
|
||||
from mealie.schema.events import EventNotificationIn
|
||||
from mealie.schema.group.group_events import GroupEventNotifierOut
|
||||
from mealie.schema.group.group_exports import GroupDataExport
|
||||
from mealie.schema.group.group_preferences import ReadGroupPreferences
|
||||
from mealie.schema.group.group_shopping_list import ShoppingListItemOut, ShoppingListOut
|
||||
@@ -116,10 +117,6 @@ class AllRepositories:
|
||||
def sign_up(self) -> RepositoryGeneric[SignUpOut, SignUp]:
|
||||
return RepositoryGeneric(self.session, pk_id, SignUp, SignUpOut)
|
||||
|
||||
@cached_property
|
||||
def event_notifications(self) -> RepositoryGeneric[EventNotificationIn, EventNotification]:
|
||||
return RepositoryGeneric(self.session, pk_id, EventNotification, EventNotificationIn)
|
||||
|
||||
@cached_property
|
||||
def events(self) -> RepositoryGeneric[EventSchema, Event]:
|
||||
return RepositoryGeneric(self.session, pk_id, Event, EventSchema)
|
||||
@@ -193,3 +190,7 @@ class AllRepositories:
|
||||
@cached_property
|
||||
def group_multi_purpose_labels(self) -> RepositoryGeneric[MultiPurposeLabelOut, MultiPurposeLabel]:
|
||||
return RepositoryGeneric(self.session, pk_id, MultiPurposeLabel, MultiPurposeLabelOut)
|
||||
|
||||
@cached_property
|
||||
def group_event_notifier(self) -> RepositoryGeneric[GroupEventNotifierOut, GroupEventNotifierModel]:
|
||||
return RepositoryGeneric(self.session, pk_id, GroupEventNotifierModel, GroupEventNotifierOut)
|
||||
|
||||
@@ -70,6 +70,8 @@ class RepositoryGeneric(Generic[T, D]):
|
||||
def get_all(self, limit: int = None, order_by: str = None, start=0, override_schema=None) -> list[T]:
|
||||
eff_schema = override_schema or self.schema
|
||||
|
||||
filter = self._filter_builder()
|
||||
|
||||
order_attr = None
|
||||
if order_by:
|
||||
order_attr = getattr(self.sql_model, str(order_by))
|
||||
@@ -77,10 +79,18 @@ class RepositoryGeneric(Generic[T, D]):
|
||||
|
||||
return [
|
||||
eff_schema.from_orm(x)
|
||||
for x in self.session.query(self.sql_model).order_by(order_attr).offset(start).limit(limit).all()
|
||||
for x in self.session.query(self.sql_model)
|
||||
.order_by(order_attr)
|
||||
.filter_by(**filter)
|
||||
.offset(start)
|
||||
.limit(limit)
|
||||
.all()
|
||||
]
|
||||
|
||||
return [eff_schema.from_orm(x) for x in self.session.query(self.sql_model).offset(start).limit(limit).all()]
|
||||
return [
|
||||
eff_schema.from_orm(x)
|
||||
for x in self.session.query(self.sql_model).filter_by(**filter).offset(start).limit(limit).all()
|
||||
]
|
||||
|
||||
def multi_query(
|
||||
self,
|
||||
@@ -92,6 +102,8 @@ class RepositoryGeneric(Generic[T, D]):
|
||||
) -> list[T]:
|
||||
eff_schema = override_schema or self.schema
|
||||
|
||||
filer = self._filter_builder(**query_by)
|
||||
|
||||
order_attr = None
|
||||
if order_by:
|
||||
order_attr = getattr(self.sql_model, str(order_by))
|
||||
@@ -100,7 +112,7 @@ class RepositoryGeneric(Generic[T, D]):
|
||||
return [
|
||||
eff_schema.from_orm(x)
|
||||
for x in self.session.query(self.sql_model)
|
||||
.filter_by(**query_by)
|
||||
.filter_by(**filer)
|
||||
.order_by(order_attr)
|
||||
.offset(start)
|
||||
.limit(limit)
|
||||
|
||||
@@ -76,6 +76,17 @@ class CrudMixins:
|
||||
|
||||
return item
|
||||
|
||||
def get_one(self, item_id):
|
||||
item = self.repo.get(item_id)
|
||||
|
||||
if not item:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
detail=ErrorResponse.respond(message="Not found."),
|
||||
)
|
||||
|
||||
return item
|
||||
|
||||
def update_one(self, data, item_id):
|
||||
item = self.repo.get(item_id)
|
||||
|
||||
@@ -98,11 +109,11 @@ class CrudMixins:
|
||||
self.handle_exception(ex)
|
||||
|
||||
def delete_one(self, item_id):
|
||||
item = self.repo.get(item_id)
|
||||
self.logger.info(f"Deleting item with id {item}")
|
||||
self.logger.info(f"Deleting item with id {item_id}")
|
||||
|
||||
try:
|
||||
item = self.repo.delete(item)
|
||||
item = self.repo.delete(item_id)
|
||||
self.logger.info(item)
|
||||
except Exception as ex:
|
||||
self.handle_exception(ex)
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import events, notifications
|
||||
from . import events
|
||||
|
||||
about_router = APIRouter(prefix="/api/about")
|
||||
|
||||
about_router.include_router(events.router, tags=["Events: CRUD"])
|
||||
about_router.include_router(notifications.router, tags=["Events: Notifications"])
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
from http.client import HTTPException
|
||||
|
||||
from fastapi import Depends, status
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.routes.routers import AdminAPIRouter
|
||||
from mealie.schema.events import EventNotificationIn, EventNotificationOut, TestEvent
|
||||
from mealie.services.events import test_notification
|
||||
|
||||
router = AdminAPIRouter()
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
@router.post("/notifications")
|
||||
async def create_event_notification(
|
||||
event_data: EventNotificationIn,
|
||||
session: Session = Depends(generate_session),
|
||||
):
|
||||
"""Create event_notification in the Database"""
|
||||
db = get_repositories(session)
|
||||
|
||||
return db.event_notifications.create(event_data)
|
||||
|
||||
|
||||
@router.post("/notifications/test")
|
||||
async def test_notification_route(
|
||||
test_data: TestEvent,
|
||||
session: Session = Depends(generate_session),
|
||||
):
|
||||
"""Create event_notification in the Database"""
|
||||
db = get_repositories(session)
|
||||
|
||||
if test_data.id:
|
||||
event_obj: EventNotificationIn = db.event_notifications.get(test_data.id)
|
||||
test_data.test_url = event_obj.notification_url
|
||||
|
||||
try:
|
||||
test_notification(test_data.test_url)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=list[EventNotificationOut])
|
||||
async def get_all_event_notification(session: Session = Depends(generate_session)):
|
||||
"""Get all event_notification from the Database"""
|
||||
db = get_repositories(session)
|
||||
return db.event_notifications.get_all(override_schema=EventNotificationOut)
|
||||
|
||||
|
||||
@router.put("/notifications/{id}")
|
||||
async def update_event_notification(id: int, session: Session = Depends(generate_session)):
|
||||
"""Update event_notification in the Database"""
|
||||
# not yet implemented
|
||||
raise HTTPException(status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||
|
||||
|
||||
@router.delete("/notifications/{id}")
|
||||
async def delete_event_notification(id: int, session: Session = Depends(generate_session)):
|
||||
"""Delete event_notification from the Database"""
|
||||
# Delete Item
|
||||
db = get_repositories(session)
|
||||
return db.event_notifications.delete(id)
|
||||
@@ -8,7 +8,7 @@ from mealie.services.group_services import CookbookService, WebhookService
|
||||
from mealie.services.group_services.meal_service import MealService
|
||||
from mealie.services.group_services.reports_service import GroupReportService
|
||||
|
||||
from . import categories, invitations, labels, migrations, preferences, self_service, shopping_lists
|
||||
from . import categories, invitations, labels, migrations, notifications, preferences, self_service, shopping_lists
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -56,3 +56,4 @@ def get_all_reports(
|
||||
router.include_router(report_router)
|
||||
router.include_router(shopping_lists.router)
|
||||
router.include_router(labels.router)
|
||||
router.include_router(notifications.router)
|
||||
|
||||
85
mealie/routes/groups/notifications.py
Normal file
85
mealie/routes/groups/notifications.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from functools import cached_property
|
||||
from sqlite3 import IntegrityError
|
||||
from typing import Type
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.routes._base.controller import controller
|
||||
from mealie.routes._base.dependencies import SharedDependencies
|
||||
from mealie.routes._base.mixins import CrudMixins
|
||||
from mealie.schema.group.group_events import (
|
||||
GroupEventNotifierCreate,
|
||||
GroupEventNotifierOut,
|
||||
GroupEventNotifierPrivate,
|
||||
GroupEventNotifierSave,
|
||||
GroupEventNotifierUpdate,
|
||||
)
|
||||
from mealie.schema.mapper import cast
|
||||
from mealie.schema.query import GetAll
|
||||
from mealie.services.event_bus_service.event_bus_service import EventBusService
|
||||
|
||||
router = APIRouter(prefix="/groups/events/notifications", tags=["Group: Event Notifications"])
|
||||
|
||||
|
||||
@controller(router)
|
||||
class GroupEventsNotifierController:
|
||||
deps: SharedDependencies = Depends(SharedDependencies.user)
|
||||
event_bus: EventBusService = Depends(EventBusService)
|
||||
|
||||
@cached_property
|
||||
def repo(self):
|
||||
if not self.deps.acting_user:
|
||||
raise Exception("No user is logged in.")
|
||||
|
||||
return self.deps.repos.group_event_notifier.by_group(self.deps.acting_user.group_id)
|
||||
|
||||
def registered_exceptions(self, ex: Type[Exception]) -> str:
|
||||
registered = {
|
||||
Exception: "An unexpected error occurred.",
|
||||
IntegrityError: "An unexpected error occurred.",
|
||||
}
|
||||
|
||||
return registered.get(ex, "An unexpected error occurred.")
|
||||
|
||||
# =======================================================================
|
||||
# CRUD Operations
|
||||
|
||||
@property
|
||||
def mixins(self) -> CrudMixins:
|
||||
return CrudMixins(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.")
|
||||
|
||||
@router.get("", response_model=list[GroupEventNotifierOut])
|
||||
def get_all(self, q: GetAll = Depends(GetAll)):
|
||||
return self.repo.get_all(start=q.start, limit=q.limit)
|
||||
|
||||
@router.post("", response_model=GroupEventNotifierOut, status_code=201)
|
||||
def create_one(self, data: GroupEventNotifierCreate):
|
||||
save_data = cast(data, GroupEventNotifierSave, group_id=self.deps.acting_user.group_id)
|
||||
return self.mixins.create_one(save_data)
|
||||
|
||||
@router.get("/{item_id}", response_model=GroupEventNotifierOut)
|
||||
def get_one(self, item_id: UUID4):
|
||||
return self.mixins.get_one(item_id)
|
||||
|
||||
@router.put("/{item_id}", response_model=GroupEventNotifierOut)
|
||||
def update_one(self, item_id: UUID4, data: GroupEventNotifierUpdate):
|
||||
if data.apprise_url is None:
|
||||
current_data: GroupEventNotifierPrivate = self.repo.get_one(
|
||||
item_id, override_schema=GroupEventNotifierPrivate
|
||||
)
|
||||
data.apprise_url = current_data.apprise_url
|
||||
|
||||
return self.mixins.update_one(data, item_id)
|
||||
|
||||
@router.delete("/{item_id}", status_code=204)
|
||||
def delete_one(self, item_id: UUID4):
|
||||
self.mixins.delete_one(item_id) # type: ignore
|
||||
|
||||
# =======================================================================
|
||||
# Test Event Notifications
|
||||
|
||||
@router.post("/{item_id}/test", status_code=204)
|
||||
def test_notification(self, item_id: UUID4):
|
||||
item: GroupEventNotifierPrivate = self.repo.get_one(item_id, override_schema=GroupEventNotifierPrivate)
|
||||
self.event_bus.test_publisher(item.apprise_url)
|
||||
@@ -17,6 +17,8 @@ from mealie.schema.group.group_shopping_list import (
|
||||
)
|
||||
from mealie.schema.mapper import cast
|
||||
from mealie.schema.query import GetAll
|
||||
from mealie.services.event_bus_service.event_bus_service import EventBusService
|
||||
from mealie.services.event_bus_service.message_types import EventTypes
|
||||
from mealie.services.group_services.shopping_lists import ShoppingListService
|
||||
|
||||
router = APIRouter(prefix="/groups/shopping/lists", tags=["Group: Shopping Lists"])
|
||||
@@ -26,6 +28,7 @@ router = APIRouter(prefix="/groups/shopping/lists", tags=["Group: Shopping Lists
|
||||
class ShoppingListRoutes:
|
||||
deps: SharedDependencies = Depends(SharedDependencies.user)
|
||||
service: ShoppingListService = Depends(ShoppingListService.private)
|
||||
event_bus: EventBusService = Depends(EventBusService)
|
||||
|
||||
@cached_property
|
||||
def repo(self):
|
||||
@@ -56,7 +59,16 @@ class ShoppingListRoutes:
|
||||
@router.post("", response_model=ShoppingListOut)
|
||||
def create_one(self, data: ShoppingListCreate):
|
||||
save_data = cast(data, ShoppingListSave, group_id=self.deps.acting_user.group_id)
|
||||
return self.mixins.create_one(save_data)
|
||||
val = self.mixins.create_one(save_data)
|
||||
|
||||
if val:
|
||||
self.event_bus.dispatch(
|
||||
self.deps.acting_user.group_id,
|
||||
EventTypes.shopping_list_created,
|
||||
msg="A new shopping list has been created.",
|
||||
)
|
||||
|
||||
return val
|
||||
|
||||
@router.get("/{item_id}", response_model=ShoppingListOut)
|
||||
def get_one(self, item_id: UUID4):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
from .about import *
|
||||
from .backup import *
|
||||
from .migration import *
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
# GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
from .cookbook import *
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
from .event_notifications import *
|
||||
# GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
from .events import *
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
|
||||
|
||||
class DeclaredTypes(str, Enum):
|
||||
general = "General"
|
||||
discord = "Discord"
|
||||
gotify = "Gotify"
|
||||
pushover = "Pushover"
|
||||
home_assistant = "Home Assistant"
|
||||
|
||||
|
||||
class EventNotificationOut(CamelModel):
|
||||
id: Optional[int]
|
||||
name: str = ""
|
||||
type: DeclaredTypes = DeclaredTypes.general
|
||||
general: bool = True
|
||||
recipe: bool = True
|
||||
backup: bool = True
|
||||
scheduled: bool = True
|
||||
migration: bool = True
|
||||
group: bool = True
|
||||
user: bool = True
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class EventNotificationIn(EventNotificationOut):
|
||||
notification_url: str = ""
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class Discord(CamelModel):
|
||||
webhook_id: str
|
||||
webhook_token: str
|
||||
|
||||
@property
|
||||
def create_url(self) -> str:
|
||||
return f"discord://{self.webhook_id}/{self.webhook_token}/"
|
||||
|
||||
|
||||
class GotifyPriority(str, Enum):
|
||||
low = "low"
|
||||
moderate = "moderate"
|
||||
normal = "normal"
|
||||
high = "high"
|
||||
|
||||
|
||||
class Gotify(CamelModel):
|
||||
hostname: str
|
||||
token: str
|
||||
priority: GotifyPriority = GotifyPriority.normal
|
||||
|
||||
@property
|
||||
def create_url(self) -> str:
|
||||
return f"gotifys://{self.hostname}/{self.token}/?priority={self.priority}"
|
||||
@@ -1,2 +1,10 @@
|
||||
# GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
from .group import *
|
||||
from .group_events import *
|
||||
from .group_exports import *
|
||||
from .group_migration import *
|
||||
from .group_permissions import *
|
||||
from .group_preferences import *
|
||||
from .group_shopping_list import *
|
||||
from .invite_token import *
|
||||
from .webhook import *
|
||||
|
||||
89
mealie/schema/group/group_events.py
Normal file
89
mealie/schema/group/group_events.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic import UUID4
|
||||
|
||||
# =============================================================================
|
||||
# Group Events Notifier Options
|
||||
|
||||
|
||||
class GroupEventNotifierOptions(CamelModel):
|
||||
"""
|
||||
These events are in-sync with the EventTypes found in the EventBusService.
|
||||
If you modify this, make sure to update the EventBusService as well.
|
||||
"""
|
||||
|
||||
recipe_created: bool = False
|
||||
recipe_updated: bool = False
|
||||
recipe_deleted: bool = False
|
||||
|
||||
user_signup: bool = False
|
||||
|
||||
data_migrations: bool = False
|
||||
data_export: bool = False
|
||||
data_import: bool = False
|
||||
|
||||
mealplan_entry_created: bool = False
|
||||
|
||||
shopping_list_created: bool = False
|
||||
shopping_list_updated: bool = False
|
||||
shopping_list_deleted: bool = False
|
||||
|
||||
cookbook_created: bool = False
|
||||
cookbook_updated: bool = False
|
||||
cookbook_deleted: bool = False
|
||||
|
||||
tag_created: bool = False
|
||||
tag_updated: bool = False
|
||||
tag_deleted: bool = False
|
||||
|
||||
category_created: bool = False
|
||||
category_updated: bool = False
|
||||
category_deleted: bool = False
|
||||
|
||||
|
||||
class GroupEventNotifierOptionsSave(GroupEventNotifierOptions):
|
||||
notifier_id: UUID4
|
||||
|
||||
|
||||
class GroupEventNotifierOptionsOut(GroupEventNotifierOptions):
|
||||
id: UUID4
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
# =======================================================================
|
||||
# Notifiers
|
||||
|
||||
|
||||
class GroupEventNotifierCreate(CamelModel):
|
||||
name: str
|
||||
apprise_url: str
|
||||
|
||||
|
||||
class GroupEventNotifierSave(GroupEventNotifierCreate):
|
||||
enabled: bool = True
|
||||
group_id: UUID4
|
||||
options: GroupEventNotifierOptions = GroupEventNotifierOptions()
|
||||
|
||||
|
||||
class GroupEventNotifierUpdate(GroupEventNotifierSave):
|
||||
id: UUID4
|
||||
apprise_url: str = None
|
||||
|
||||
|
||||
class GroupEventNotifierOut(CamelModel):
|
||||
id: UUID4
|
||||
name: str
|
||||
enabled: bool
|
||||
group_id: UUID4
|
||||
options: GroupEventNotifierOptionsOut
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class GroupEventNotifierPrivate(GroupEventNotifierOut):
|
||||
apprise_url: str
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
@@ -1,36 +1,2 @@
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.schema.recipe import IngredientFood
|
||||
|
||||
|
||||
class MultiPurposeLabelCreate(CamelModel):
|
||||
name: str
|
||||
|
||||
|
||||
class MultiPurposeLabelSave(MultiPurposeLabelCreate):
|
||||
group_id: UUID4
|
||||
|
||||
|
||||
class MultiPurposeLabelUpdate(MultiPurposeLabelSave):
|
||||
id: UUID4
|
||||
|
||||
|
||||
class MultiPurposeLabelSummary(MultiPurposeLabelUpdate):
|
||||
pass
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class MultiPurposeLabelOut(MultiPurposeLabelUpdate):
|
||||
shopping_list_items: "list[ShoppingListItemOut]" = []
|
||||
foods: list[IngredientFood] = []
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
from mealie.schema.group.group_shopping_list import ShoppingListItemOut
|
||||
|
||||
MultiPurposeLabelOut.update_forward_refs()
|
||||
# GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
from .multi_purpose_label import *
|
||||
|
||||
36
mealie/schema/labels/multi_purpose_label.py
Normal file
36
mealie/schema/labels/multi_purpose_label.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.schema.recipe import IngredientFood
|
||||
|
||||
|
||||
class MultiPurposeLabelCreate(CamelModel):
|
||||
name: str
|
||||
|
||||
|
||||
class MultiPurposeLabelSave(MultiPurposeLabelCreate):
|
||||
group_id: UUID4
|
||||
|
||||
|
||||
class MultiPurposeLabelUpdate(MultiPurposeLabelSave):
|
||||
id: UUID4
|
||||
|
||||
|
||||
class MultiPurposeLabelSummary(MultiPurposeLabelUpdate):
|
||||
pass
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class MultiPurposeLabelOut(MultiPurposeLabelUpdate):
|
||||
shopping_list_items: "list[ShoppingListItemOut]" = []
|
||||
foods: list[IngredientFood] = []
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
from mealie.schema.group.group_shopping_list import ShoppingListItemOut
|
||||
|
||||
MultiPurposeLabelOut.update_forward_refs()
|
||||
@@ -1,3 +1,4 @@
|
||||
# GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
from .meal import *
|
||||
from .new_meal import *
|
||||
from .shopping_list import *
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
# GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
from .recipe import *
|
||||
from .recipe_asset import *
|
||||
from .recipe_bulk_actions import *
|
||||
from .recipe_category import *
|
||||
from .recipe_comments import *
|
||||
from .recipe_image_types import *
|
||||
from .recipe_ingredient import *
|
||||
from .recipe_notes import *
|
||||
from .recipe_nutrition import *
|
||||
from .recipe_settings import *
|
||||
from .recipe_share_token import *
|
||||
from .recipe_step import *
|
||||
from .recipe_tool import *
|
||||
from .request_helpers import *
|
||||
|
||||
@@ -2,7 +2,7 @@ import enum
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
|
||||
from . import CategoryBase, TagBase
|
||||
from mealie.schema.recipe.recipe_category import CategoryBase, TagBase
|
||||
|
||||
|
||||
class ExportTypes(str, enum.Enum):
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from typing import List
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic.utils import GetterDict
|
||||
|
||||
@@ -23,7 +21,7 @@ class CategoryBase(CategoryIn):
|
||||
|
||||
|
||||
class RecipeCategoryResponse(CategoryBase):
|
||||
recipes: List["Recipe"] = []
|
||||
recipes: "list[Recipe]" = []
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
@@ -42,7 +40,7 @@ class RecipeTagResponse(RecipeCategoryResponse):
|
||||
pass
|
||||
|
||||
|
||||
from . import Recipe
|
||||
from mealie.schema.recipe.recipe import Recipe
|
||||
|
||||
RecipeCategoryResponse.update_forward_refs()
|
||||
RecipeTagResponse.update_forward_refs()
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
# GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
from .reports import *
|
||||
|
||||
@@ -1,17 +1,2 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
message: str
|
||||
error: bool = True
|
||||
exception: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def respond(cls, message: str, exception: Optional[str] = None) -> dict:
|
||||
"""
|
||||
This method is an helper to create an obect and convert to a dictionary
|
||||
in the same call, for use while providing details to a HTTPException
|
||||
"""
|
||||
return cls(message=message, exception=exception).dict()
|
||||
# GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
from .error_response import *
|
||||
|
||||
17
mealie/schema/response/error_response.py
Normal file
17
mealie/schema/response/error_response.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
message: str
|
||||
error: bool = True
|
||||
exception: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def respond(cls, message: str, exception: Optional[str] = None) -> dict:
|
||||
"""
|
||||
This method is an helper to create an obect and convert to a dictionary
|
||||
in the same call, for use while providing details to a HTTPException
|
||||
"""
|
||||
return cls(message=message, exception=exception).dict()
|
||||
@@ -1 +1,2 @@
|
||||
# GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
from .tasks import *
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
from .auth import *
|
||||
from .registration import *
|
||||
from .sign_up import *
|
||||
from .user import *
|
||||
from .user_passwords import *
|
||||
|
||||
@@ -9,8 +9,7 @@ from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.core.config import get_app_dirs
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.schema.admin import CommentImport, GroupImport, NotificationImport, RecipeImport, UserImport
|
||||
from mealie.schema.events import EventNotificationIn
|
||||
from mealie.schema.admin import CommentImport, GroupImport, RecipeImport, UserImport
|
||||
from mealie.schema.recipe import Recipe, RecipeCommentOut
|
||||
from mealie.schema.user import PrivateUser, UpdateGroup
|
||||
from mealie.services.image import minify
|
||||
@@ -159,24 +158,6 @@ class ImportDatabase:
|
||||
|
||||
minify.migrate_images()
|
||||
|
||||
def import_notifications(self):
|
||||
notify_file = self.import_dir.joinpath("notifications", "notifications.json")
|
||||
notifications = ImportDatabase.read_models_file(notify_file, EventNotificationIn)
|
||||
import_notifications = []
|
||||
|
||||
for notify in notifications:
|
||||
import_status = self.import_model(
|
||||
db_table=self.db.event_notifications,
|
||||
model=notify,
|
||||
return_model=NotificationImport,
|
||||
name_attr="name",
|
||||
search_key="notification_url",
|
||||
)
|
||||
|
||||
import_notifications.append(import_status)
|
||||
|
||||
return import_notifications
|
||||
|
||||
def import_settings(self):
|
||||
return []
|
||||
|
||||
@@ -330,11 +311,6 @@ def import_database(
|
||||
user_report = import_session.import_users()
|
||||
|
||||
notification_report = []
|
||||
if import_notifications:
|
||||
notification_report = import_session.import_notifications()
|
||||
|
||||
# if import_recipes:
|
||||
# import_session.import_comments()
|
||||
|
||||
import_session.clean_up()
|
||||
|
||||
|
||||
0
mealie/services/event_bus_service/__init__.py
Normal file
0
mealie/services/event_bus_service/__init__.py
Normal file
46
mealie/services/event_bus_service/event_bus_service.py
Normal file
46
mealie/services/event_bus_service/event_bus_service.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from fastapi import BackgroundTasks, Depends
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.repos.repository_factory import AllRepositories
|
||||
from mealie.schema.group.group_events import GroupEventNotifierPrivate
|
||||
|
||||
from .message_types import EventBusMessage, EventTypes
|
||||
from .publisher import ApprisePublisher, PublisherLike
|
||||
|
||||
|
||||
class EventBusService:
|
||||
def __init__(self, bg: BackgroundTasks, session=Depends(generate_session)) -> None:
|
||||
self.bg = bg
|
||||
self._publisher = ApprisePublisher
|
||||
self.session = session
|
||||
self.group_id = None
|
||||
|
||||
@property
|
||||
def publisher(self) -> PublisherLike:
|
||||
return self._publisher()
|
||||
|
||||
def get_urls(self, event_type: EventTypes) -> list[str]:
|
||||
repos = AllRepositories(self.session)
|
||||
|
||||
notifiers: list[GroupEventNotifierPrivate] = repos.group_event_notifier.by_group(self.group_id).multi_query(
|
||||
{"enabled": True}, override_schema=GroupEventNotifierPrivate
|
||||
)
|
||||
|
||||
return [notifier.apprise_url for notifier in notifiers if getattr(notifier.options, event_type.name)]
|
||||
|
||||
def dispatch(self, group_id: UUID4, event_type: EventTypes, msg: str = "") -> None:
|
||||
self.group_id = group_id
|
||||
|
||||
def _dispatch():
|
||||
if urls := self.get_urls(event_type):
|
||||
self.publisher.publish(EventBusMessage.from_type(event_type, body=msg), urls)
|
||||
|
||||
self.bg.add_task(_dispatch)
|
||||
|
||||
def test_publisher(self, url: str) -> None:
|
||||
self.bg.add_task(
|
||||
self.publisher.publish,
|
||||
event=EventBusMessage.from_type(EventTypes.test_message, body="This is a test event."),
|
||||
notification_urls=[url],
|
||||
)
|
||||
47
mealie/services/event_bus_service/message_types.py
Normal file
47
mealie/services/event_bus_service/message_types.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from enum import Enum, auto
|
||||
|
||||
|
||||
class EventTypes(Enum):
|
||||
test_message = auto()
|
||||
|
||||
recipe_created = auto()
|
||||
recipe_updated = auto()
|
||||
recipe_deleted = auto()
|
||||
|
||||
user_signup = auto()
|
||||
|
||||
data_migrations = auto()
|
||||
data_export = auto()
|
||||
data_import = auto()
|
||||
|
||||
mealplan_entry_created = auto()
|
||||
|
||||
shopping_list_created = auto()
|
||||
shopping_list_updated = auto()
|
||||
shopping_list_deleted = auto()
|
||||
|
||||
cookbook_created = auto()
|
||||
cookbook_updated = auto()
|
||||
cookbook_deleted = auto()
|
||||
|
||||
tag_created = auto()
|
||||
tag_updated = auto()
|
||||
tag_deleted = auto()
|
||||
|
||||
category_created = auto()
|
||||
category_updated = auto()
|
||||
category_deleted = auto()
|
||||
|
||||
|
||||
class EventBusMessage:
|
||||
title: str
|
||||
body: str = ""
|
||||
|
||||
def __init__(self, title, body) -> None:
|
||||
self.title = title
|
||||
self.body = body
|
||||
|
||||
@classmethod
|
||||
def from_type(cls, event_type: EventTypes, body: str = "") -> "EventBusMessage":
|
||||
title = event_type.name.replace("_", " ").title()
|
||||
return cls(title=title, body=body)
|
||||
29
mealie/services/event_bus_service/publisher.py
Normal file
29
mealie/services/event_bus_service/publisher.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from typing import Protocol
|
||||
|
||||
import apprise
|
||||
|
||||
from mealie.services.event_bus_service.event_bus_service import EventBusMessage
|
||||
|
||||
|
||||
class PublisherLike(Protocol):
|
||||
def publish(self, event: EventBusMessage, notification_urls: list[str]):
|
||||
...
|
||||
|
||||
|
||||
class ApprisePublisher:
|
||||
def __init__(self, hard_fail=False) -> None:
|
||||
asset = apprise.AppriseAsset(
|
||||
async_mode=True,
|
||||
image_url_mask="https://raw.githubusercontent.com/hay-kot/mealie/dev/frontend/public/img/icons/android-chrome-maskable-512x512.png",
|
||||
)
|
||||
self.apprise = apprise.Apprise(asset=asset)
|
||||
self.hard_fail = hard_fail
|
||||
|
||||
def publish(self, event: EventBusMessage, notification_urls: list[str]):
|
||||
for dest in notification_urls:
|
||||
status = self.apprise.add(dest)
|
||||
|
||||
if not status and self.hard_fail:
|
||||
raise Exception("Apprise URL Add Failed")
|
||||
|
||||
self.apprise.notify(title=event.title, body=event.body)
|
||||
@@ -1,4 +1,3 @@
|
||||
import apprise
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.db.db_setup import create_session
|
||||
@@ -6,44 +5,12 @@ from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.schema.events import Event, EventCategory
|
||||
|
||||
|
||||
def test_notification(notification_url, event=None) -> bool:
|
||||
if event is None:
|
||||
event = Event(
|
||||
title="Test Notification",
|
||||
text="This is a test message from the Mealie API server",
|
||||
category=EventCategory.general.value,
|
||||
)
|
||||
|
||||
post_notifications(event, [notification_url], hard_fail=True)
|
||||
|
||||
|
||||
def post_notifications(event: Event, notification_urls=list[str], hard_fail=False, attachment=None):
|
||||
asset = apprise.AppriseAsset(async_mode=False)
|
||||
apobj = apprise.Apprise(asset=asset)
|
||||
|
||||
for dest in notification_urls:
|
||||
status = apobj.add(dest)
|
||||
|
||||
if not status and hard_fail:
|
||||
raise Exception("Apprise URL Add Failed")
|
||||
|
||||
apobj.notify(
|
||||
body=event.text,
|
||||
title=event.title,
|
||||
attach=str(attachment),
|
||||
)
|
||||
|
||||
|
||||
def save_event(title, text, category, session: Session, attachment=None):
|
||||
def save_event(title, text, category, session: Session):
|
||||
event = Event(title=title, text=text, category=category)
|
||||
session = session or create_session()
|
||||
db = get_repositories(session)
|
||||
db.events.create(event.dict())
|
||||
|
||||
notification_objects = db.event_notifications.get(match_value=True, match_key=category, limit=9999)
|
||||
notification_urls = [x.notification_url for x in notification_objects]
|
||||
post_notifications(event, notification_urls, attachment=attachment)
|
||||
|
||||
|
||||
def create_general_event(title, text, session=None):
|
||||
category = EventCategory.general
|
||||
@@ -52,8 +19,7 @@ def create_general_event(title, text, session=None):
|
||||
|
||||
def create_recipe_event(title, text, session=None, attachment=None):
|
||||
category = EventCategory.recipe
|
||||
|
||||
save_event(title=title, text=text, category=category, session=session, attachment=attachment)
|
||||
save_event(title=title, text=text, category=category, session=session)
|
||||
|
||||
|
||||
def create_backup_event(title, text, session=None):
|
||||
|
||||
Reference in New Issue
Block a user