From d329a5af012cc118ee4aa18c4bec8b5590b2dd47 Mon Sep 17 00:00:00 2001 From: Markbeep Date: Thu, 24 Apr 2025 16:13:44 +0200 Subject: [PATCH] add option to use custom fields in post request --- README.md | 1 + .../304eed96f8ed_add_additional_fields.py | 39 +++++++ app/internal/models.py | 10 ++ app/internal/notifications.py | 106 +++++++++++++----- app/routers/settings.py | 38 +++++-- app/util/json_type.py | 1 + templates/settings_page/notifications.html | 54 +++++++-- 7 files changed, 203 insertions(+), 46 deletions(-) create mode 100644 alembic/versions/304eed96f8ed_add_additional_fields.py create mode 100644 app/util/json_type.py diff --git a/README.md b/README.md index c5656cb..460f48d 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ Notifications depend on [Apprise](https://github.com/caronc/apprise) or [Gotify] 2. Add the Apprise URL or the path to your gotify instance with `/message` appended, i.e.: `http://gotify:8080/message`. 3. For gofity, add the API key as a header: `{"Authentication": "Bearer "}`. 4. Configure the remaining settings. **The event variables are case sensitive**. +5. `Additional POST fields` allow you to add extra values that are sent along in the POST request to the notification service. This allows for changing the priority for gotify notifications or changing the look of apprise notifications. **Event variables also work in keys and values!** ### OpenID Connect diff --git a/alembic/versions/304eed96f8ed_add_additional_fields.py b/alembic/versions/304eed96f8ed_add_additional_fields.py new file mode 100644 index 0000000..705cc30 --- /dev/null +++ b/alembic/versions/304eed96f8ed_add_additional_fields.py @@ -0,0 +1,39 @@ +"""add additional fields + +Revision ID: 304eed96f8ed +Revises: 0fa71b2e5d30 +Create Date: 2025-04-24 15:29:26.686894 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "304eed96f8ed" +down_revision: Union[str, None] = "0fa71b2e5d30" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("notification", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "additional_fields", sa.JSON(), nullable=True, server_default="{}" + ) + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("notification", schema=None) as batch_op: + batch_op.drop_column("additional_fields") + + # ### end Alembic commands ### diff --git a/app/internal/models.py b/app/internal/models.py index 565bb84..e77f1ea 100644 --- a/app/internal/models.py +++ b/app/internal/models.py @@ -8,6 +8,8 @@ from typing import Annotated, Literal, Optional, Union import pydantic from sqlmodel import JSON, Column, DateTime, Field, SQLModel, UniqueConstraint, func +from app.util import json_type + class BaseModel(SQLModel): pass @@ -201,6 +203,7 @@ class EventEnum(str, Enum): class NotificationServiceEnum(str, Enum): apprise = "apprise" gotify = "gotify" + custom = "custom" class Notification(BaseModel, table=True): @@ -208,6 +211,9 @@ class Notification(BaseModel, table=True): name: str url: str headers: dict[str, str] = Field(default_factory=dict, sa_column=Column(JSON)) + additional_fields: dict[str, json_type.JSON] = Field( + default_factory=dict, sa_column=Column(JSON) + ) event: EventEnum service: NotificationServiceEnum title_template: str @@ -217,3 +223,7 @@ class Notification(BaseModel, table=True): @property def serialized_headers(self): return json.dumps(self.headers) + + @property + def serialized_additional_fields(self): + return json.dumps(self.additional_fields) diff --git a/app/internal/notifications.py b/app/internal/notifications.py index 85115db..7ba0b28 100644 --- a/app/internal/notifications.py +++ b/app/internal/notifications.py @@ -1,3 +1,4 @@ +import json import logging from typing import Optional @@ -11,14 +12,14 @@ from app.internal.models import ( Notification, NotificationServiceEnum, ) +from app.util import json_type from app.util.db import open_session logger = logging.getLogger(__name__) def replace_variables( - title_template: str, - body_template: str, + template: str, username: Optional[str] = None, book_title: Optional[str] = None, book_authors: Optional[str] = None, @@ -26,35 +27,27 @@ def replace_variables( event_type: Optional[str] = None, other_replacements: dict[str, str] = {}, ): - title = title_template - body = body_template - if username: - title = title.replace("{eventUser}", username) - body = body.replace("{eventUser}", username) + template = template.replace("{eventUser}", username) if book_title: - title = title.replace("{bookTitle}", book_title) - body = body.replace("{bookTitle}", book_title) + template = template.replace("{bookTitle}", book_title) if book_authors: - title = title.replace("{bookAuthors}", book_authors) - body = body.replace("{bookAuthors}", book_authors) + template = template.replace("{bookAuthors}", book_authors) if book_narrators: - title = title.replace("{bookNarrators}", book_narrators) - body = body.replace("{bookNarrators}", book_narrators) + template = template.replace("{bookNarrators}", book_narrators) if event_type: - title = title.replace("{eventType}", event_type) - body = body.replace("{eventType}", event_type) + template = template.replace("{eventType}", event_type) for key, value in other_replacements.items(): - title = title.replace(f"{{{key}}}", value) - body = body.replace(f"{{{key}}}", value) + template = template.replace(f"{{{key}}}", value) - return title, body + return template async def _send( title: str, body: str, + additional_fields: dict[str, json_type.JSON], notification: Notification, client_session: ClientSession, ): @@ -63,13 +56,29 @@ async def _send( body_key = "message" case NotificationServiceEnum.apprise: body_key = "body" + case NotificationServiceEnum.custom: + body_key = "" + + if notification.service == NotificationServiceEnum.custom: + json_body = {} + else: + json_body: dict[str, json_type.JSON] = { + "title": title, + body_key: body, + } + + for key, value in additional_fields.items(): + if key in json_body.keys(): + logger.warning( + f"Key '{key}' already exists in the JSON body but is passed as additional field. Overwriting with value: {value}" + ) + json_body[key] = value + + print(json_body) async with client_session.post( notification.url, - json={ - "title": title, - body_key: body, - }, + json=json_body, headers=notification.headers, ) as response: response.raise_for_status() @@ -95,8 +104,16 @@ async def send_notification( book_authors = ",".join(book.authors) book_narrators = ",".join(book.narrators) - title, body = replace_variables( + title = replace_variables( notification.title_template, + requester_username, + book_title, + book_authors, + book_narrators, + notification.event.value, + other_replacements, + ) + body = replace_variables( notification.body_template, requester_username, book_title, @@ -105,13 +122,24 @@ async def send_notification( notification.event.value, other_replacements, ) + additional_fields: dict[str, json_type.JSON] = json.loads( + replace_variables( + json.dumps(notification.additional_fields), + requester_username, + book_title, + book_authors, + book_narrators, + notification.event.value, + other_replacements, + ) + ) logger.info( f"Sending notification to {notification.url} with title: '{title}', event type: {notification.event.value}" ) async with ClientSession() as client_session: - return await _send(title, body, notification, client_session) + return await _send(title, body, additional_fields, notification, client_session) async def send_all_notifications( @@ -144,23 +172,45 @@ async def send_manual_notification( ): """Send a notification for manual book requests""" try: - title, body = replace_variables( + book_authors = ",".join(book.authors) + book_narrators = ",".join(book.narrators) + + title = replace_variables( notification.title_template, + requester_username, + book.title, + book_authors, + book_narrators, + notification.event.value, + other_replacements, + ) + body = replace_variables( notification.body_template, requester_username, book.title, - ",".join(book.authors), - ",".join(book.narrators), + book_authors, + book_narrators, notification.event.value, other_replacements, ) + additional_fields: dict[str, json_type.JSON] = json.loads( + replace_variables( + json.dumps(notification.additional_fields), + requester_username, + book.title, + book_authors, + book_narrators, + notification.event.value, + other_replacements, + ) + ) logger.info( f"Sending manual notification to {notification.url} with title: '{title}', event type: {notification.event.value}" ) async with ClientSession() as client_session: - await _send(title, body, notification, client_session) + await _send(title, body, additional_fields, notification, client_session) except Exception as e: logger.error("Failed to send notification", e) diff --git a/app/routers/settings.py b/app/routers/settings.py index a131769..e7f792d 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -35,6 +35,7 @@ from app.internal.prowlarr.prowlarr import ( prowlarr_config, ) from app.internal.ranking.quality import IndexerFlag, QualityRange, quality_config +from app.util import json_type from app.util.connection import get_connection from app.util.db import get_session from app.util.templates import template_response @@ -512,8 +513,7 @@ def read_notifications( def _list_notifications(request: Request, session: Session, admin_user: DetailedUser): notifications = session.exec(select(Notification)).all() event_types = [e.value for e in EventEnum] - notifications = session.exec(select(Notification)).all() - event_types = [e.value for e in EventEnum] + service_types = [e.value for e in NotificationServiceEnum] return template_response( "settings_page/notifications.html", request, @@ -522,6 +522,7 @@ def _list_notifications(request: Request, session: Session, admin_user: Detailed "page": "notifications", "notifications": notifications, "event_types": event_types, + "service_types": service_types, }, block_name="notfications_block", ) @@ -537,22 +538,33 @@ def _upsert_notification( event_type: str, service_type: str, headers: str, + additional_fields: str, admin_user: DetailedUser, session: Session, notification_id: Optional[uuid.UUID] = None, ): - if not headers: - headers = "{}" try: - headers_json = json.loads(headers) + headers_json = json.loads(headers or "{}") if not isinstance(headers_json, dict) or any( not isinstance(v, str) for v in cast(dict[str, Any], headers_json).values() ): - raise ValueError() + raise ToastException( + "Invalid headers JSON. Not of type object/dict", "error" + ) headers_json = cast(dict[str, str], headers_json) except (json.JSONDecodeError, ValueError): raise ToastException("Invalid headers JSON", "error") + try: + additional_json = json.loads(additional_fields or "{}") + if not isinstance(additional_json, dict): + raise ToastException( + "Invalid additional fields JSON. Not of type object/dict", "error" + ) + additional_json = cast(dict[str, json_type.JSON], additional_json) + except (json.JSONDecodeError, ValueError): + raise ToastException("Invalid additional fields JSON", "error") + try: event_enum = EventEnum(event_type) except ValueError: @@ -574,6 +586,7 @@ def _upsert_notification( notification.title_template = title_template notification.body_template = body_template notification.headers = headers_json + notification.additional_fields = additional_json notification.enabled = True else: notification = Notification( @@ -584,6 +597,7 @@ def _upsert_notification( title_template=title_template, body_template=body_template, headers=headers_json, + additional_fields=additional_json, enabled=True, ) session.add(notification) @@ -597,15 +611,16 @@ def add_notification( request: Request, name: Annotated[str, Form()], url: Annotated[str, Form()], - title_template: Annotated[str, Form()], - body_template: Annotated[str, Form()], event_type: Annotated[str, Form()], service_type: Annotated[str, Form()], headers: Annotated[str, Form()], + additional_fields: Annotated[str, Form()], admin_user: Annotated[ DetailedUser, Depends(get_authenticated_user(GroupEnum.admin)) ], session: Annotated[Session, Depends(get_session)], + title_template: Annotated[str, Form()] = "", + body_template: Annotated[str, Form()] = "", ): return _upsert_notification( request=request, @@ -616,6 +631,7 @@ def add_notification( event_type=event_type, service_type=service_type, headers=headers, + additional_fields=additional_fields, admin_user=admin_user, session=session, ) @@ -627,15 +643,16 @@ def update_notification( notification_id: uuid.UUID, name: Annotated[str, Form()], url: Annotated[str, Form()], - title_template: Annotated[str, Form()], - body_template: Annotated[str, Form()], event_type: Annotated[str, Form()], service_type: Annotated[str, Form()], headers: Annotated[str, Form()], + additional_fields: Annotated[str, Form()], admin_user: Annotated[ DetailedUser, Depends(get_authenticated_user(GroupEnum.admin)) ], session: Annotated[Session, Depends(get_session)], + title_template: Annotated[str, Form()] = "", + body_template: Annotated[str, Form()] = "", ): return _upsert_notification( request=request, @@ -646,6 +663,7 @@ def update_notification( event_type=event_type, service_type=service_type, headers=headers, + additional_fields=additional_fields, admin_user=admin_user, session=session, notification_id=notification_id, diff --git a/app/util/json_type.py b/app/util/json_type.py new file mode 100644 index 0000000..6dc7502 --- /dev/null +++ b/app/util/json_type.py @@ -0,0 +1 @@ +type JSON = str | int | float | bool | None | dict[str, "JSON"] | list["JSON"] diff --git a/templates/settings_page/notifications.html b/templates/settings_page/notifications.html index 4557bcf..f98c21c 100644 --- a/templates/settings_page/notifications.html +++ b/templates/settings_page/notifications.html @@ -11,7 +11,8 @@ hx-post="{{ base_url }}/settings/notification" hx-target="#notification-list" hx-swap="outerHTML" - hx-on::after-request="if (event.detail.successful && event.detail.target?.id === 'notification-list') this.reset()"> + hx-on::after-request="if (event.detail.successful && event.detail.target?.id === 'notification-list') this.reset()" + x-data="{ service: '{{ service_types[0] }}' }"> @@ -30,11 +31,20 @@ - {% for e in service_types %} {% endfor %} +