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:
Hayden
2022-01-09 21:04:24 -09:00
committed by GitHub
parent 50a341ed3f
commit 190773c5d7
74 changed files with 1992 additions and 1229 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -1,4 +1,5 @@
from .cookbook import *
from .events import *
from .exports import *
from .group import *
from .invite_tokens import *

View 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

View File

@@ -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 = {

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"])

View File

@@ -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)

View File

@@ -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)

View 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)

View File

@@ -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):

View File

@@ -1,3 +1,4 @@
# GENERATED CODE - DO NOT MODIFY BY HAND
from .about import *
from .backup import *
from .migration import *

View File

@@ -1 +1,2 @@
# GENERATED CODE - DO NOT MODIFY BY HAND
from .cookbook import *

View File

@@ -1,2 +1,2 @@
from .event_notifications import *
# GENERATED CODE - DO NOT MODIFY BY HAND
from .events import *

View File

@@ -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}"

View File

@@ -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 *

View 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

View File

@@ -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 *

View 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()

View File

@@ -1,3 +1,4 @@
# GENERATED CODE - DO NOT MODIFY BY HAND
from .meal import *
from .new_meal import *
from .shopping_list import *

View File

@@ -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 *

View File

@@ -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):

View File

@@ -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()

View File

@@ -1 +1,2 @@
# GENERATED CODE - DO NOT MODIFY BY HAND
from .reports import *

View File

@@ -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 *

View 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()

View File

@@ -1 +1,2 @@
# GENERATED CODE - DO NOT MODIFY BY HAND
from .tasks import *

View File

@@ -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 *

View File

@@ -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()

View 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],
)

View 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)

View 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)

View File

@@ -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):