Merge pull request #88 from markbeep/15-allow-editing-notifications

allow editing notifications
This commit is contained in:
Mark
2025-04-09 11:15:34 +02:00
committed by GitHub
6 changed files with 308 additions and 89 deletions

View File

@@ -57,6 +57,8 @@ async def send_all_notifications(
select(Notification).where(Notification.event == event_type)
).all()
for notification in notifications:
if not notification.enabled:
continue
await send_notification(
session=session,
notification=notification,

View File

@@ -15,6 +15,7 @@ from app.internal.auth.authentication import (
raise_for_invalid_password,
)
from app.internal.auth.config import LoginTypeEnum, auth_config
from app.internal.env_settings import Settings
from app.internal.models import GroupEnum
from app.util.db import get_session
from app.util.templates import templates
@@ -30,7 +31,7 @@ etag_cache: dict[PathLike[str] | str, str] = {}
def add_cache_headers(func: Callable[..., FileResponse]):
def wrapper(v: str):
file = func()
if not (etag := etag_cache.get(file.path)):
if not (etag := etag_cache.get(file.path)) or Settings().app.debug:
with open(file.path, "rb") as f:
etag = hashlib.sha1(f.read(), usedforsecurity=False).hexdigest()
etag_cache[file.path] = etag

View File

@@ -461,6 +461,80 @@ 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]
return template_response(
"settings_page/notifications.html",
request,
admin_user,
{
"page": "notifications",
"notifications": notifications,
"event_types": event_types,
},
block_name="notfications_block",
)
def _upsert_notification(
request: Request,
name: str,
apprise_url: str,
title_template: str,
body_template: str,
event_type: str,
headers: str,
admin_user: DetailedUser,
session: Session,
notification_id: Optional[uuid.UUID] = None,
):
if not headers:
headers = "{}"
try:
headers_json = json.loads(headers)
if not isinstance(headers_json, dict) or any(
not isinstance(v, str) for v in cast(dict[str, Any], headers_json).values()
):
raise ValueError()
headers_json = cast(dict[str, str], headers_json)
except (json.JSONDecodeError, ValueError):
raise ToastException("Invalid headers JSON", "error")
try:
event_enum = EventEnum(event_type)
except ValueError:
raise ToastException("Invalid event type", "error")
if notification_id:
notification = session.get(Notification, notification_id)
if not notification:
raise ToastException("Notification not found", "error")
notification.name = name
notification.apprise_url = apprise_url
notification.event = event_enum
notification.title_template = title_template
notification.body_template = body_template
notification.headers = headers_json
notification.enabled = True
else:
notification = Notification(
name=name,
apprise_url=apprise_url,
event=event_enum,
title_template=title_template,
body_template=body_template,
headers=headers_json,
enabled=True,
)
session.add(notification)
session.commit()
return _list_notifications(request, session, admin_user)
@router.post("/notification")
def add_notification(
request: Request,
@@ -475,57 +549,65 @@ def add_notification(
],
session: Annotated[Session, Depends(get_session)],
):
if not headers:
headers = "{}"
try:
headers_json = json.loads(headers)
if not isinstance(headers_json, dict) or any(
not isinstance(v, str) for v in cast(dict[str, Any], headers_json).values()
):
raise ValueError()
headers_json = cast(dict[str, str], headers_json)
except (json.JSONDecodeError, ValueError):
return template_response(
"settings_page/notifications.html",
request,
admin_user,
{"page": "notifications", "error": "Invalid headers JSON"},
block_name="form_error",
)
try:
event_enum = EventEnum(event_type)
except ValueError:
return template_response(
"settings_page/notifications.html",
request,
admin_user,
{"page": "notifications", "error": "Invalid event type"},
block_name="form_error",
)
notification = Notification(
return _upsert_notification(
request=request,
name=name,
apprise_url=apprise_url,
event=event_enum,
title_template=title_template,
body_template=body_template,
headers=headers_json,
enabled=True,
event_type=event_type,
headers=headers,
admin_user=admin_user,
session=session,
)
@router.put("/notification/{notification_id}")
def update_notification(
request: Request,
notification_id: uuid.UUID,
name: Annotated[str, Form()],
apprise_url: Annotated[str, Form()],
title_template: Annotated[str, Form()],
body_template: Annotated[str, Form()],
event_type: Annotated[str, Form()],
headers: Annotated[str, Form()],
admin_user: Annotated[
DetailedUser, Depends(get_authenticated_user(GroupEnum.admin))
],
session: Annotated[Session, Depends(get_session)],
):
return _upsert_notification(
request=request,
name=name,
apprise_url=apprise_url,
title_template=title_template,
body_template=body_template,
event_type=event_type,
headers=headers,
admin_user=admin_user,
session=session,
notification_id=notification_id,
)
@router.patch("/notification/{notification_id}/enable")
def toggle_notification(
request: Request,
notification_id: uuid.UUID,
admin_user: Annotated[
DetailedUser, Depends(get_authenticated_user(GroupEnum.admin))
],
session: Annotated[Session, Depends(get_session)],
):
notification = session.get_one(Notification, notification_id)
if not notification:
raise ToastException("Notification not found", "error")
notification.enabled = not notification.enabled
session.add(notification)
session.commit()
notifications = session.exec(select(Notification)).all()
return template_response(
"settings_page/notifications.html",
request,
admin_user,
{"page": "notifications", "notifications": notifications},
block_name="notfications_block",
headers={"HX-Retarget": "#notification-list"},
)
return _list_notifications(request, session, admin_user)
@router.delete("/notification/{notification_id}")
@@ -537,25 +619,17 @@ def delete_notification(
],
session: Annotated[Session, Depends(get_session)],
):
notifications = session.exec(select(Notification)).all()
for notif in notifications:
if notif.id == notification_id:
session.delete(notif)
session.commit()
break
notifications = session.exec(select(Notification)).all()
notification = session.get_one(Notification, notification_id)
if not notification:
raise ToastException("Notification not found", "error")
session.delete(notification)
session.commit()
return template_response(
"settings_page/notifications.html",
request,
admin_user,
{"page": "notifications", "notifications": notifications},
block_name="notfications_block",
)
return _list_notifications(request, session, admin_user)
@router.post("/notification/{notification_id}")
async def execute_notification(
async def test_notification(
notification_id: uuid.UUID,
admin_user: Annotated[
DetailedUser, Depends(get_authenticated_user(GroupEnum.admin))

View File

@@ -12,6 +12,9 @@ templates.env.filters["zfill"] = lambda val, num: str(val).zfill(num) # pyright
templates.env.globals["vars"] = vars # pyright: ignore[reportUnknownMemberType]
templates.env.globals["getattr"] = getattr # pyright: ignore[reportUnknownMemberType]
templates.env.globals["version"] = Settings().app.version # pyright: ignore[reportUnknownMemberType]
templates.env.globals["json_regexp"] = ( # pyright: ignore[reportUnknownMemberType]
r'^\{\s*(?:"[^"\\]*(?:\\.[^"\\]*)*"\s*:\s*"[^"\\]*(?:\\.[^"\\]*)*"\s*(?:,\s*"[^"\\]*(?:\\.[^"\\]*)*"\s*:\s*"[^"\\]*(?:\\.[^"\\]*)*"\s*)*)?\}$'
)
@overload

View File

@@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
width="24"
height="24"
stroke-width="2"
style="--darkreader-inline-stroke: currentColor"
data-darkreader-inline-stroke=""
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
/>
</svg>

After

Width:  |  Height:  |  Size: 534 B

View File

@@ -1,6 +1,6 @@
{% extends "settings_page/base.html" %} {% block head %}
<title>Settings - Notifications</title>
{% endblock %} {% block content %}
{% include 'scripts/alpinejs.html' %} {% endblock %} {% block content %}
<div class="flex flex-col">
<h2 class="text-lg">Notifications</h2>
@@ -9,15 +9,11 @@
id="add-notification-form"
class="flex flex-col gap-2"
hx-post="/settings/notification"
hx-target="#error-message"
hx-target="#notification-list"
hx-swap="outerHTML"
hx-on::after-request="if (event.detail.successful) this.reset()"
hx-on::after-request="if (event.detail.successful && event.detail.target?.id === 'notification-list') this.reset()"
>
{% block form_error %}
<span id="error-message" class="text-red-400">{{ error }}</span>
{% endblock %}
<label for="name">Name</label>
<label for="name">Name<span class="text-error">*</span></label>
<input
id="name"
name="name"
@@ -27,7 +23,7 @@
required
/>
<label for="event">Event</label>
<label for="event">Event<span class="text-error">*</span></label>
<select id="event" name="event_type" class="select w-full" required>
{% for e in event_types %}
<option value="{{ e }}">{{ e }}</option>
@@ -36,9 +32,8 @@
<label for="apprise_url"
>Apprise Notify URL
<span class="text-xs font-mono"
>(http://.../notify/c2h3fg...)</span
></label
<span class="text-xs font-mono">(http://.../notify/c2h3fg...)</span
><span class="text-error">*</span></label
>
<input
id="apprise_url"
@@ -50,7 +45,8 @@
/>
<label for="headers"
>Headers <span class="astext-xs font-mono">(JSON format)</span></label
>Headers
<span class="astext-xs font-mono">(JSON format, optional)</span></label
>
<!-- prettier-ignore -->
<input
@@ -59,10 +55,12 @@
type="text"
class="input w-full"
placeholder="{&quot;username&quot;: &quot;admin&quot;, &quot;password&quot;: &quot;password123&quot;}"
pattern="{{'^\{\s*(?:"[^"\\]*(?:\\.[^"\\]*)*"\s*:\s*"[^"\\]*(?:\\.[^"\\]*)*"\s*(?:,\s*"[^"\\]*(?:\\.[^"\\]*)*"\s*:\s*"[^"\\]*(?:\\.[^"\\]*)*"\s*)*)?\}$'}}"
pattern="{{ json_regexp }}"
/>
<label for="title_template">Title Template</label>
<label for="title_template"
>Title Template<span class="text-error">*</span></label
>
<input
id="title_template"
placeholder="New Book: {bookTitle}"
@@ -73,7 +71,9 @@
required
/>
<label for="body_template">Body Template</label>
<label for="body_template"
>Body Template<span class="text-error">*</span></label
>
<textarea
id="body_template"
placeholder="New book {bookTitle} by {bookAuthors} narrated by {bookNarrators}. `Requested by {eventUser}`"
@@ -96,7 +96,14 @@
</div>
{% block notfications_block %}
<div id="notification-list" class="pt-4 border-t border-base-200">
<div
id="notification-list"
class="pt-4 border-t border-base-200"
x-data="{ edit: null }"
x-init="$watch('edit', value => { // load htmx when the form is shown
if (edit) htmx.process(document.querySelector('#edit-notification-form'))
})"
>
<h2 class="text-lg">Apprise Notifications</h2>
<div class="max-h-[30rem] overflow-x-auto">
@@ -117,31 +124,144 @@
<td>{{ n.name }}</td>
<td>{{ n.event.value }}</td>
<td>{{ n.apprise_url }}</td>
<td class="flex gap-1">
<td class="grid grid-cols-2 min-w-[8rem] gap-1">
<button
title="Delete notification"
class="btn btn-error btn-square"
hx-delete="/settings/notification/{{n.id}}"
hx-target="#notification-list"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this notification?"
>
{% include 'icons/trash.html' %}
</button>
<button
title="Test notification"
title="Test"
class="btn btn-square"
hx-post="/settings/notification/{{n.id}}"
hx-disabled-elt="this"
>
{% include 'icons/test-pipe.html' %}
</button>
<button
title="Edit"
class="btn btn-square"
x-on:click="edit === '{{n.id}}' ? edit=null: edit='{{n.id}}'"
>
{% include 'icons/pencil.html' %}
</button>
<button
title="{{ 'Enabled' if n.enabled else 'Disabled' }}"
class="btn btn-square {{ 'btn-success' if n.enabled else 'btn-error' }}"
hx-patch="/settings/notification/{{n.id}}/enable"
hx-disabled-elt="this"
hx-target="#notification-list"
hx-swap="outerHTML"
>
{% if n.enabled %}
<span>{% include 'icons/checkmark.html' %}</span> {% else %}
<span>{% include 'icons/xmark.html' %}</span> {% endif %}
</button>
<button
title="Delete"
class="btn btn-error btn-square"
hx-delete="/settings/notification/{{n.id}}"
hx-target="#notification-list"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this notification? ({{n.name}})"
>
{% include 'icons/trash.html' %}
</button>
</td>
<td></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% for n in notifications %}
<template x-if="edit === '{{n.id}}'">
<form
x-data="{ name: '{{n.name}}', event: '{{n.event.value}}', apprise_url: '{{n.apprise_url}}', headers: '{{n.headers}}', title_template: '{{n.title_template}}', body_template: '{{n.body_template}}' }"
class="flex flex-col gap-2"
hx-put="/settings/notification/{{n.id}}"
hx-target="#notification-list"
hx-swap="outerHTML"
id="edit-notification-form"
>
<label for="name">Name<span class="text-error">*</span></label>
<input
id="name"
name="name"
minlength="1"
type="text"
class="input w-full"
required
x-model="name"
/>
<label for="event">Event<span class="text-error">*</span></label>
<select
id="event"
name="event_type"
class="select w-full"
required
x-model="event"
>
{% for e in event_types %}
<option value="{{ e }}">{{ e }}</option>
{% endfor %}
</select>
<label for="apprise_url"
>Apprise Notify URL
<span class="text-xs font-mono">(http://.../notify/c2h3fg...)</span
><span class="text-error">*</span></label
>
<input
id="apprise_url"
name="apprise_url"
minlength="1"
type="text"
class="input w-full"
required
x-model="apprise_url"
/>
<label for="headers"
>Headers
<span class="astext-xs font-mono">(JSON format, optional)</span></label
>
<!-- prettier-ignore -->
<input
id="headers"
name="headers"
type="text"
class="input w-full"
placeholder="{&quot;username&quot;: &quot;admin&quot;, &quot;password&quot;: &quot;password123&quot;}"
pattern="{{ json_regexp }}"
x-model="headers"
/>
<label for="title_template"
>Title Template<span class="text-error">*</span></label
>
<input
id="title_template"
placeholder="New Book: {bookTitle}"
name="title_template"
minlength="1"
type="text"
class="input w-full"
required
x-model="title_template"
/>
<label for="body_template"
>Body Template<span class="text-error">*</span></label
>
<textarea
id="body_template"
placeholder="New book {bookTitle} by {bookAuthors} narrated by {bookNarrators}. `Requested by {eventUser}`"
name="body_template"
class="textarea w-full"
required
x-model="body_template"
></textarea>
<button type="submit" class="btn">Update</button>
</form>
</template>
{% endfor %}
</div>
{% endblock %} {% endblock %}