Merge branch 'inventree:master' into matmair/issue2694

This commit is contained in:
Matthias Mair 2022-03-20 22:15:27 +01:00 committed by GitHub
commit ce68ee3781
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1442 additions and 182 deletions

View File

@ -180,7 +180,7 @@ class InvenTreeConfig(AppConfig):
# not all needed variables set
if set_variables < 3:
logger.warn('Not all required settings for adding a user on startup are present:\nINVENTREE_SET_USER, INVENTREE_SET_EMAIL, INVENTREE_SET_PASSWORD')
logger.warn('Not all required settings for adding a user on startup are present:\nINVENTREE_ADMIN_USER, INVENTREE_ADMIN_EMAIL, INVENTREE_ADMIN_PASSWORD')
settings.USER_ADDED = True
return

View File

@ -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

View File

@ -900,3 +900,4 @@ if DEBUG or TESTING:
PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING) # are plugins beeing tested?
PLUGIN_TESTING_SETUP = get_setting('PLUGIN_TESTING_SETUP', False) # load plugins from setup hooks in testing?
PLUGIN_RETRY = get_setting('PLUGIN_RETRY', 5) # how often should plugin loading be tried?
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?

View File

@ -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
*/

View File

@ -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 = `<p><small>${options.details}</p></small>`;
}
// Hacky function to get the next available ID
var id = 1;
while ($(`#alert-${id}`).exists()) {
id++;
}
var icon = '';
if (options.icon) {
icon = `<span class='${options.icon}'></span>`;
}
// Construct the alert
var html = `
<div id='alert-${id}' class='alert alert-${style} alert-dismissible fade show' role='alert'>
${icon}
<b>${message}</b>
${details}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
target.append(html);
// Remove the alert automatically after a specified period of time
$(`#alert-${id}`).delay(timeout).slideUp(200, function() {
$(this).alert(close);
});
}

View File

@ -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()

View File

@ -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'),

View File

@ -904,3 +904,10 @@ class DatabaseStatsView(AjaxView):
"""
return ctx
class NotificationsView(TemplateView):
""" View for showing notifications
"""
template_name = "InvenTree/notifications/notifications.html"

View File

@ -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)

View File

@ -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/<slug:endpoint>/', 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/<slug:endpoint>/', WebhookView.as_view(), name='api-webhook'),
# Notifications
url(r'^notifications/', include([
# Individual purchase order detail URLs
url(r'^(?P<pk>\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'),
])),
]

View File

@ -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')),
],
),
]

View File

@ -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
@ -1002,6 +1007,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool,
},
'PLUGIN_ON_STARTUP': {
'name': _('Check plugins on startup'),
'description': _('Check that all plugins are installed on startup - enable in container enviroments'),
'default': False,
'validator': bool,
'requires_restart': True,
},
# Settings for plugin mixin features
'ENABLE_PLUGINS_URL': {
'name': _('Enable URL integration'),
@ -1699,3 +1711,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)

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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):
"""

View File

@ -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)

View File

@ -30,6 +30,15 @@ class PluginAppConfig(AppConfig):
if not registry.is_loading:
# this is the first startup
try:
from common.models import InvenTreeSetting
if InvenTreeSetting.get_setting('PLUGIN_ON_STARTUP', create=False):
# make sure all plugins are installed
registry.install_plugin_file()
except:
pass
# get plugins and init them
registry.collect_plugins()
registry.load_plugins()

View File

@ -11,6 +11,7 @@ import pkgutil
from django.conf import settings
from django.core.exceptions import AppRegistryNotReady
from django.db.utils import IntegrityError
# region logging / errors
@ -85,6 +86,9 @@ def handle_error(error, do_raise: bool = True, do_log: bool = True, log_name: st
log_error({package_name: str(error)}, **log_kwargs)
if do_raise:
# do a straight raise if we are playing with enviroment variables at execution time, ignore the broken sample
if settings.TESTING_ENV and package_name != 'integration.broken_sample' and isinstance(error, IntegrityError):
raise error
raise IntegrationPluginError(package_name, str(error))
# endregion

View File

@ -8,6 +8,8 @@ Registry for loading and managing multiple plugins at run-time
import importlib
import pathlib
import logging
import os
import subprocess
from typing import OrderedDict
from importlib import reload
@ -211,6 +213,27 @@ class PluginsRegistry:
# Log collected plugins
logger.info(f'Collected {len(self.plugin_modules)} plugins!')
logger.info(", ".join([a.__module__ for a in self.plugin_modules]))
def install_plugin_file(self):
"""
Make sure all plugins are installed in the current enviroment
"""
if settings.PLUGIN_FILE_CHECKED:
logger.info('Plugin file was already checked')
return
try:
output = str(subprocess.check_output(['pip', 'install', '-U', '-r', settings.PLUGIN_FILE], cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')
except subprocess.CalledProcessError as error: # pragma: no cover
logger.error(f'Ran into error while trying to install plugins!\n{str(error)}')
return False
logger.info(f'plugin requirements were run\n{output}')
# do not run again
settings.PLUGIN_FILE_CHECKED = True
# endregion
# region registry functions

View File

@ -16,7 +16,6 @@ from django.utils import timezone
from rest_framework import serializers
from plugin.models import PluginConfig, PluginSetting
from InvenTree.config import get_plugin_file
from common.serializers import SettingsSerializer
@ -123,7 +122,7 @@ class PluginConfigInstallSerializer(serializers.Serializer):
# save plugin to plugin_file if installed successfull
if success:
with open(get_plugin_file(), "a") as plugin_file:
with open(settings.PLUGIN_FILE, "a") as plugin_file:
plugin_file.write(f'{" ".join(install_name)} # Installed {timezone.now()} by {str(self.context["request"].user)}\n')
return ret

View File

@ -77,9 +77,11 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
fixtures = PluginConfig.objects.all()
# check if plugins were registered -> in some test setups the startup has no db access
print(f'[PLUGIN-TEST] currently {len(fixtures)} plugin entries found')
if not fixtures:
registry.reload_plugins()
fixtures = PluginConfig.objects.all()
print(f'Reloaded plugins - now {len(fixtures)} entries found')
print([str(a) for a in fixtures])
fixtures = fixtures[0:1]

View File

@ -49,7 +49,7 @@
{% include "filter_list.html" with id="buildorderallocation" %}
</div>
</div>
<table class='table table-striped table-condensed' data-toolbar='#build-order-allocation-toolbar' id='build-order-allocation-table'></table>
<table class='table table-striped table-condensed' data-toolbar='#build-order-allocations-toolbar' id='build-order-allocation-table'></table>
</div>
{% endif %}

View File

@ -0,0 +1,25 @@
{% extends "panel.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block label %}history{% endblock %}
{% block heading %}
{% trans "Notification History" %}
{% endblock %}
{% block actions %}
<div class='btn btn-secondary' type='button' id='history-refresh' title='{% trans "Refresh Notification History" %}'>
<span class='fa fa-sync'></span> {% trans "Refresh Notification History" %}
</div>
{% endblock %}
{% block content %}
<div class='row'>
<table class='table table-striped table-condensed' id='history-table'>
</table>
</div>
{% endblock %}

View File

@ -0,0 +1,28 @@
{% extends "panel.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block label %}inbox{% endblock %}
{% block heading %}
{% trans "Pending Notifications" %}
{% endblock %}
{% block actions %}
<div class='btn btn-secondary' type='button' id='mark-all' title='{% trans "Mark all as read" %}'>
<span class='fa fa-bookmark'></span> {% trans "Mark all as read" %}
</div>
<div class='btn btn-secondary' type='button' id='inbox-refresh' title='{% trans "Refresh Pending Notifications" %}'>
<span class='fa fa-sync'></span> {% trans "Refresh Pending Notifications" %}
</div>
{% endblock %}
{% block content %}
<div class='row'>
<table class='table table-striped table-condensed' id='inbox-table'>
</table>
</div>
{% endblock %}

View File

@ -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 = `<a href='${value.link}'>${html}</a>`;}
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 = "<button title='{% trans "Delete Notification" %}' class='notification-delete btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>";
} else {
var bDel = '';
}
var html = "<div class='btn-group float-right' role='group'>" + bRead + bDel + "</div>";
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 %}

View File

@ -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" %}

View File

@ -91,7 +91,7 @@
<!-- general InvenTree -->
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'notification.js' %}"></script>
<!-- fontawesome -->
<script type='text/javascript' src="{% static 'fontawesome/js/solid.js' %}"></script>

View File

@ -128,6 +128,7 @@
</div>
{% include 'modals.html' %}
{% include 'about.html' %}
{% include "notifications.html" %}
</div>
<!-- Scripts -->
@ -161,7 +162,6 @@
<script type='text/javascript' src="{% static 'script/randomColor.min.js' %}"></script>
<!-- general InvenTree -->
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
<!-- dynamic javascript templates -->
@ -189,8 +189,10 @@
<script type='text/javascript' src="{% i18n_static 'plugin.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'tables.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'table_filters.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'notification.js' %}"></script>
<script type='text/javascript' src="{% static 'fontawesome/js/solid.min.js' %}"></script>
<script type='text/javascript' src="{% static 'fontawesome/js/regular.min.js' %}"></script>
<script type='text/javascript' src="{% static 'fontawesome/js/brands.min.js' %}"></script>
<script type='text/javascript' src="{% static 'fontawesome/js/fontawesome.min.js' %}"></script>

View File

@ -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 %}
<p>{% trans "Click on the following link to view this part" %}: <a href="{{ link }}">{{ link }}</a></p>
{% endif %}

View File

@ -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 = `<p><small>${options.details}</p></small>`;
}
// Hacky function to get the next available ID
var id = 1;
while ($(`#alert-${id}`).exists()) {
id++;
}
var icon = '';
if (options.icon) {
icon = `<span class='${options.icon}'></span>`;
}
// Construct the alert
var html = `
<div id='alert-${id}' class='alert alert-${style} alert-dismissible fade show' role='alert'>
${icon}
<b>${message}</b>
${details}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
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 `<button title='${bReadText}' class='notification-read btn ${style}btn-outline-secondary' type='button' pk='${pk}' target='${bReadTarget}'><span class='${bReadIcon}'></span></button>`;
}
/**
* 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 = `<p class='text-muted'>{% trans "No unread notifications" %}</p>`;
} else {
// build up items
response.forEach(function(item, index) {
html += '<li class="list-group-item">';
// d-flex justify-content-between align-items-start
html += '<div>';
html += `<span class="badge rounded-pill bg-primary">${item.category}</span><span class="ms-2">${item.name}</span>`;
html += '</div>';
if (item.target) {
var link_text = `${item.target.model}: ${item.target.name}`;
if (item.target.link) {
link_text = `<a href='${item.target.link}'>${link_text}</a>`;
}
html += link_text;
}
html += '<div>';
html += `<span class="text-muted">${item.age_human}</span>`;
html += getReadEditButton(item.pk, item.read, true);
html += '</div></li>';
});
// package up
html = `<ul class="list-group">${html}</ul>`;
}
// 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(`<p class='text-muted'>{% trans "Notifications will load here" %}</p>`);
}
/**
* 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);
}

View File

@ -96,7 +96,18 @@
</button>
</li>
{% endif %}
<li class='nav-item' id='navbar-barcode-li'>
<li class='nav-item me-2'>
<button data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" class='btn position-relative' title='{% trans "Show Notifications" %}'>
<span class='fas fa-bell'></span>
<span class="position-absolute top-100 start-100 translate-middle badge rounded-pill bg-danger d-none" id="notification-alert">
<span class="visually-hidden">{% trans "New Notifications" %}</span>
<span id="notification-counter">0</span>
</span>
</button>
</li>
<li class='nav-item me-2'>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-objects" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>

View File

@ -0,0 +1,14 @@
{% load i18n %}
<div class="offcanvas offcanvas-end" tabindex="-1" id="offcanvasRight" data-bs-scroll="true" aria-labelledby="offcanvasRightLabel">
<div class="offcanvas-header">
<h5 id="offcanvasRightLabel">{% trans "Notifications" %}</h5>
<button type="button" class="btn-close text-reset" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<div id="notification-center">
<p class='text-muted'>{% trans "Notifications will load here" %}</p>
</div>
<hr>
<a href="{% url 'notifications' %}">{% trans "Show all notifications and history" %}</a>
</div>
</div>

View File

@ -77,7 +77,7 @@
<!-- general InvenTree -->
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'notification.js' %}"></script>
{% block body_scripts_inventree %}
{% endblock %}

View File

@ -161,6 +161,7 @@ class RuleSet(models.Model):
'common_webhookendpoint',
'common_webhookmessage',
'common_notificationentry',
'common_notificationmessage',
'company_contact',
'users_owner',