Add test notification button (#996)

* Add api to test notifications before you save

* Add button to UI

* Inspect apprise logs to get errors

* Better formatting around errors coming back from the test notification endpoint

* Use apprise's built in log capture

* Instruct the user to get error from container log instead of intercepting and parsing apprise logs

* refac move to test method on notification class

---------

Co-authored-by: Simon <simobilleter@gmail.com>
This commit is contained in:
Craig Alexander 2025-07-11 05:30:50 -05:00 committed by GitHub
parent 624a5f9bd4
commit aefd678dca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 222 additions and 39 deletions

View File

@ -329,3 +329,21 @@ SPECTACULAR_SETTINGS = {
"VERSION": TA_VERSION,
"SERVE_INCLUDE_SCHEMA": False,
}
# Logging configuration
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"loggers": {
"apprise": {
"handlers": ["console"],
"level": "DEBUG",
"propagate": True,
},
},
}

View File

@ -91,3 +91,12 @@ class TaskNotificationPostSerializer(serializers.Serializer):
task_name = serializers.ChoiceField(choices=list(TASK_CONFIG))
url = serializers.CharField(required=False)
class TaskNotificationTestSerializer(serializers.Serializer):
"""serialize task notification test POST"""
url = serializers.CharField()
task_name = serializers.ChoiceField(
choices=list(TASK_CONFIG), required=False
)

View File

@ -32,6 +32,38 @@ class Notifications:
apobj.notify(body=body, title=title)
def test(self, url) -> tuple[bool, str]:
"""send test notification"""
try:
apobj = apprise.Apprise()
if not apobj.add(url):
success = False
message = f"Invalid notification URL format: {url}"
return success, message
title = f"[TA] {self.task_name} process ended with SUCCESS"
body = "This is a test notification. Task completed successfully."
result = apobj.notify(body=body, title=title)
if result:
success = True
message = "Test notification sent successfully"
return success, message
success = False
message = (
"Notification failed. "
"Please check container logs for more information."
)
return success, message
except Exception as err: # pylint: disable=broad-exception-caught
success = False
message = f"Notification error: {str(err)}"
return success, message
def _build_message(
self, task_id: str, task_title: str
) -> tuple[str, str | None]:

View File

@ -34,4 +34,9 @@ urlpatterns = [
views.ScheduleNotification.as_view(),
name="api-schedule-notification",
),
path(
"notification/test/",
views.NotificationTestView.as_view(),
name="api-schedule-notification-test",
),
]

View File

@ -15,6 +15,7 @@ from task.serializers import (
TaskIDDataSerializer,
TaskNotificationPostSerializer,
TaskNotificationSerializer,
TaskNotificationTestSerializer,
TaskResultSerializer,
)
from task.src.config_schedule import CrontabValidator, ScheduleBuilder
@ -343,3 +344,34 @@ class ScheduleNotification(ApiBaseView):
Notifications(task_name).remove_task()
return Response(status=204)
class NotificationTestView(ApiBaseView):
"""resolves to /api/task/notification/test/
POST: test notification url
"""
@extend_schema(
request=TaskNotificationTestSerializer(),
responses={
200: OpenApiResponse(description="test notification sent"),
400: OpenApiResponse(
ErrorResponseSerializer(), description="bad request"
),
},
)
def post(self, request):
"""test notification"""
data_serializer = TaskNotificationTestSerializer(data=request.data)
data_serializer.is_valid(raise_exception=True)
validated_data = data_serializer.validated_data
url = validated_data["url"]
task_name = validated_data.get("task_name", "manual_test")
success, message = Notifications(task_name).test(url)
status = 200 if success else 400
return Response(
{"success": success, "message": message}, status=status
)

View File

@ -0,0 +1,16 @@
import APIClient from '../../functions/APIClient';
import { AppriseTaskNameType } from './createAppriseNotificationUrl';
export type TestNotificationResponseType = {
success: boolean;
message: string;
};
const testAppriseNotificationUrl = async (taskName: AppriseTaskNameType, url: string) => {
return APIClient<TestNotificationResponseType>('/api/task/notification/test/', {
method: 'POST',
body: { task_name: taskName, url },
});
};
export default testAppriseNotificationUrl;

View File

@ -9,6 +9,7 @@ export interface ButtonProps {
children?: string | ReactNode | ReactNode[];
value?: string;
title?: string;
disabled?: boolean;
onClick?: () => void;
}
@ -21,6 +22,7 @@ const Button = ({
children,
value,
title,
disabled,
onClick,
}: ButtonProps) => {
return (
@ -31,6 +33,7 @@ const Button = ({
type={type}
value={value}
title={title}
disabled={disabled}
onClick={onClick}
>
{label}

View File

@ -12,6 +12,9 @@ import createTaskSchedule from '../api/actions/createTaskSchedule';
import createAppriseNotificationUrl, {
AppriseTaskNameType,
} from '../api/actions/createAppriseNotificationUrl';
import testAppriseNotificationUrl, {
TestNotificationResponseType,
} from '../api/actions/testAppriseNotificationUrl';
import deleteAppriseNotificationUrl from '../api/actions/deleteAppriseNotificationUrl';
import { ApiError, ApiResponseType } from '../functions/APIClient';
@ -62,6 +65,10 @@ const SettingsScheduling = () => {
const [downloadPendingError, setDownloadPendingError] = useState<string | null>(null);
const [thumnailCheckError, setThumnailCheckError] = useState<string | null>(null);
const [zipBackupError, setZipBackupError] = useState<string | null>(null);
const [testNotificationMessage, setTestNotificationMessage] = useState<{
message: string;
success: boolean;
} | null>(null);
const { data: appriseNotificationData } = appriseNotification ?? {};
@ -539,49 +546,99 @@ const SettingsScheduling = () => {
</div>
<div className="settings-item">
<p>
<i>
Send notification on completed tasks with the help of the{' '}
<a href="https://github.com/caronc/apprise" target="_blank">
Apprise
</a>{' '}
library.
</i>
</p>
<div>
<p>
<i>
Send notification on completed tasks with the help of the{' '}
<a href="https://github.com/caronc/apprise" target="_blank">
Apprise
</a>{' '}
library.
</i>
</p>
<select
value={notificationTask}
onChange={e => {
setNotificationTask(e.currentTarget.value);
}}
>
<option value="">-- select task --</option>
<option value="update_subscribed">Rescan your Subscriptions</option>
<option value="extract_download">Add to download queue</option>
<option value="download_pending">Downloading</option>
<option value="check_reindex">Reindex Documents</option>
</select>
<select
value={notificationTask}
onChange={e => {
setNotificationTask(e.currentTarget.value);
}}
>
<option value="">-- select task --</option>
<option value="update_subscribed">Rescan your Subscriptions</option>
<option value="extract_download">Add to download queue</option>
<option value="download_pending">Downloading</option>
<option value="check_reindex">Reindex Documents</option>
</select>
<input
type="text"
placeholder="Apprise notification URL"
value={notificationUrl}
onChange={e => {
setNotificationUrl(e.currentTarget.value);
}}
/>
<input
type="text"
placeholder="Apprise notification URL"
value={notificationUrl}
onChange={e => {
setNotificationUrl(e.currentTarget.value);
}}
/>
<Button
label="Save"
onClick={async () => {
await createAppriseNotificationUrl(
notificationTask as AppriseTaskNameType,
notificationUrl || '',
);
<div className="button-box">
<Button
label="Save"
disabled={!notificationUrl || !notificationTask}
onClick={async () => {
await createAppriseNotificationUrl(
notificationTask as AppriseTaskNameType,
notificationUrl || '',
);
setRefresh(true);
}}
/>
setRefresh(true);
}}
/>
<Button
label="Test"
disabled={!notificationUrl || !notificationTask}
onClick={async () => {
setTestNotificationMessage(null);
try {
const response: ApiResponseType<TestNotificationResponseType> =
await testAppriseNotificationUrl(
notificationTask as AppriseTaskNameType,
notificationUrl || '',
);
if (response.data?.success) {
setTestNotificationMessage({
message: response.data.message,
success: true,
});
} else {
setTestNotificationMessage({
message: response.data?.message || 'Test notification failed',
success: true,
});
}
} catch (error) {
const apiError = error as ApiError;
if (apiError.status && apiError.message) {
setTestNotificationMessage({ message: apiError.message, success: false });
} else {
setTestNotificationMessage({
message: 'An unexpected error occurred while testing notification.',
success: false,
});
}
}
setRefresh(true);
}}
/>
</div>
{testNotificationMessage && (
<div
className={!testNotificationMessage.success ? 'danger-zone' : ''}
style={{ margin: '10px 5px' }}
>
<p>{testNotificationMessage.message}</p>
</div>
)}
</div>
</div>
</div>

View File

@ -133,6 +133,17 @@ button:hover {
color: var(--main-bg);
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
button:disabled:hover {
background-color: var(--accent-font-dark);
transform: none;
color: #ffffff;
}
.video-item.table {
overflow-x: auto;
-webkit-overflow-scrolling: touch;