diff --git a/app/models/task.py b/app/models/task.py index 497bb74ad..be2c49876 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -812,6 +812,11 @@ def callback(progress): else: # FAILED, CANCELED self.save() + + if self.status == status_codes.FAILED: + from app.plugins import signals as plugin_signals + plugin_signals.task_failed.send_robust(sender=self.__class__, task_id=self.id) + else: # Still waiting... self.save() diff --git a/app/plugins/functions.py b/app/plugins/functions.py index 1092f15f1..9e3b5f115 100644 --- a/app/plugins/functions.py +++ b/app/plugins/functions.py @@ -273,7 +273,7 @@ def get_plugin_by_name(name, only_active=True, refresh_cache_if_none=False): else: return res -def get_current_plugin(): +def get_current_plugin(only_active=False): """ When called from a python module inside a plugin's directory, it returns the plugin that this python module belongs to @@ -289,7 +289,7 @@ def get_current_plugin(): parts = relp.split(os.sep) if len(parts) > 0: plugin_name = parts[0] - return get_plugin_by_name(plugin_name, only_active=False) + return get_plugin_by_name(plugin_name, only_active=only_active) return None diff --git a/app/plugins/signals.py b/app/plugins/signals.py index 80ad2f66b..0f1701c37 100644 --- a/app/plugins/signals.py +++ b/app/plugins/signals.py @@ -3,5 +3,6 @@ task_completed = django.dispatch.Signal(providing_args=["task_id"]) task_removing = django.dispatch.Signal(providing_args=["task_id"]) task_removed = django.dispatch.Signal(providing_args=["task_id"]) +task_failed = django.dispatch.Signal(providing_args=["task_id"]) processing_node_removed = django.dispatch.Signal(providing_args=["processing_node_id"]) diff --git a/coreplugins/tasknotification/.gitignore b/coreplugins/tasknotification/.gitignore new file mode 100644 index 000000000..384293204 --- /dev/null +++ b/coreplugins/tasknotification/.gitignore @@ -0,0 +1 @@ +.conf \ No newline at end of file diff --git a/coreplugins/tasknotification/__init__.py b/coreplugins/tasknotification/__init__.py new file mode 100644 index 000000000..d6dda97cd --- /dev/null +++ b/coreplugins/tasknotification/__init__.py @@ -0,0 +1,2 @@ +from .plugin import * +from . import signals \ No newline at end of file diff --git a/coreplugins/tasknotification/config.py b/coreplugins/tasknotification/config.py new file mode 100644 index 000000000..2d613abc3 --- /dev/null +++ b/coreplugins/tasknotification/config.py @@ -0,0 +1,40 @@ +import os +import configparser + +script_dir = os.path.dirname(os.path.abspath(__file__)) + +def load(): + config = configparser.ConfigParser() + config.read(f"{script_dir}/.conf") + smtp_configuration = { + 'smtp_server': config.get('SETTINGS', 'smtp_server', fallback=""), + 'smtp_port': config.getint('SETTINGS', 'smtp_port', fallback=587), + 'smtp_username': config.get('SETTINGS', 'smtp_username', fallback=""), + 'smtp_password': config.get('SETTINGS', 'smtp_password', fallback=""), + 'smtp_use_tls': config.getboolean('SETTINGS', 'smtp_use_tls', fallback=False), + 'smtp_from_address': config.get('SETTINGS', 'smtp_from_address', fallback=""), + 'smtp_to_address': config.get('SETTINGS', 'smtp_to_address', fallback=""), + 'notification_app_name': config.get('SETTINGS', 'notification_app_name', fallback=""), + 'notify_task_completed': config.getboolean('SETTINGS', 'notify_task_completed', fallback=False), + 'notify_task_failed': config.getboolean('SETTINGS', 'notify_task_failed', fallback=False), + 'notify_task_removed': config.getboolean('SETTINGS', 'notify_task_removed', fallback=False) + } + return smtp_configuration + +def save(data : dict): + config = configparser.ConfigParser() + config['SETTINGS'] = { + 'smtp_server': str(data.get('smtp_server')), + 'smtp_port': str(data.get('smtp_port')), + 'smtp_username': str(data.get('smtp_username')), + 'smtp_password': str(data.get('smtp_password')), + 'smtp_use_tls': str(data.get('smtp_use_tls')), + 'smtp_from_address': str(data.get('smtp_from_address')), + 'smtp_to_address': str(data.get('smtp_to_address')), + 'notification_app_name': str(data.get('notification_app_name')), + 'notify_task_completed': str(data.get('notify_task_completed')), + 'notify_task_failed': str(data.get('notify_task_failed')), + 'notify_task_removed': str(data.get('notify_task_removed')) + } + with open(f"{script_dir}/.conf", 'w') as configFile: + config.write(configFile) \ No newline at end of file diff --git a/coreplugins/tasknotification/disabled b/coreplugins/tasknotification/disabled new file mode 100644 index 000000000..e69de29bb diff --git a/coreplugins/tasknotification/email.py b/coreplugins/tasknotification/email.py new file mode 100644 index 000000000..fc6004824 --- /dev/null +++ b/coreplugins/tasknotification/email.py @@ -0,0 +1,29 @@ +from django.core.mail import send_mail +from django.core.mail.backends.smtp import EmailBackend +from . import config + + +def send(subject : str, message : str, smtp_config : dict = None): + + if not smtp_config: + smtp_config = config.load() + + email_backend = EmailBackend( + smtp_config.get('smtp_server'), + smtp_config.get('smtp_port'), + smtp_config.get('smtp_username'), + smtp_config.get('smtp_password'), + smtp_config.get('smtp_use_tls'), + timeout=10 + ) + + result = send_mail( + subject, + message, + smtp_config.get('smtp_from_address'), + [smtp_config.get('smtp_to_address')], + connection=email_backend, + auth_user = smtp_config.get('smtp_username'), + auth_password = smtp_config.get('smtp_password'), + fail_silently = False + ) \ No newline at end of file diff --git a/coreplugins/tasknotification/manifest.json b/coreplugins/tasknotification/manifest.json new file mode 100644 index 000000000..559d84352 --- /dev/null +++ b/coreplugins/tasknotification/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "Task Notification", + "webodmMinVersion": "0.6.2", + "description": "Get notified when a task has finished processing, has been removed or has failed", + "version": "0.1.0", + "author": "Ronald W. Machado", + "email": "ronadlwilsonmachado@gmail.com", + "repository": "https://github.com/OpenDroneMap/WebODM", + "tags": [ + "notification", + "email", + "smtp" + ], + "homepage": "https://github.com/OpenDroneMap/WebODM", + "experimental": false, + "deprecated": false +} \ No newline at end of file diff --git a/coreplugins/tasknotification/plugin.py b/coreplugins/tasknotification/plugin.py new file mode 100644 index 000000000..715d82f8e --- /dev/null +++ b/coreplugins/tasknotification/plugin.py @@ -0,0 +1,114 @@ +from app.plugins import PluginBase, Menu, MountPoint +from app.models import Setting +from django.utils.translation import gettext as _ +from django.shortcuts import render +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django import forms +from smtplib import SMTPAuthenticationError, SMTPConnectError, SMTPDataError +from . import email +from . import config + +class ConfigurationForm(forms.Form): + notification_app_name = forms.CharField( + label='App name', + max_length=100, + required=True, + ) + smtp_to_address = forms.EmailField( + label='Send Notification to Address', + max_length=100, + required=True + ) + smtp_from_address = forms.EmailField( + label='From Address', + max_length=100, + required=True + ) + smtp_server = forms.CharField( + label='SMTP Server', + max_length=100, + required=True + ) + smtp_port = forms.IntegerField( + label='Port', + required=True + ) + smtp_username = forms.CharField( + label='Username', + max_length=100, + required=True + ) + smtp_password = forms.CharField( + label='Password', + max_length=100, + required=True + ) + smtp_use_tls = forms.BooleanField( + label='Use Transport Layer Security (TLS)', + required=False, + ) + + notify_task_completed = forms.BooleanField( + label='Notify Task Completed', + required=False, + ) + notify_task_failed = forms.BooleanField( + label='Notify Task Failed', + required=False, + ) + notify_task_removed = forms.BooleanField( + label='Notify Task Removed', + required=False, + ) + + def test_settings(self, request): + try: + settings = Setting.objects.first() + email.send(f'{self.cleaned_data["notification_app_name"]} - Testing Notification', 'Hi, just testing if notification is working', self.cleaned_data) + messages.success(request, f"Email sent successfully, check your inbox at {self.cleaned_data.get('smtp_to_address')}") + except SMTPAuthenticationError as e: + messages.error(request, 'Invalid SMTP username or password') + except SMTPConnectError as e: + messages.error(request, 'Could not connect to the SMTP server') + except SMTPDataError as e: + messages.error(request, 'Error sending email. Please try again later') + except Exception as e: + messages.error(request, f'An error occured: {e}') + + def save_settings(self): + config.save(self.cleaned_data) + +class Plugin(PluginBase): + def main_menu(self): + return [Menu(_("Task Notification"), self.public_url(""), "fa fa-envelope fa-fw")] + + def include_css_files(self): + return ['style.css'] + + def app_mount_points(self): + + @login_required + def index(request): + if request.method == "POST": + + form = ConfigurationForm(request.POST) + test_configuration = request.POST.get("test_configuration") + if form.is_valid() and test_configuration: + form.test_settings(request) + elif form.is_valid() and not test_configuration: + form.save_settings() + messages.success(request, "Notification settings applied successfully!") + else: + config_data = config.load() + + # notification_app_name initial value should be whatever is defined in the settings + settings = Setting.objects.first() + config_data['notification_app_name'] = config_data['notification_app_name'] or settings.app_name + form = ConfigurationForm(initial=config_data) + + return render(request, self.template_path('index.html'), {'form' : form, 'title' : 'Task Notification'}) + + return [ + MountPoint('$', index), + ] \ No newline at end of file diff --git a/coreplugins/tasknotification/public/style.css b/coreplugins/tasknotification/public/style.css new file mode 100644 index 000000000..6b537281a --- /dev/null +++ b/coreplugins/tasknotification/public/style.css @@ -0,0 +1,11 @@ +.errorlist { + color: red; + list-style: none; + margin: 0; + padding: 0; +} + +.errorlist li { + margin: 0; + padding: 0; +} \ No newline at end of file diff --git a/coreplugins/tasknotification/signals.py b/coreplugins/tasknotification/signals.py new file mode 100644 index 000000000..ad49e4c11 --- /dev/null +++ b/coreplugins/tasknotification/signals.py @@ -0,0 +1,98 @@ +import logging +from django.dispatch import receiver +from django.core.mail import send_mail +from app.plugins.signals import task_completed, task_failed, task_removed +from app.plugins.functions import get_current_plugin +from . import email as notification +from . import config +from app.models import Task, Setting + +logger = logging.getLogger('app.logger') + +@receiver(task_completed) +def handle_task_completed(sender, task_id, **kwargs): + if get_current_plugin(only_active=True) is None: + return + + logger.info("TaskNotification: Task Completed") + + config_data = config.load() + if config_data.get("notify_task_completed") == True: + task = Task.objects.get(id=task_id) + setting = Setting.objects.first() + notification_app_name = config_data['notification_app_name'] or settings.app_name + + console_output = reverse_output(task.console_output) + notification.send( + f"{notification_app_name} - {task.project.name} Task Completed", + f"{task.project.name}\n{task.name} Completed\nProcessing time:{hours_minutes_secs(task.processing_time)}\n\nConsole Output:{console_output}", + config_data + ) + +@receiver(task_removed) +def handle_task_removed(sender, task_id, **kwargs): + if get_current_plugin(only_active=True) is None: + return + + logger.info("TaskNotification: Task Removed") + + config_data = config.load() + if config_data.get("notify_task_removed") == True: + task = Task.objects.get(id=task_id) + setting = Setting.objects.first() + notification_app_name = config_data['notification_app_name'] or settings.app_name + console_output = reverse_output(task.console_output) + notification.send( + f"{notification_app_name} - {task.project.name} Task removed", + f"{task.project.name}\n{task.name} was removed\nProcessing time:{hours_minutes_secs(task.processing_time)}\n\nConsole Output:{console_output}", + config_data + ) + +@receiver(task_failed) +def handle_task_failed(sender, task_id, **kwargs): + if get_current_plugin(only_active=True) is None: + return + + logger.info("TaskNotification: Task Failed") + + config_data = config.load() + if config_data.get("notify_task_failed") == True: + task = Task.objects.get(id=task_id) + setting = Setting.objects.first() + notification_app_name = config_data['notification_app_name'] or settings.app_name + console_output = reverse_output(task.console_output) + notification.send( + f"{notification_app_name} - {task.project.name} Task Failed", + f"{task.project.name}\n{task.name} Failed with error: {task.last_error}\nProcessing time:{hours_minutes_secs(task.processing_time)}\n\nConsole Output:{console_output}", + config_data + ) + +def hours_minutes_secs(milliseconds): + if milliseconds == 0 or milliseconds == -1: + return "-- : -- : --" + + ch = 60 * 60 * 1000 + cm = 60 * 1000 + h = milliseconds // ch + m = (milliseconds - h * ch) // cm + s = round((milliseconds - h * ch - m * cm) / 1000) + pad = lambda n: '0' + str(n) if n < 10 else str(n) + + if s == 60: + m += 1 + s = 0 + if m == 60: + h += 1 + m = 0 + + return ':'.join([pad(h), pad(m), pad(s)]) + +def reverse_output(output_string): + # Split the output string into lines, then reverse the order + lines = output_string.split('\n') + lines.reverse() + + # Join the reversed lines back into a single string with newlines + reversed_string = '\n'.join(lines) + + return reversed_string \ No newline at end of file diff --git a/coreplugins/tasknotification/templates/index.html b/coreplugins/tasknotification/templates/index.html new file mode 100644 index 000000000..7c0016686 --- /dev/null +++ b/coreplugins/tasknotification/templates/index.html @@ -0,0 +1,92 @@ +{% extends "app/plugins/templates/base.html" %} +{% load i18n %} + +{% block content %} +

{% trans 'Tasks Notification' %}

+

Get notified when a task has finished processing, has been removed or has failed

+
+
+ {% csrf_token %} +
+
+
+ + + {{form.notification_app_name.errors}} +
+
+
+
+ + + {{ form.smtp_to_address.errors }} +
+
+
+

Allowed Notifications

+
+ + {{form.notify_task_completed.errors}} +
+
+ + {{form.notify_task_failed.errors}} +
+
+ + {{form.notify_task_removed.errors}} +
+
+

Smtp Settings

+
+
+
+
+ + + {{ form.smtp_from_address.errors }} +
+
+
+
+ + + {{form.smtp_server.errors}} +
+
+ + + {{form.smtp_port.errors}} +
+
+ + + {{form.smtp_username.errors}} +
+
+ + + {{form.smtp_password.errors}} +
+
+ + {{form.smtp_use_tls.errors}} +
+
+

+ {{ form.non_field_errors }} +

+
+ + +
+
+{% endblock %}