mirror of
https://github.com/markbeep/AudioBookRequest.git
synced 2026-01-05 13:09:45 -06:00
add option to use custom fields in post request
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
39
alembic/versions/304eed96f8ed_add_additional_fields.py
Normal file
39
alembic/versions/304eed96f8ed_add_additional_fields.py
Normal 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 ###
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
1
app/util/json_type.py
Normal file
@@ -0,0 +1 @@
|
||||
type JSON = str | int | float | bool | None | dict[str, "JSON"] | list["JSON"]
|
||||
@@ -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="{"{eventType}": {"book": "{bookTitle}"}}" />
|
||||
<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="{"{eventType}": {"book": "{bookTitle}"}}"
|
||||
x-model="additional_fields" />
|
||||
<button type="submit" class="btn">Update</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user