add option to use custom fields in post request

This commit is contained in:
Markbeep
2025-04-24 16:13:44 +02:00
parent 4945d68d7a
commit d329a5af01
7 changed files with 203 additions and 46 deletions

View File

@@ -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 <your token>"}`.
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

View File

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

View File

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

View File

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

View File

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

1
app/util/json_type.py Normal file
View File

@@ -0,0 +1 @@
type JSON = str | int | float | bool | None | dict[str, "JSON"] | list["JSON"]

View File

@@ -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] }}' }">
<label for="name">
Name<span class="text-error">*</span>
</label>
@@ -30,11 +31,20 @@
<label for="service">
Notification Service<span class="text-error">*</span>
</label>
<select id="service" name="service_type" class="select w-full" required>
<select id="service"
name="service_type"
class="select w-full"
required
x-model="service">
{% for e in service_types %}
<option value="{{ e }}" {% if loop.index.__eq__(0) %}selected{% endif %}>{{ e }}</option>
{% endfor %}
</select>
<template x-if="service === 'custom'">
<p class="text-sm font-semibold text-error">
For the custom service the title and body are ignored. Use the additional fields to add what you need.
</p>
</template>
<label for="url" class="flex gap-0 flex-col">
<span>Apprise Notify URL / Gotify Message URL<span class="text-error">*</span></span>
<br />
@@ -65,7 +75,8 @@
minlength="1"
type="text"
class="input w-full"
required />
required
x-bind:disabled="service === 'custom'" />
<label for="body_template">
Body Template<span class="text-error">*</span>
</label>
@@ -73,10 +84,20 @@
placeholder="New book {bookTitle} by {bookAuthors} narrated by {bookNarrators}. `Requested by {eventUser}`"
name="body_template"
class="textarea w-full"
required></textarea>
required
x-bind:disabled="service === 'custom'"></textarea>
<label for="additional_fields">
Additional POST fields
<span class="astext-xs font-mono">(JSON format, optional)</span>
</label>
<input id="additional_fields"
name="additional_fields"
type="text"
class="input w-full"
placeholder="{&quot;{eventType}&quot;: {&quot;book&quot;: &quot;{bookTitle}&quot;}}" />
<p class="text-xs opacity-60">
Possible event variables:
<span class="font-mono">eventUser, bookTitle, bookAuthors, bookNarrators</span>
<span class="font-mono">eventType, eventUser, bookTitle, bookAuthors, bookNarrators</span>
<br />
Failed download event additionally has:
<span class="font-mono">errorStatus, errorReason</span>
@@ -146,7 +167,7 @@
</div>
{% for n in notifications %}
<template x-if="edit === '{{ n.id }}'">
<form x-data="{ name: {{ n.name|toJSstring }}, event: {{ n.event.value|toJSstring }}, service: {{ n.service.value|toJSstring }}, url: {{ n.url|toJSstring }}, headers: {{ n.serialized_headers|toJSstring }}, title_template: {{ n.title_template|toJSstring }}, body_template: {{ n.body_template|toJSstring }} }"
<form x-data="{ name: {{ n.name|toJSstring }}, event: {{ n.event.value|toJSstring }}, service: {{ n.service.value|toJSstring }}, url: {{ n.url|toJSstring }}, headers: {{ n.serialized_headers|toJSstring }}, additional_fields: {{ n.serialized_additional_fields|toJSstring }}, title_template: {{ n.title_template|toJSstring }}, body_template: {{ n.body_template|toJSstring }} }"
class="flex flex-col gap-2"
hx-put="{{ base_url }}/settings/notification/{{ n.id }}"
hx-target="#notification-list"
@@ -183,6 +204,11 @@
x-model="service">
{% for e in service_types %}<option value="{{ e }}">{{ e }}</option>{% endfor %}
</select>
<template x-if="service === 'custom'">
<p class="text-sm font-semibold text-error">
For the custom service the title and body are ignored. Use the additional fields to add what you need.
</p>
</template>
<label for="url">
Apprise Notify URL / Gotify Message URL<span class="text-error">*</span>
<br />
@@ -216,7 +242,8 @@
type="text"
class="input w-full"
required
x-model="title_template" />
x-model="title_template"
x-bind:disabled="service === 'custom'" />
<label for="body_template">
Body Template<span class="text-error">*</span>
</label>
@@ -225,7 +252,18 @@
name="body_template"
class="textarea w-full"
required
x-model="body_template"></textarea>
x-model="body_template"
x-bind:disabled="service === 'custom'"></textarea>
<label for="additional_fields">
Additional POST fields
<span class="astext-xs font-mono">(JSON format, optional)</span>
</label>
<input id="additional_fields"
name="additional_fields"
type="text"
class="input w-full"
placeholder="{&quot;{eventType}&quot;: {&quot;book&quot;: &quot;{bookTitle}&quot;}}"
x-model="additional_fields" />
<button type="submit" class="btn">Update</button>
</form>
</template>