diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index b5cecf764d..c5a8ad4b67 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -716,6 +716,46 @@ def clean_decimal(number): return clean_number.quantize(Decimal(1)) if clean_number == clean_number.to_integral() else clean_number.normalize() +def get_objectreference(obj, type_ref: str = 'content_type', object_ref: str = 'object_id'): + """lookup method for the GenericForeignKey fields + + Attributes: + - obj: object that will be resolved + - type_ref: field name for the contenttype field in the model + - object_ref: field name for the object id in the model + + Example implementation in the serializer: + ``` + target = serializers.SerializerMethodField() + def get_target(self, obj): + return get_objectreference(obj, 'target_content_type', 'target_object_id') + ``` + + The method name must always be the name of the field prefixed by 'get_' + """ + model_cls = getattr(obj, type_ref) + obj_id = getattr(obj, object_ref) + + # check if references are set -> return nothing if not + if model_cls is None or obj_id is None: + return None + + # resolve referenced data into objects + model_cls = model_cls.model_class() + item = model_cls.objects.get(id=obj_id) + url_fnc = getattr(item, 'get_absolute_url', None) + + # create output + ret = {} + if url_fnc: + ret['link'] = url_fnc() + return { + 'name': str(item), + 'model': str(model_cls._meta.verbose_name), + **ret + } + + def inheritors(cls): """ Return all classes that are subclasses from the supplied cls diff --git a/InvenTree/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js index 078cb52924..d1b49aaf4b 100644 --- a/InvenTree/InvenTree/static/script/inventree/inventree.js +++ b/InvenTree/InvenTree/static/script/inventree/inventree.js @@ -1,6 +1,7 @@ /* globals ClipboardJS, inventreeFormDataUpload, + inventreeGet, launchModalForm, user_settings, */ @@ -216,8 +217,25 @@ function inventreeDocReady() { // Display any cached alert messages showCachedAlerts(); + + // start watcher + startNotificationWatcher(); + + // always refresh when the focus returns + $(document).focus(function(){ + startNotificationWatcher(); + }); + + // kill notification watcher if focus is lost -> respect your users cycles + $(document).blur(function(){ + stopNotificationWatcher(); + }); + + $('#offcanvasRight').on('show.bs.offcanvas', openNotificationPanel); // listener for opening the notification panel + $('#offcanvasRight').on('hidden.bs.offcanvas', closeNotificationPanel); // listener for closing the notification panel } + function isFileTransfer(transfer) { /* Determine if a transfer (e.g. drag-and-drop) is a file transfer */ diff --git a/InvenTree/InvenTree/static/script/inventree/notification.js b/InvenTree/InvenTree/static/script/inventree/notification.js deleted file mode 100644 index c4816d4b5c..0000000000 --- a/InvenTree/InvenTree/static/script/inventree/notification.js +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Add a cached alert message to sesion storage - */ -function addCachedAlert(message, options={}) { - - var alerts = sessionStorage.getItem('inventree-alerts'); - - if (alerts) { - alerts = JSON.parse(alerts); - } else { - alerts = []; - } - - alerts.push({ - message: message, - style: options.style || 'success', - icon: options.icon, - }); - - sessionStorage.setItem('inventree-alerts', JSON.stringify(alerts)); -} - - -/* - * Remove all cached alert messages - */ -function clearCachedAlerts() { - sessionStorage.removeItem('inventree-alerts'); -} - - -/* - * Display an alert, or cache to display on reload - */ -function showAlertOrCache(message, cache, options={}) { - - if (cache) { - addCachedAlert(message, options); - } else { - showMessage(message, options); - } -} - - -/* - * Display cached alert messages when loading a page - */ -function showCachedAlerts() { - - var alerts = JSON.parse(sessionStorage.getItem('inventree-alerts')) || []; - - alerts.forEach(function(alert) { - showMessage( - alert.message, - { - style: alert.style || 'success', - icon: alert.icon, - } - ); - }); - - clearCachedAlerts(); -} - - -/* - * Display an alert message at the top of the screen. - * The message will contain a "close" button, - * and also dismiss automatically after a certain amount of time. - * - * arguments: - * - message: Text / HTML content to display - * - * options: - * - style: alert style e.g. 'success' / 'warning' - * - timeout: Time (in milliseconds) after which the message will be dismissed - */ -function showMessage(message, options={}) { - - var style = options.style || 'info'; - - var timeout = options.timeout || 5000; - - var target = options.target || $('#alerts'); - - var details = ''; - - if (options.details) { - details = `

${options.details}

`; - } - - // Hacky function to get the next available ID - var id = 1; - - while ($(`#alert-${id}`).exists()) { - id++; - } - - var icon = ''; - - if (options.icon) { - icon = ``; - } - - // Construct the alert - var html = ` - - `; - - target.append(html); - - // Remove the alert automatically after a specified period of time - $(`#alert-${id}`).delay(timeout).slideUp(200, function() { - $(this).alert(close); - }); -} diff --git a/InvenTree/InvenTree/test_views.py b/InvenTree/InvenTree/test_views.py index 92f8e7b4f1..56d8889984 100644 --- a/InvenTree/InvenTree/test_views.py +++ b/InvenTree/InvenTree/test_views.py @@ -72,7 +72,7 @@ class ViewTests(TestCase): """ # Change this number as more javascript files are added to the index page - N_SCRIPT_FILES = 36 + N_SCRIPT_FILES = 37 content = self.get_index_page() diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index cf996547df..d795b81472 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -20,7 +20,7 @@ from order.urls import order_urls from plugin.urls import get_plugin_urls from barcodes.api import barcode_api_urls -from common.api import common_api_urls +from common.api import common_api_urls, settings_api_urls from part.api import part_api_urls, bom_api_urls from company.api import company_api_urls from stock.api import stock_api_urls @@ -43,6 +43,7 @@ from .views import CustomSessionDeleteView, CustomSessionDeleteOtherView from .views import CurrencyRefreshView from .views import AppearanceSelectView, SettingCategorySelectView from .views import DynamicJsView +from .views import NotificationsView from .api import InfoView, NotFoundView from .api import ActionPluginView @@ -60,7 +61,7 @@ if settings.PLUGINS_ENABLED: apipatterns += [ url(r'^barcode/', include(barcode_api_urls)), - url(r'^settings/', include(common_api_urls)), + url(r'^settings/', include(settings_api_urls)), url(r'^part/', include(part_api_urls)), url(r'^bom/', include(bom_api_urls)), url(r'^company/', include(company_api_urls)), @@ -99,6 +100,12 @@ settings_urls = [ url(r'^.*$', SettingsView.as_view(template_name='InvenTree/settings/settings.html'), name='settings'), ] +notifications_urls = [ + + # Catch any other urls + url(r'^.*$', NotificationsView.as_view(), name='notifications'), +] + # These javascript files are served "dynamically" - i.e. rendered on demand dynamic_javascript_urls = [ url(r'^calendar.js', DynamicJsView.as_view(template_name='js/dynamic/calendar.js'), name='calendar.js'), @@ -127,6 +134,7 @@ translated_javascript_urls = [ url(r'^plugin.js', DynamicJsView.as_view(template_name='js/translated/plugin.js'), name='plugin.js'), url(r'^tables.js', DynamicJsView.as_view(template_name='js/translated/tables.js'), name='tables.js'), url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/translated/table_filters.js'), name='table_filters.js'), + url(r'^notification.js', DynamicJsView.as_view(template_name='js/translated/notification.js'), name='notification.js'), ] backendpatterns = [ @@ -160,6 +168,8 @@ frontendpatterns = [ url(r'^settings/', include(settings_urls)), + url(r'^notifications/', include(notifications_urls)), + url(r'^edit-user/', EditUserView.as_view(), name='edit-user'), url(r'^set-password/', SetPasswordView.as_view(), name='set-password'), diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index a5c4da48b6..feb586c844 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -904,3 +904,10 @@ class DatabaseStatsView(AjaxView): """ return ctx + + +class NotificationsView(TemplateView): + """ View for showing notifications + """ + + template_name = "InvenTree/notifications/notifications.html" diff --git a/InvenTree/common/admin.py b/InvenTree/common/admin.py index 91c872a642..b49bef1c07 100644 --- a/InvenTree/common/admin.py +++ b/InvenTree/common/admin.py @@ -48,8 +48,18 @@ class NotificationEntryAdmin(admin.ModelAdmin): list_display = ('key', 'uid', 'updated', ) +class NotificationMessageAdmin(admin.ModelAdmin): + + list_display = ('age_human', 'user', 'category', 'name', 'read', 'target_object', 'source_object', ) + + list_filter = ('category', 'read', 'user', ) + + search_fields = ('name', 'category', 'message', ) + + admin.site.register(common.models.InvenTreeSetting, SettingsAdmin) admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin) admin.site.register(common.models.WebhookEndpoint, WebhookAdmin) admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin) admin.site.register(common.models.NotificationEntry, NotificationEntryAdmin) +admin.site.register(common.models.NotificationMessage, NotificationMessageAdmin) diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index 7c246c46d5..d725799894 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -14,9 +14,11 @@ from django.views.decorators.csrf import csrf_exempt from django.conf.urls import url, include from rest_framework.views import APIView +from rest_framework.response import Response from rest_framework.exceptions import NotAcceptable, NotFound from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters, generics, permissions +from rest_framework import serializers from django_q.tasks import async_task import common.models @@ -217,9 +219,122 @@ class UserSettingsDetail(generics.RetrieveUpdateAPIView): ] -common_api_urls = [ - path('webhook//', WebhookView.as_view(), name='api-webhook'), +class NotificationList(generics.ListAPIView): + queryset = common.models.NotificationMessage.objects.all() + serializer_class = common.serializers.NotificationMessageSerializer + filter_backends = [ + DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter, + ] + + ordering_fields = [ + 'category', + 'name', + 'read', + ] + + search_fields = [ + 'name', + 'message', + ] + + filter_fields = [ + 'category', + 'read', + ] + + def filter_queryset(self, queryset): + """ + Only list notifications which apply to the current user + """ + + try: + user = self.request.user + except AttributeError: + return common.models.NotificationMessage.objects.none() + + queryset = super().filter_queryset(queryset) + queryset = queryset.filter(user=user) + return queryset + + +class NotificationDetail(generics.RetrieveUpdateDestroyAPIView): + """ + Detail view for an individual notification object + + - User can only view / delete their own notification objects + """ + + queryset = common.models.NotificationMessage.objects.all() + serializer_class = common.serializers.NotificationMessageSerializer + permission_classes = [ + UserSettingsPermissions, + ] + + +class NotificationReadEdit(generics.CreateAPIView): + """ + general API endpoint to manipulate read state of a notification + """ + + queryset = common.models.NotificationMessage.objects.all() + serializer_class = common.serializers.NotificationReadSerializer + + permission_classes = [ + UserSettingsPermissions, + ] + + def get_serializer_context(self): + context = super().get_serializer_context() + if self.request: + context['instance'] = self.get_object() + return context + + def perform_create(self, serializer): + message = self.get_object() + try: + message.read = self.target + message.save() + except Exception as exc: + raise serializers.ValidationError(detail=serializers.as_serializer_error(exc)) + + +class NotificationRead(NotificationReadEdit): + """ + API endpoint to mark a notification as read. + """ + target = True + + +class NotificationUnread(NotificationReadEdit): + """ + API endpoint to mark a notification as unread. + """ + target = False + + +class NotificationReadAll(generics.RetrieveAPIView): + """ + API endpoint to mark all notifications as read. + """ + + queryset = common.models.NotificationMessage.objects.all() + + permission_classes = [ + UserSettingsPermissions, + ] + + def get(self, request, *args, **kwargs): + try: + self.queryset.filter(user=request.user, read=False).update(read=True) + return Response({'status': 'ok'}) + except Exception as exc: + raise serializers.ValidationError(detail=serializers.as_serializer_error(exc)) + + +settings_api_urls = [ # User settings url(r'^user/', include([ # User Settings Detail @@ -237,5 +352,25 @@ common_api_urls = [ # Global Settings List url(r'^.*$', GlobalSettingsList.as_view(), name='api-global-setting-list'), ])), +] + +common_api_urls = [ + # Webhooks + path('webhook//', WebhookView.as_view(), name='api-webhook'), + + # Notifications + url(r'^notifications/', include([ + # Individual purchase order detail URLs + url(r'^(?P\d+)/', include([ + url(r'^read/', NotificationRead.as_view(), name='api-notifications-read'), + url(r'^unread/', NotificationUnread.as_view(), name='api-notifications-unread'), + url(r'.*$', NotificationDetail.as_view(), name='api-notifications-detail'), + ])), + # Read all + url(r'^readall/', NotificationReadAll.as_view(), name='api-notifications-readall'), + + # Notification messages list + url(r'^.*$', NotificationList.as_view(), name='api-notifications-list'), + ])), ] diff --git a/InvenTree/common/migrations/0014_notificationmessage.py b/InvenTree/common/migrations/0014_notificationmessage.py new file mode 100644 index 0000000000..aa842a20a3 --- /dev/null +++ b/InvenTree/common/migrations/0014_notificationmessage.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.5 on 2022-02-13 03:09 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('common', '0013_webhookendpoint_webhookmessage'), + ] + + operations = [ + migrations.CreateModel( + name='NotificationMessage', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('target_object_id', models.PositiveIntegerField()), + ('source_object_id', models.PositiveIntegerField(blank=True, null=True)), + ('category', models.CharField(max_length=250)), + ('name', models.CharField(max_length=250)), + ('message', models.CharField(blank=True, max_length=250, null=True)), + ('creation', models.DateTimeField(auto_now_add=True)), + ('read', models.BooleanField(default=False)), + ('source_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='notification_source', to='contenttypes.contenttype')), + ('target_content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_target', to='contenttypes.contenttype')), + ('user', models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + ), + ] diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 22afb80664..56e41dd24b 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -19,8 +19,13 @@ from datetime import datetime, timedelta from django.db import models, transaction from django.contrib.auth.models import User, Group +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.db.utils import IntegrityError, OperationalError from django.conf import settings +from django.urls import reverse +from django.utils.timezone import now +from django.contrib.humanize.templatetags.humanize import naturaltime from djmoney.settings import CURRENCY_CHOICES from djmoney.contrib.exchange.models import convert_money @@ -1699,3 +1704,90 @@ class NotificationEntry(models.Model): ) entry.save() + + +class NotificationMessage(models.Model): + """ + A NotificationEntry records the last time a particular notifaction was sent out. + + It is recorded to ensure that notifications are not sent out "too often" to users. + + Attributes: + - key: A text entry describing the notification e.g. 'part.notify_low_stock' + - uid: An (optional) numerical ID for a particular instance + - date: The last time this notification was sent + """ + + # generic link to target + target_content_type = models.ForeignKey( + ContentType, + on_delete=models.CASCADE, + related_name='notification_target', + ) + + target_object_id = models.PositiveIntegerField() + + target_object = GenericForeignKey('target_content_type', 'target_object_id') + + # generic link to source + source_content_type = models.ForeignKey( + ContentType, + on_delete=models.SET_NULL, + related_name='notification_source', + null=True, + blank=True, + ) + + source_object_id = models.PositiveIntegerField( + null=True, + blank=True, + ) + + source_object = GenericForeignKey('source_content_type', 'source_object_id') + + # user that receives the notification + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + verbose_name=_('User'), + help_text=_('User'), + null=True, + blank=True, + ) + + category = models.CharField( + max_length=250, + blank=False, + ) + + name = models.CharField( + max_length=250, + blank=False, + ) + + message = models.CharField( + max_length=250, + blank=True, + null=True, + ) + + creation = models.DateTimeField( + auto_now_add=True, + ) + + read = models.BooleanField( + default=False, + ) + + @staticmethod + def get_api_url(): + return reverse('api-notifications-list') + + def age(self): + """age of the message in seconds""" + delta = now() - self.creation + return delta.seconds + + def age_human(self): + """humanized age""" + return naturaltime(self.creation) diff --git a/InvenTree/common/notifications.py b/InvenTree/common/notifications.py new file mode 100644 index 0000000000..6ba7b6d659 --- /dev/null +++ b/InvenTree/common/notifications.py @@ -0,0 +1,228 @@ +import logging +from datetime import timedelta + +from django.template.loader import render_to_string + +from allauth.account.models import EmailAddress + +from InvenTree.helpers import inheritors +from InvenTree.ready import isImportingData +from common.models import NotificationEntry, NotificationMessage +import InvenTree.tasks + + +logger = logging.getLogger('inventree') + + +# region notification classes +# region base classes +class NotificationMethod: + METHOD_NAME = '' + CONTEXT_BUILTIN = ['name', 'message', ] + CONTEXT_EXTRA = [] + + def __init__(self, obj, category, targets, context) -> None: + # Check if a sending fnc is defined + if (not hasattr(self, 'send')) and (not hasattr(self, 'send_bulk')): + raise NotImplementedError('A NotificationMethod must either define a `send` or a `send_bulk` method') + + # No method name is no good + if self.METHOD_NAME in ('', None): + raise NotImplementedError(f'The NotificationMethod {self.__class__} did not provide a METHOD_NAME') + + # Define arguments + self.obj = obj + self.category = category + self.targets = targets + self.context = self.check_context(context) + + # Gather targets + self.targets = self.get_targets() + + def check_context(self, context): + def check(ref, obj): + # the obj is not accesible so we are on the end + if not isinstance(obj, (list, dict, tuple, )): + return ref + + # check if the ref exsists + if isinstance(ref, str): + if not obj.get(ref): + return ref + return False + + # nested + elif isinstance(ref, (tuple, list)): + if len(ref) == 1: + return check(ref[0], obj) + ret = check(ref[0], obj) + if ret: + return ret + return check(ref[1:], obj[ref[0]]) + + # other cases -> raise + raise NotImplementedError('This type can not be used as a context reference') + + missing = [] + for item in (*self.CONTEXT_BUILTIN, *self.CONTEXT_EXTRA): + ret = check(item, context) + if ret: + missing.append(ret) + + if missing: + raise NotImplementedError(f'The `context` is missing the following items:\n{missing}') + + return context + + def get_targets(self): + raise NotImplementedError('The `get_targets` method must be implemented!') + + def setup(self): + return True + + # def send(self, targets) + # def send_bulk(self) + + def cleanup(self): + return True + + +class SingleNotificationMethod(NotificationMethod): + def send(self, target): + raise NotImplementedError('The `send` method must be overriden!') + + +class BulkNotificationMethod(NotificationMethod): + def send_bulk(self): + raise NotImplementedError('The `send` method must be overriden!') +# endregion + + +# region implementations +class EmailNotification(BulkNotificationMethod): + METHOD_NAME = 'mail' + CONTEXT_EXTRA = [ + ('template', ), + ('template', 'html', ), + ('template', 'subject', ), + ] + + def get_targets(self): + return EmailAddress.objects.filter( + user__in=self.targets, + ) + + def send_bulk(self): + html_message = render_to_string(self.context['template']['html'], self.context) + targets = self.targets.values_list('email', flat=True) + + InvenTree.tasks.send_email(self.context['template']['subject'], '', targets, html_message=html_message) + + return True + + +class UIMessageNotification(SingleNotificationMethod): + METHOD_NAME = 'ui_message' + + def get_targets(self): + return self.targets + + def send(self, target): + NotificationMessage.objects.create( + target_object=self.obj, + source_object=target, + user=target, + category=self.category, + name=self.context['name'], + message=self.context['message'], + ) + return True +# endregion +# endregion + + +def trigger_notifaction(obj, category=None, obj_ref='pk', targets=None, target_fnc=None, target_args=[], target_kwargs={}, context={}): + """ + Send out an notification + """ + # check if data is importet currently + if isImportingData(): + return + + # Resolve objekt reference + obj_ref_value = getattr(obj, obj_ref) + # Try with some defaults + if not obj_ref_value: + obj_ref_value = getattr(obj, 'pk') + if not obj_ref_value: + obj_ref_value = getattr(obj, 'id') + if not obj_ref_value: + raise KeyError(f"Could not resolve an object reference for '{str(obj)}' with {obj_ref}, pk, id") + + # Check if we have notified recently... + delta = timedelta(days=1) + + if NotificationEntry.check_recent(category, obj_ref_value, delta): + logger.info(f"Notification '{category}' has recently been sent for '{str(obj)}' - SKIPPING") + return + + logger.info(f"Gathering users for notification '{category}'") + # Collect possible targets + if not targets: + targets = target_fnc(*target_args, **target_kwargs) + + if targets: + logger.info(f"Sending notification '{category}' for '{str(obj)}'") + + # Collect possible methods + delivery_methods = inheritors(NotificationMethod) + + for method in [a for a in delivery_methods if a not in [SingleNotificationMethod, BulkNotificationMethod]]: + logger.info(f"Triggering method '{method.METHOD_NAME}'") + try: + deliver_notification(method, obj, category, targets, context) + except NotImplementedError as error: + raise error + except Exception as error: + logger.error(error) + + # Set delivery flag + NotificationEntry.notify(category, obj_ref_value) + else: + logger.info(f"No possible users for notification '{category}'") + + +def deliver_notification(cls: NotificationMethod, obj, category: str, targets, context: dict): + # Init delivery method + method = cls(obj, category, targets, context) + + if method.targets and len(method.targets) > 0: + # Log start + logger.info(f"Notify users via '{method.METHOD_NAME}' for notification '{category}' for '{str(obj)}'") + + # Run setup for delivery method + method.setup() + + # Counters for success logs + success = True + success_count = 0 + + # Select delivery method and execute it + if hasattr(method, 'send_bulk'): + success = method.send_bulk() + success_count = len(method.targets) + + elif hasattr(method, 'send'): + for target in method.targets: + if method.send(target): + success_count += 1 + else: + success = False + + # Run cleanup for delivery method + method.cleanup() + + # Log results + logger.info(f"Notified {success_count} users via '{method.METHOD_NAME}' for notification '{category}' for '{str(obj)}' successfully") + if not success: + logger.info("There were some problems") diff --git a/InvenTree/common/serializers.py b/InvenTree/common/serializers.py index 60eb609dc1..71ccac8a4d 100644 --- a/InvenTree/common/serializers.py +++ b/InvenTree/common/serializers.py @@ -6,10 +6,11 @@ JSON serializers for common components from __future__ import unicode_literals from InvenTree.serializers import InvenTreeModelSerializer +from InvenTree.helpers import get_objectreference from rest_framework import serializers -from common.models import InvenTreeSetting, InvenTreeUserSetting +from common.models import InvenTreeSetting, InvenTreeUserSetting, NotificationMessage class SettingsSerializer(InvenTreeModelSerializer): @@ -95,3 +96,59 @@ class UserSettingsSerializer(SettingsSerializer): 'type', 'choices', ] + + +class NotificationMessageSerializer(InvenTreeModelSerializer): + """ + Serializer for the InvenTreeUserSetting model + """ + + target = serializers.SerializerMethodField(read_only=True) + + source = serializers.SerializerMethodField(read_only=True) + + user = serializers.PrimaryKeyRelatedField(read_only=True) + + category = serializers.CharField(read_only=True) + + name = serializers.CharField(read_only=True) + + message = serializers.CharField(read_only=True) + + creation = serializers.CharField(read_only=True) + + age = serializers.IntegerField(read_only=True) + + age_human = serializers.CharField(read_only=True) + + read = serializers.BooleanField(read_only=True) + + def get_target(self, obj): + return get_objectreference(obj, 'target_content_type', 'target_object_id') + + def get_source(self, obj): + return get_objectreference(obj, 'source_content_type', 'source_object_id') + + class Meta: + model = NotificationMessage + fields = [ + 'pk', + 'target', + 'source', + 'user', + 'category', + 'name', + 'message', + 'creation', + 'age', + 'age_human', + 'read', + ] + + +class NotificationReadSerializer(NotificationMessageSerializer): + + def is_valid(self, raise_exception=False): + self.instance = self.context['instance'] # set instance that should be returned + self._validated_data = True + return True diff --git a/InvenTree/common/test_notifications.py b/InvenTree/common/test_notifications.py new file mode 100644 index 0000000000..3c0009fb51 --- /dev/null +++ b/InvenTree/common/test_notifications.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from common.notifications import NotificationMethod, SingleNotificationMethod, BulkNotificationMethod +from part.test_part import BaseNotificationIntegrationTest + + +class BaseNotificationTests(BaseNotificationIntegrationTest): + + def test_NotificationMethod(self): + """ensure the implementation requirements are tested""" + + class FalseNotificationMethod(NotificationMethod): + METHOD_NAME = 'FalseNotification' + + class AnotherFalseNotificationMethod(NotificationMethod): + METHOD_NAME = 'AnotherFalseNotification' + + def send(self): + """a comment so we do not need a pass""" + + class NoNameNotificationMethod(NotificationMethod): + + def send(self): + """a comment so we do not need a pass""" + + class WrongContextNotificationMethod(NotificationMethod): + METHOD_NAME = 'WrongContextNotification' + CONTEXT_EXTRA = [ + 'aa', + ('aa', 'bb', ), + ('templates', 'ccc', ), + (123, ) + ] + + def send(self): + """a comment so we do not need a pass""" + + class WrongDeliveryImplementation(SingleNotificationMethod): + METHOD_NAME = 'WrongDeliveryImplementation' + + def get_targets(self): + return [1, ] + + def send(self, target): + return False + + # no send / send bulk + with self.assertRaises(NotImplementedError): + FalseNotificationMethod('', '', '', '', ) + + # no METHOD_NAME + with self.assertRaises(NotImplementedError): + NoNameNotificationMethod('', '', '', '', ) + + # a not existant context check + with self.assertRaises(NotImplementedError): + WrongContextNotificationMethod('', '', '', '', ) + + # no get_targets + with self.assertRaises(NotImplementedError): + AnotherFalseNotificationMethod('', '', '', {'name': 1, 'message': 2, }, ) + + def test_failing_passing(self): + # cover failing delivery + self._notification_run() + + def test_errors_passing(self): + """ensure that errors do not kill the whole delivery""" + + class ErrorImplementation(SingleNotificationMethod): + METHOD_NAME = 'ErrorImplementation' + + def get_targets(self): + return [1, ] + + def send(self, target): + raise KeyError('This could be any error') + + self._notification_run() + + +class BulkNotificationMethodTests(BaseNotificationIntegrationTest): + + def test_BulkNotificationMethod(self): + """ensure the implementation requirements are tested""" + + class WrongImplementation(BulkNotificationMethod): + METHOD_NAME = 'WrongImplementationBulk' + + def get_targets(self): + return [1, ] + + with self.assertRaises(NotImplementedError): + self._notification_run() + + +class SingleNotificationMethodTests(BaseNotificationIntegrationTest): + + def test_SingleNotificationMethod(self): + """ensure the implementation requirements are tested""" + + class WrongImplementation(SingleNotificationMethod): + METHOD_NAME = 'WrongImplementationSingle' + + def get_targets(self): + return [1, ] + + with self.assertRaises(NotImplementedError): + self._notification_run() + +# A integration test for notifications is provided in test_part.PartNotificationTest diff --git a/InvenTree/part/tasks.py b/InvenTree/part/tasks.py index a004b8a8cf..9bc34f83df 100644 --- a/InvenTree/part/tasks.py +++ b/InvenTree/part/tasks.py @@ -2,18 +2,13 @@ from __future__ import unicode_literals import logging -from datetime import timedelta from django.utils.translation import ugettext_lazy as _ -from django.template.loader import render_to_string -from allauth.account.models import EmailAddress - -from common.models import NotificationEntry import InvenTree.helpers import InvenTree.tasks -from InvenTree.ready import isImportingData +import common.notifications import part.models @@ -21,48 +16,26 @@ logger = logging.getLogger("inventree") def notify_low_stock(part: part.models.Part): - """ - Notify users who have starred a part when its stock quantity falls below the minimum threshold - """ + name = _("Low stock notification") + message = _(f'The available stock for {part.name} has fallen below the configured minimum level') + context = { + 'part': part, + 'name': name, + 'message': message, + 'link': InvenTree.helpers.construct_absolute_url(part.get_absolute_url()), + 'template': { + 'html': 'email/low_stock_notification.html', + 'subject': "[InvenTree] " + name, + }, + } - # Do not notify if we are importing data - if isImportingData(): - return - - # Check if we have notified recently... - delta = timedelta(days=1) - - if NotificationEntry.check_recent('part.notify_low_stock', part.pk, delta): - logger.info(f"Low stock notification has recently been sent for '{part.full_name}' - SKIPPING") - return - - logger.info(f"Sending low stock notification email for {part.full_name}") - - # Get a list of users who are subcribed to this part - subscribers = part.get_subscribers() - - emails = EmailAddress.objects.filter( - user__in=subscribers, + common.notifications.trigger_notifaction( + part, + 'part.notify_low_stock', + target_fnc=part.get_subscribers, + context=context, ) - # TODO: In the future, include the part image in the email template - - if len(emails) > 0: - logger.info(f"Notify users regarding low stock of {part.name}") - context = { - # Pass the "Part" object through to the template context - 'part': part, - 'link': InvenTree.helpers.construct_absolute_url(part.get_absolute_url()), - } - - subject = "[InvenTree] " + _("Low stock notification") - html_message = render_to_string('email/low_stock_notification.html', context) - recipients = emails.values_list('email', flat=True) - - InvenTree.tasks.send_email(subject, '', recipients, html_message=html_message) - - NotificationEntry.notify('part.notify_low_stock', part.pk) - def notify_low_stock_if_required(part: part.models.Part): """ diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index b61affcafd..040b2c9e68 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -3,6 +3,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from allauth.account.models import EmailAddress from django.contrib.auth import get_user_model @@ -17,7 +18,7 @@ from .templatetags import inventree_extras import part.settings -from common.models import InvenTreeSetting +from common.models import InvenTreeSetting, NotificationEntry, NotificationMessage class TemplateTagTest(TestCase): @@ -464,3 +465,64 @@ class PartSubscriptionTests(TestCase): # Check part self.assertTrue(self.part.is_starred_by(self.user)) + + +class BaseNotificationIntegrationTest(TestCase): + """ Integration test for notifications """ + + fixtures = [ + 'location', + 'category', + 'part', + 'stock' + ] + + def setUp(self): + # Create a user for auth + user = get_user_model() + + self.user = user.objects.create_user( + username='testuser', + email='test@testing.com', + password='password', + is_staff=True + ) + # Add Mailadress + EmailAddress.objects.create(user=self.user, email='test@testing.com') + + # Define part that will be tested + self.part = Part.objects.get(name='R_2K2_0805') + + def _notification_run(self): + # There should be no notification runs + self.assertEqual(NotificationEntry.objects.all().count(), 0) + + # Test that notifications run through without errors + self.part.minimum_stock = self.part.get_stock_count() + 1 # make sure minimum is one higher than current count + self.part.save() + + # There should be no notification as no-one is subscribed + self.assertEqual(NotificationEntry.objects.all().count(), 0) + + # Subscribe and run again + self.part.set_starred(self.user, True) + self.part.save() + + # There should be 1 notification + self.assertEqual(NotificationEntry.objects.all().count(), 1) + + +class PartNotificationTest(BaseNotificationIntegrationTest): + """ Integration test for part notifications """ + + def test_notification(self): + self._notification_run() + + # There should be 1 notification message right now + self.assertEqual(NotificationMessage.objects.all().count(), 1) + + # Try again -> cover the already send line + self.part.save() + + # There should not be more messages + self.assertEqual(NotificationMessage.objects.all().count(), 1) diff --git a/InvenTree/templates/InvenTree/notifications/history.html b/InvenTree/templates/InvenTree/notifications/history.html new file mode 100644 index 0000000000..863c797d1f --- /dev/null +++ b/InvenTree/templates/InvenTree/notifications/history.html @@ -0,0 +1,25 @@ +{% extends "panel.html" %} + +{% load i18n %} +{% load inventree_extras %} + +{% block label %}history{% endblock %} + +{% block heading %} +{% trans "Notification History" %} +{% endblock %} + +{% block actions %} +
+ {% trans "Refresh Notification History" %} +
+{% endblock %} + +{% block content %} + +
+ +
+
+ +{% endblock %} \ No newline at end of file diff --git a/InvenTree/templates/InvenTree/notifications/inbox.html b/InvenTree/templates/InvenTree/notifications/inbox.html new file mode 100644 index 0000000000..7ec2a27b6a --- /dev/null +++ b/InvenTree/templates/InvenTree/notifications/inbox.html @@ -0,0 +1,28 @@ +{% extends "panel.html" %} + +{% load i18n %} +{% load inventree_extras %} + +{% block label %}inbox{% endblock %} + +{% block heading %} +{% trans "Pending Notifications" %} +{% endblock %} + +{% block actions %} +
+ {% trans "Mark all as read" %} +
+
+ {% trans "Refresh Pending Notifications" %} +
+{% endblock %} + +{% block content %} + +
+ +
+
+ +{% endblock %} \ No newline at end of file diff --git a/InvenTree/templates/InvenTree/notifications/notifications.html b/InvenTree/templates/InvenTree/notifications/notifications.html new file mode 100644 index 0000000000..89f10c3e3d --- /dev/null +++ b/InvenTree/templates/InvenTree/notifications/notifications.html @@ -0,0 +1,154 @@ +{% extends "base.html" %} + +{% load i18n %} +{% load inventree_extras %} + +{% block breadcrumb_list %} +{% endblock %} + +{% block page_title %} +{% inventree_title %} | {% trans "Notifications" %} +{% endblock %} + +{% block sidebar %} + {% include "InvenTree/notifications/sidebar.html" %} +{% endblock %} + +{% block content %} + {% include "InvenTree/notifications/inbox.html" %} + {% include "InvenTree/notifications/history.html" %} +{% endblock %} + +{% block js_ready %} +{{ block.super }} + +function updateNotificationTables() { + $("#inbox-table").bootstrapTable('refresh'); + $("#history-table").bootstrapTable('refresh'); +} +// this allows the global notification panel to update the tables +window.updateNotifications = updateNotificationTables + +function loadNotificationTable(table, options={}, enableDelete=false) { + + var params = options.params || {}; + var read = typeof(params.read) === 'undefined' ? true : params.read; + + $(table).inventreeTable({ + url: options.url, + name: options.name, + groupBy: false, + search: true, + queryParams: { + ordering: 'age', + read: read, + }, + paginationVAlign: 'bottom', + formatNoMatches: options.no_matches, + columns: [ + { + field: 'pk', + title: '{% trans "ID" %}', + visible: false, + switchable: false, + }, + { + field: 'age', + title: '{% trans "Age" %}', + sortable: 'true', + formatter: function(value, row) { + return row.age_human + } + }, + { + field: 'category', + title: '{% trans "Category" %}', + sortable: 'true', + }, + { + field: 'target', + title: '{% trans "Item" %}', + sortable: 'true', + formatter: function(value, row, index, field) { + if (value == null) { + return ''; + } + + var html = `${value.model}: ${value.name}`; + if (value.link ) {html = `${html}`;} + return html; + } + }, + { + field: 'name', + title: '{% trans "Name" %}', + }, + { + field: 'message', + title: '{% trans "Message" %}', + }, + { + formatter: function(value, row, index, field) { + var bRead = getReadEditButton(row.pk, row.read) + if (enableDelete) { + var bDel = ""; + } else { + var bDel = ''; + } + var html = "
" + bRead + bDel + "
"; + return html; + } + } + ] + }); + + $(table).on('click', '.notification-read', function() { + updateNotificationReadState($(this)); + }); +} + +loadNotificationTable("#inbox-table", { + name: 'inbox', + url: '{% url 'api-notifications-list' %}', + params: { + read: false, + }, + no_matches: function() { return '{% trans "No unread notifications found" %}'; }, +}); + +$("#inbox-refresh").on('click', function() { + $("#inbox-table").bootstrapTable('refresh'); +}); + +$("#mark-all").on('click', function() { + inventreeGet( + '{% url "api-notifications-readall" %}', + { + read: false, + }, + ); + updateNotificationTables(); +}); + +loadNotificationTable("#history-table", { + name: 'history', + url: '{% url 'api-notifications-list' %}', + no_matches: function() { return '{% trans "No notification history found" %}'; }, +}, true); + +$("#history-refresh").on('click', function() { + $("#history-table").bootstrapTable('refresh'); +}); + +$("#history-table").on('click', '.notification-delete', function() { + constructForm(`/api/notifications/${$(this).attr('pk')}/`, { + method: 'DELETE', + title: '{% trans "Delete Notification" %}', + onSuccess: function(data) { + updateNotificationTables(); + } + }); +}); + +enableSidebar('notifications'); +{% endblock %} diff --git a/InvenTree/templates/InvenTree/notifications/sidebar.html b/InvenTree/templates/InvenTree/notifications/sidebar.html new file mode 100644 index 0000000000..5b832113e2 --- /dev/null +++ b/InvenTree/templates/InvenTree/notifications/sidebar.html @@ -0,0 +1,11 @@ +{% load i18n %} +{% load static %} +{% load inventree_extras %} + +{% trans "Notifications" as text %} +{% include "sidebar_header.html" with text=text icon='fa-bell' %} + +{% trans "Inbox" as text %} +{% include "sidebar_item.html" with label='inbox' text=text icon="fa-envelope" %} +{% trans "History" as text %} +{% include "sidebar_item.html" with label='history' text=text icon="fa-clock" %} diff --git a/InvenTree/templates/account/base.html b/InvenTree/templates/account/base.html index ea3795e87c..6c54faac67 100644 --- a/InvenTree/templates/account/base.html +++ b/InvenTree/templates/account/base.html @@ -91,7 +91,7 @@ - + diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html index f86218c021..f916344bf9 100644 --- a/InvenTree/templates/base.html +++ b/InvenTree/templates/base.html @@ -128,6 +128,7 @@ {% include 'modals.html' %} {% include 'about.html' %} + {% include "notifications.html" %} @@ -161,7 +162,6 @@ - @@ -189,8 +189,10 @@ + + diff --git a/InvenTree/templates/email/low_stock_notification.html b/InvenTree/templates/email/low_stock_notification.html index f922187f33..01c475cd87 100644 --- a/InvenTree/templates/email/low_stock_notification.html +++ b/InvenTree/templates/email/low_stock_notification.html @@ -4,7 +4,7 @@ {% load inventree_extras %} {% block title %} -{% blocktrans with part=part.name %} The available stock for {{ part }} has fallen below the configured minimum level{% endblocktrans %} +{{ message }} {% if link %}

{% trans "Click on the following link to view this part" %}: {{ link }}

{% endif %} diff --git a/InvenTree/templates/js/translated/notification.js b/InvenTree/templates/js/translated/notification.js new file mode 100644 index 0000000000..4da4c2e875 --- /dev/null +++ b/InvenTree/templates/js/translated/notification.js @@ -0,0 +1,313 @@ +{% load i18n %} + +/* exported + showAlertOrCache, + showCachedAlerts, + startNotificationWatcher, + stopNotificationWatcher, + openNotificationPanel, + closeNotificationPanel, +*/ + +/* + * Add a cached alert message to sesion storage + */ +function addCachedAlert(message, options={}) { + + var alerts = sessionStorage.getItem('inventree-alerts'); + + if (alerts) { + alerts = JSON.parse(alerts); + } else { + alerts = []; + } + + alerts.push({ + message: message, + style: options.style || 'success', + icon: options.icon, + }); + + sessionStorage.setItem('inventree-alerts', JSON.stringify(alerts)); +} + + +/* + * Remove all cached alert messages + */ +function clearCachedAlerts() { + sessionStorage.removeItem('inventree-alerts'); +} + + +/* + * Display an alert, or cache to display on reload + */ +function showAlertOrCache(message, cache, options={}) { + + if (cache) { + addCachedAlert(message, options); + } else { + showMessage(message, options); + } +} + + +/* + * Display cached alert messages when loading a page + */ +function showCachedAlerts() { + + var alerts = JSON.parse(sessionStorage.getItem('inventree-alerts')) || []; + + alerts.forEach(function(alert) { + showMessage( + alert.message, + { + style: alert.style || 'success', + icon: alert.icon, + } + ); + }); + + clearCachedAlerts(); +} + + +/* + * Display an alert message at the top of the screen. + * The message will contain a "close" button, + * and also dismiss automatically after a certain amount of time. + * + * arguments: + * - message: Text / HTML content to display + * + * options: + * - style: alert style e.g. 'success' / 'warning' + * - timeout: Time (in milliseconds) after which the message will be dismissed + */ +function showMessage(message, options={}) { + + var style = options.style || 'info'; + + var timeout = options.timeout || 5000; + + var target = options.target || $('#alerts'); + + var details = ''; + + if (options.details) { + details = `

${options.details}

`; + } + + // Hacky function to get the next available ID + var id = 1; + + while ($(`#alert-${id}`).exists()) { + id++; + } + + var icon = ''; + + if (options.icon) { + icon = ``; + } + + // Construct the alert + var html = ` + + `; + + target.append(html); + + // Remove the alert automatically after a specified period of time + $(`#alert-${id}`).delay(timeout).slideUp(200, function() { + $(this).alert(close); + }); +} + +var notificationWatcher = null; // reference for the notificationWatcher +/** + * start the regular notification checks + **/ +function startNotificationWatcher() { + notificationCheck(force=true); + notificationWatcher = setInterval(notificationCheck, 1000); +} + +/** + * stop the regular notification checks + **/ +function stopNotificationWatcher() { + clearInterval(notificationWatcher); +} + + +var notificationUpdateTic = 0; +/** + * The notification checker is initiated when the document is loaded. It checks if there are unread notifications + * if unread messages exist the notification indicator is updated + * + * options: + * - force: set true to force an update now (if you got in focus for example) + **/ +function notificationCheck(force = false) { + notificationUpdateTic = notificationUpdateTic + 1; + + // refresh if forced or + // if in focus and was not refreshed in the last 5 seconds + if (force || (document.hasFocus() && notificationUpdateTic >= 5)) { + notificationUpdateTic = 0; + inventreeGet( + '/api/notifications/', + { + read: false, + }, + { + success: function(response) { + updateNotificationIndicator(response.length); + } + } + ); + } +} + +/** + * handles read / unread buttons and UI rebuilding + * + * arguments: + * - btn: element that got clicked / fired the event -> must contain pk and target as attributes + * + * options: + * - panel_caller: this button was clicked in the notification panel + **/ +function updateNotificationReadState(btn, panel_caller=false) { + var url = `/api/notifications/${btn.attr('pk')}/${btn.attr('target')}/`; + + inventreePut(url, {}, { + method: 'POST', + success: function() { + // update the notification tables if they were declared + if (window.updateNotifications) { + window.updateNotifications(); + } + + // update current notification count + var count = parseInt($('#notification-counter').html()); + if (btn.attr('target') == 'read') { + count = count - 1; + } else { + count = count + 1; + } + // update notification indicator now + updateNotificationIndicator(count); + + // remove notification if called from notification panel + if (panel_caller) { + btn.parent().parent().remove(); + } + } + }); +}; + +/** + * Returns the html for a read / unread button + * + * arguments: + * - pk: primary key of the notification + * - state: current state of the notification (read / unread) -> just pass what you were handed by the api + * - small: should the button be small + **/ +function getReadEditButton(pk, state, small=false) { + if (state) { + var bReadText = '{% trans "Mark as unread" %}'; + var bReadIcon = 'fas fa-bookmark icon-red'; + var bReadTarget = 'unread'; + } else { + var bReadText = '{% trans "Mark as read" %}'; + var bReadIcon = 'far fa-bookmark icon-green'; + var bReadTarget = 'read'; + } + + var style = (small) ? 'btn-sm ' : ''; + return ``; +} + +/** + * fills the notification panel when opened + **/ +function openNotificationPanel() { + var html = ''; + var center_ref = '#notification-center'; + + inventreeGet( + '/api/notifications/', + { + read: false, + }, + { + success: function(response) { + if (response.length == 0) { + html = `

{% trans "No unread notifications" %}

`; + } else { + // build up items + response.forEach(function(item, index) { + html += '
  • '; + // d-flex justify-content-between align-items-start + html += '
    '; + html += `${item.category}${item.name}`; + html += '
    '; + if (item.target) { + var link_text = `${item.target.model}: ${item.target.name}`; + if (item.target.link) { + link_text = `${link_text}`; + } + html += link_text; + } + html += '
    '; + html += `${item.age_human}`; + html += getReadEditButton(item.pk, item.read, true); + html += '
  • '; + }); + + // package up + html = `
      ${html}
    `; + } + + // set html + $(center_ref).html(html); + } + } + ); + + $(center_ref).on('click', '.notification-read', function() { + updateNotificationReadState($(this), true); + }); +} + +/** + * clears the notification panel when closed + **/ +function closeNotificationPanel() { + $('#notification-center').html(`

    {% trans "Notifications will load here" %}

    `); +} + +/** + * updates the notification counter + **/ +function updateNotificationIndicator(count) { + // reset update Ticker -> safe some API bandwidth + notificationUpdateTic = 0; + + if (count == 0) { + $('#notification-alert').addClass('d-none'); + } else { + $('#notification-alert').removeClass('d-none'); + } + $('#notification-counter').html(count); +} diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html index e898b5fafa..126376a7dc 100644 --- a/InvenTree/templates/navbar.html +++ b/InvenTree/templates/navbar.html @@ -96,7 +96,18 @@ {% endif %} - + +