Feature/event notifications (#399)

* additional server events

* sort 'recent recipes' by updated

* remove duplicate code

* fixes #396

* set color

* consolidate tag/category pages

* set colors

* list unorganized recipes

* cleanup old code

* remove flash message, switch to global snackbar

* cancel to close

* cleanup

* notifications first pass

* test notification

* complete notification feature

* use background tasks

* add url param

* update documentation

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden
2021-05-08 18:29:31 -08:00
committed by GitHub
parent 8923c1ecf8
commit 14b6ab7ec7
49 changed files with 875 additions and 355 deletions

View File

@@ -95,6 +95,7 @@ def determine_sqlite_path(path=False, suffix=DB_VERSION) -> str:
class AppSettings(BaseSettings):
global DATA_DIR
PRODUCTION: bool = Field(True, env="PRODUCTION")
BASE_URL: str = "http://localhost:8080"
IS_DEMO: bool = False
API_PORT: int = 9000
API_DOCS: bool = True

View File

@@ -1,7 +1,7 @@
from logging import getLogger
from mealie.db.db_base import BaseDocument
from mealie.db.models.event import Event
from mealie.db.models.event import Event, EventNotification
from mealie.db.models.group import Group
from mealie.db.models.mealplan import MealPlanModel
from mealie.db.models.recipe.recipe import Category, RecipeModel, Tag
@@ -10,6 +10,7 @@ from mealie.db.models.sign_up import SignUp
from mealie.db.models.theme import SiteThemeModel
from mealie.db.models.users import LongLiveToken, User
from mealie.schema.category import RecipeCategoryResponse, RecipeTagResponse
from mealie.schema.event_notifications import EventNotificationIn
from mealie.schema.events import Event as EventSchema
from mealie.schema.meal import MealPlanInDB
from mealie.schema.recipe import Recipe
@@ -156,6 +157,13 @@ class _Events(BaseDocument):
self.schema = EventSchema
class _EventNotification(BaseDocument):
def __init__(self) -> None:
self.primary_key = "id"
self.sql_model = EventNotification
self.schema = EventNotificationIn
class Database:
def __init__(self) -> None:
self.recipes = _Recipes()
@@ -170,6 +178,7 @@ class Database:
self.groups = _Groups()
self.custom_pages = _CustomPages()
self.events = _Events()
self.event_notifications = _EventNotification()
db = Database()

View File

@@ -1,14 +1,45 @@
import sqlalchemy as sa
from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase
from sqlalchemy import Boolean, Column, DateTime, Integer, String
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)
def __init__(
self, name, notification_url, type, general, recipe, backup, scheduled, migration, group, user, *args, **kwargs
) -> None:
self.name = name
self.notification_url = notification_url
self.type = type
self.general = general
self.recipe = recipe
self.backup = backup
self.scheduled = scheduled
self.migration = migration
self.group = group
self.user = user
class Event(SqlAlchemyBase, BaseMixins):
__tablename__ = "events"
id = sa.Column(sa.Integer, primary_key=True)
title = sa.Column(sa.String)
text = sa.Column(sa.String)
time_stamp = sa.Column(sa.DateTime)
category = sa.Column(sa.String)
id = Column(Integer, primary_key=True)
title = Column(String)
text = Column(String)
time_stamp = Column(DateTime)
category = Column(String)
def __init__(self, title, text, time_stamp, category, *args, **kwargs) -> None:
self.title = title

View File

@@ -1,28 +1,97 @@
from fastapi import APIRouter, Depends
from http.client import HTTPException
from fastapi import APIRouter, Depends, status
from mealie.core.root_logger import get_logger
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user
from mealie.schema.events import EventsOut
from mealie.schema.event_notifications import EventNotificationIn, EventNotificationOut
from mealie.schema.events import EventsOut, TestEvent
from mealie.schema.user import UserInDB
from mealie.services.events import test_notification
from sqlalchemy.orm.session import Session
router = APIRouter(prefix="/events", tags=["App Events"])
logger = get_logger()
@router.get("", response_model=EventsOut)
async def get_events(session: Session = Depends(generate_session), current_user=Depends(get_current_user)):
async def get_events(session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user)):
""" Get event from the Database """
# Get Item
return EventsOut(total=db.events.count_all(session), events=db.events.get_all(session, order_by="time_stamp"))
@router.delete("")
async def delete_events(session: Session = Depends(generate_session), current_user=Depends(get_current_user)):
async def delete_events(
session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user)
):
""" Get event from the Database """
# Get Item
return db.events.delete_all(session)
@router.delete("/{id}")
async def delete_event(id: int, session: Session = Depends(generate_session), current_user=Depends(get_current_user)):
async def delete_event(
id: int, session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user)
):
""" Delete event from the Database """
return db.events.delete(session, id)
@router.post("/notifications")
async def create_event_notification(
event_data: EventNotificationIn,
session: Session = Depends(generate_session),
current_user: UserInDB = Depends(get_current_user),
):
""" Create event_notification in the Database """
return db.event_notifications.create(session, event_data)
@router.post("/notifications/test")
async def test_notification_route(
test_data: TestEvent,
session: Session = Depends(generate_session),
current_user: UserInDB = Depends(get_current_user),
):
""" Create event_notification in the Database """
if test_data.id:
event_obj: EventNotificationIn = db.event_notifications.get(session, 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), current_user: UserInDB = Depends(get_current_user)
):
""" Get all event_notification from the Database """
# Get Item
return db.event_notifications.get_all(session, override_schema=EventNotificationOut)
@router.put("/notifications/{id}")
async def update_event_notification(
id: int, session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user)
):
""" Update event_notification in the Database """
# Update Item
return {"details": "not yet implemented"}
@router.delete("/notifications/{id}")
async def delete_event_notification(
id: int, session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user)
):
""" Delete event_notification from the Database """
# Delete Item
return db.event_notifications.delete(session, id)

View File

@@ -2,7 +2,7 @@ import operator
import shutil
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile, status
from mealie.core.config import app_dirs
from mealie.core.root_logger import get_logger
from mealie.core.security import create_file_token
@@ -33,7 +33,7 @@ def available_imports():
@router.post("/export/database", status_code=status.HTTP_201_CREATED)
def export_database(data: BackupJob, session: Session = Depends(generate_session)):
def export_database(background_tasks: BackgroundTasks, data: BackupJob, session: Session = Depends(generate_session)):
"""Generates a backup of the recipe database in json format."""
try:
export_path = backup_all(
@@ -47,7 +47,9 @@ def export_database(data: BackupJob, session: Session = Depends(generate_session
export_users=data.options.users,
export_groups=data.options.groups,
)
create_backup_event("Database Backup", f"Manual Backup Created '{Path(export_path).name}'", session)
background_tasks.add_task(
create_backup_event, "Database Backup", f"Manual Backup Created '{Path(export_path).name}'", session
)
return {"export_path": export_path}
except Exception as e:
logger.error(e)
@@ -75,7 +77,12 @@ async def download_backup_file(file_name: str):
@router.post("/{file_name}/import", status_code=status.HTTP_200_OK)
def import_database(file_name: str, import_data: ImportJob, session: Session = Depends(generate_session)):
def import_database(
background_tasks: BackgroundTasks,
file_name: str,
import_data: ImportJob,
session: Session = Depends(generate_session),
):
""" Import a database backup file generated from Mealie. """
db_import = imports.import_database(
@@ -90,7 +97,7 @@ def import_database(file_name: str, import_data: ImportJob, session: Session = D
force_import=import_data.force,
rebase=import_data.rebase,
)
create_backup_event("Database Restore", f"Restore File: {file_name}", session)
background_tasks.add_task(create_backup_event, "Database Restore", f"Restore File: {file_name}", session)
return db_import

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user
@@ -32,6 +32,7 @@ async def get_current_user_group(
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_group(
background_tasks: BackgroundTasks,
group_data: GroupBase,
current_user=Depends(get_current_user),
session: Session = Depends(generate_session),
@@ -40,7 +41,7 @@ async def create_group(
try:
db.groups.create(session, group_data.dict())
create_group_event("Group Created", f"'{group_data.name}' created")
background_tasks.add_task(create_group_event, "Group Created", f"'{group_data.name}' created", session)
except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST)
@@ -58,7 +59,10 @@ async def update_group_data(
@router.delete("/{id}")
async def delete_user_group(
id: int, current_user=Depends(get_current_user), session: Session = Depends(generate_session)
background_tasks: BackgroundTasks,
id: int,
current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session),
):
""" Removes a user group from the database """
@@ -73,5 +77,8 @@ async def delete_user_group(
if group.users != []:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="GROUP_WITH_USERS")
create_group_event("Group Deleted", f"'{group.name}' Deleted")
background_tasks.add_task(
create_group_event, "Group Deleted", f"'{group.name}' deleted by {current_user.full_name}", session
)
db.groups.delete(session, id)

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user
@@ -25,16 +25,22 @@ def get_all_meals(
@router.post("/create", status_code=status.HTTP_201_CREATED)
def create_meal_plan(
data: MealPlanIn, session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user)
background_tasks: BackgroundTasks,
data: MealPlanIn,
session: Session = Depends(generate_session),
current_user: UserInDB = Depends(get_current_user),
):
""" Creates a meal plan database entry """
processed_plan = process_meals(session, data)
create_group_event("Meal Plan Created", f"Mealplan Created for '{current_user.group}'")
background_tasks.add_task(
create_group_event, "Meal Plan Created", f"Mealplan Created for '{current_user.group}'", session=session
)
return db.meals.create(session, processed_plan.dict())
@router.put("/{plan_id}")
def update_meal_plan(
background_tasks: BackgroundTasks,
plan_id: str,
meal_plan: MealPlanIn,
session: Session = Depends(generate_session),
@@ -45,13 +51,16 @@ def update_meal_plan(
processed_plan = MealPlanInDB(uid=plan_id, **processed_plan.dict())
try:
db.meals.update(session, plan_id, processed_plan.dict())
create_group_event("Meal Plan Updated", f"Mealplan Updated for '{current_user.group}'")
background_tasks.add_task(
create_group_event, "Meal Plan Updated", f"Mealplan Updated for '{current_user.group}'", session=session
)
except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST)
@router.delete("/{plan_id}")
def delete_meal_plan(
background_tasks: BackgroundTasks,
plan_id,
session: Session = Depends(generate_session),
current_user: UserInDB = Depends(get_current_user),
@@ -60,7 +69,9 @@ def delete_meal_plan(
try:
db.meals.delete(session, plan_id)
create_group_event("Meal Plan Deleted", f"Mealplan Deleted for '{current_user.group}'")
background_tasks.add_task(
create_group_event, "Meal Plan Deleted", f"Mealplan Deleted for '{current_user.group}'", session=session
)
except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST)

View File

@@ -1,12 +1,14 @@
from shutil import copyfileobj
from fastapi import APIRouter, Depends, File, Form, HTTPException, status
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, status
from fastapi.datastructures import UploadFile
from mealie.core.config import settings
from mealie.core.root_logger import get_logger
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user
from mealie.schema.recipe import Recipe, RecipeAsset, RecipeURLIn
from mealie.schema.user import UserInDB
from mealie.services.events import create_recipe_event
from mealie.services.image.image import scrape_image, write_image
from mealie.services.recipe.media import check_assets, delete_assets
@@ -20,6 +22,7 @@ logger = get_logger()
@router.post("/create", status_code=201, response_model=str)
def create_from_json(
background_tasks: BackgroundTasks,
data: Recipe,
session: Session = Depends(generate_session),
current_user=Depends(get_current_user),
@@ -27,22 +30,36 @@ def create_from_json(
""" Takes in a JSON string and loads data into the database as a new entry"""
recipe: Recipe = db.recipes.create(session, data.dict())
create_recipe_event("Recipe Created", f"Recipe '{recipe.name}' created", session=session)
background_tasks.add_task(
create_recipe_event,
"Recipe Created (URL)",
f"'{recipe.name}' by {current_user.full_name} \n {settings.BASE_URL}/recipe/{recipe.slug}",
session=session,
attachment=recipe.image_dir.joinpath("min-original.webp"),
)
return recipe.slug
@router.post("/create-url", status_code=201, response_model=str)
def parse_recipe_url(
background_tasks: BackgroundTasks,
url: RecipeURLIn,
session: Session = Depends(generate_session),
current_user=Depends(get_current_user),
current_user: UserInDB = Depends(get_current_user),
):
""" Takes in a URL and attempts to scrape data and load it into the database """
recipe = create_from_url(url.url)
recipe: Recipe = db.recipes.create(session, recipe.dict())
create_recipe_event("Recipe Created (URL)", f"'{recipe.name}' by {current_user.full_name}", session=session)
background_tasks.add_task(
create_recipe_event,
"Recipe Created (URL)",
f"'{recipe.name}' by {current_user.full_name} \n {settings.BASE_URL}/recipe/{recipe.slug}",
session=session,
attachment=recipe.image_dir.joinpath("min-original.webp"),
)
return recipe.slug
@@ -64,7 +81,6 @@ def update_recipe(
""" Updates a recipe by existing slug and data. """
recipe: Recipe = db.recipes.update(session, recipe_slug, data.dict())
print(recipe.assets)
check_assets(original_slug=recipe_slug, recipe=recipe)
@@ -91,6 +107,7 @@ def patch_recipe(
@router.delete("/{recipe_slug}")
def delete_recipe(
background_tasks: BackgroundTasks,
recipe_slug: str,
session: Session = Depends(generate_session),
current_user=Depends(get_current_user),
@@ -100,7 +117,12 @@ def delete_recipe(
try:
recipe: Recipe = db.recipes.delete(session, recipe_slug)
delete_assets(recipe_slug=recipe_slug)
create_recipe_event("Recipe Deleted", f"'{recipe.name}' deleted by {current_user.full_name}", session=session)
background_tasks.add_task(
create_recipe_event,
"Recipe Deleted",
f"'{recipe.name}' deleted by {current_user.full_name}",
session=session,
)
return recipe
except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST)

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, Request, status
from fastapi import APIRouter, BackgroundTasks, Depends, Request, status
from fastapi.exceptions import HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from mealie.core import security
@@ -15,6 +15,7 @@ router = APIRouter(prefix="/api/auth", tags=["Authentication"])
@router.post("/token/long")
@router.post("/token")
def get_token(
background_tasks: BackgroundTasks,
request: Request,
data: OAuth2PasswordRequestForm = Depends(),
session: Session = Depends(generate_session),
@@ -25,7 +26,9 @@ def get_token(
user = authenticate_user(session, email, password)
if not user:
create_user_event("Failed Login", f"Username: {email}, Source IP: '{request.client.host}'")
background_tasks.add_task(
create_user_event, "Failed Login", f"Username: {email}, Source IP: '{request.client.host}'"
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
headers={"WWW-Authenticate": "Bearer"},

View File

@@ -1,6 +1,6 @@
import shutil
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from mealie.core import security
from mealie.core.config import app_dirs, settings
@@ -17,13 +17,16 @@ router = APIRouter(prefix="/api/users", tags=["Users"])
@router.post("", response_model=UserOut, status_code=201)
async def create_user(
background_tasks: BackgroundTasks,
new_user: UserIn,
current_user=Depends(get_current_user),
session: Session = Depends(generate_session),
):
new_user.password = get_password_hash(new_user.password)
create_user_event("User Created", f"Created by {current_user.full_name}", session=session)
background_tasks.add_task(
create_user_event, "User Created", f"Created by {current_user.full_name}", session=session
)
return db.users.create(session, new_user.dict())
@@ -138,6 +141,7 @@ async def update_password(
@router.delete("/{id}")
async def delete_user(
background_tasks: BackgroundTasks,
id: int,
current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session),
@@ -150,6 +154,6 @@ async def delete_user(
if current_user.id == id or current_user.admin:
try:
db.users.delete(session, id)
create_user_event("User Deleted", f"User ID: {id}", session=session)
background_tasks.add_task(create_user_event, "User Deleted", f"User ID: {id}", session=session)
except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST)

View File

@@ -1,6 +1,6 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from mealie.core.security import get_password_hash
from mealie.db.database import db
from mealie.db.db_setup import generate_session
@@ -25,6 +25,7 @@ async def get_all_open_sign_ups(
@router.post("", response_model=SignUpToken)
async def create_user_sign_up_key(
background_tasks: BackgroundTasks,
key_data: SignUpIn,
current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session),
@@ -39,12 +40,16 @@ async def create_user_sign_up_key(
"name": key_data.name,
"admin": key_data.admin,
}
create_user_event("Sign-up Token Created", f"Created by {current_user.full_name}", session=session)
background_tasks.add_task(
create_user_event, "Sign-up Token Created", f"Created by {current_user.full_name}", session=session
)
return db.sign_ups.create(session, sign_up)
@router.post("/{token}")
async def create_user_with_token(
background_tasks: BackgroundTasks,
token: str,
new_user: UserIn,
session: Session = Depends(generate_session),
@@ -62,7 +67,9 @@ async def create_user_with_token(
db.users.create(session, new_user.dict())
# DeleteToken
create_user_event("Sign-up Token Used", f"New User {new_user.full_name}", session=session)
background_tasks.add_task(
create_user_event, "Sign-up Token Used", f"New User {new_user.full_name}", session=session
)
db.sign_ups.delete(session, token)

View File

@@ -0,0 +1,61 @@
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

@@ -30,3 +30,8 @@ class Event(CamelModel):
class EventsOut(CamelModel):
total: int
events: list[Event]
class TestEvent(CamelModel):
id: Optional[int]
test_url: Optional[str]

View File

@@ -1,23 +1,60 @@
import apprise
from mealie.db.database import db
from mealie.db.db_setup import create_session
from mealie.schema.events import Event, EventCategory
from sqlalchemy.orm.session import Session
def save_event(title, text, category, session: Session):
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")
print(attachment)
apobj.notify(
body=event.text,
title=event.title,
attach=str(attachment),
)
def save_event(title, text, category, session: Session, attachment=None):
event = Event(title=title, text=text, category=category)
session = session or create_session()
db.events.create(session, event.dict())
notification_objects = db.event_notifications.get(session=session, 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
save_event(title=title, text=text, category=category, session=session)
def create_recipe_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)
save_event(title=title, text=text, category=category, session=session, attachment=attachment)
def create_backup_event(title, text, session=None):