mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'inventree:master' into matmair/issue2694
This commit is contained in:
commit
ce68ee3781
@ -180,7 +180,7 @@ class InvenTreeConfig(AppConfig):
|
|||||||
|
|
||||||
# not all needed variables set
|
# not all needed variables set
|
||||||
if set_variables < 3:
|
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
|
settings.USER_ADDED = True
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -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()
|
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):
|
def inheritors(cls):
|
||||||
"""
|
"""
|
||||||
Return all classes that are subclasses from the supplied cls
|
Return all classes that are subclasses from the supplied cls
|
||||||
|
@ -900,3 +900,4 @@ if DEBUG or TESTING:
|
|||||||
PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING) # are plugins beeing tested?
|
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_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_RETRY = get_setting('PLUGIN_RETRY', 5) # how often should plugin loading be tried?
|
||||||
|
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
/* globals
|
/* globals
|
||||||
ClipboardJS,
|
ClipboardJS,
|
||||||
inventreeFormDataUpload,
|
inventreeFormDataUpload,
|
||||||
|
inventreeGet,
|
||||||
launchModalForm,
|
launchModalForm,
|
||||||
user_settings,
|
user_settings,
|
||||||
*/
|
*/
|
||||||
@ -216,8 +217,25 @@ function inventreeDocReady() {
|
|||||||
|
|
||||||
// Display any cached alert messages
|
// Display any cached alert messages
|
||||||
showCachedAlerts();
|
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) {
|
function isFileTransfer(transfer) {
|
||||||
/* Determine if a transfer (e.g. drag-and-drop) is a file transfer
|
/* Determine if a transfer (e.g. drag-and-drop) is a file transfer
|
||||||
*/
|
*/
|
||||||
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
@ -72,7 +72,7 @@ class ViewTests(TestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Change this number as more javascript files are added to the index page
|
# 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()
|
content = self.get_index_page()
|
||||||
|
|
||||||
|
@ -20,7 +20,7 @@ from order.urls import order_urls
|
|||||||
from plugin.urls import get_plugin_urls
|
from plugin.urls import get_plugin_urls
|
||||||
|
|
||||||
from barcodes.api import barcode_api_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 part.api import part_api_urls, bom_api_urls
|
||||||
from company.api import company_api_urls
|
from company.api import company_api_urls
|
||||||
from stock.api import stock_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 CurrencyRefreshView
|
||||||
from .views import AppearanceSelectView, SettingCategorySelectView
|
from .views import AppearanceSelectView, SettingCategorySelectView
|
||||||
from .views import DynamicJsView
|
from .views import DynamicJsView
|
||||||
|
from .views import NotificationsView
|
||||||
|
|
||||||
from .api import InfoView, NotFoundView
|
from .api import InfoView, NotFoundView
|
||||||
from .api import ActionPluginView
|
from .api import ActionPluginView
|
||||||
@ -60,7 +61,7 @@ if settings.PLUGINS_ENABLED:
|
|||||||
|
|
||||||
apipatterns += [
|
apipatterns += [
|
||||||
url(r'^barcode/', include(barcode_api_urls)),
|
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'^part/', include(part_api_urls)),
|
||||||
url(r'^bom/', include(bom_api_urls)),
|
url(r'^bom/', include(bom_api_urls)),
|
||||||
url(r'^company/', include(company_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'),
|
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
|
# These javascript files are served "dynamically" - i.e. rendered on demand
|
||||||
dynamic_javascript_urls = [
|
dynamic_javascript_urls = [
|
||||||
url(r'^calendar.js', DynamicJsView.as_view(template_name='js/dynamic/calendar.js'), name='calendar.js'),
|
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'^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'^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'^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 = [
|
backendpatterns = [
|
||||||
@ -160,6 +168,8 @@ frontendpatterns = [
|
|||||||
|
|
||||||
url(r'^settings/', include(settings_urls)),
|
url(r'^settings/', include(settings_urls)),
|
||||||
|
|
||||||
|
url(r'^notifications/', include(notifications_urls)),
|
||||||
|
|
||||||
url(r'^edit-user/', EditUserView.as_view(), name='edit-user'),
|
url(r'^edit-user/', EditUserView.as_view(), name='edit-user'),
|
||||||
url(r'^set-password/', SetPasswordView.as_view(), name='set-password'),
|
url(r'^set-password/', SetPasswordView.as_view(), name='set-password'),
|
||||||
|
|
||||||
|
@ -904,3 +904,10 @@ class DatabaseStatsView(AjaxView):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationsView(TemplateView):
|
||||||
|
""" View for showing notifications
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_name = "InvenTree/notifications/notifications.html"
|
||||||
|
@ -48,8 +48,18 @@ class NotificationEntryAdmin(admin.ModelAdmin):
|
|||||||
list_display = ('key', 'uid', 'updated', )
|
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.InvenTreeSetting, SettingsAdmin)
|
||||||
admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin)
|
admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin)
|
||||||
admin.site.register(common.models.WebhookEndpoint, WebhookAdmin)
|
admin.site.register(common.models.WebhookEndpoint, WebhookAdmin)
|
||||||
admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin)
|
admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin)
|
||||||
admin.site.register(common.models.NotificationEntry, NotificationEntryAdmin)
|
admin.site.register(common.models.NotificationEntry, NotificationEntryAdmin)
|
||||||
|
admin.site.register(common.models.NotificationMessage, NotificationMessageAdmin)
|
||||||
|
@ -14,9 +14,11 @@ from django.views.decorators.csrf import csrf_exempt
|
|||||||
from django.conf.urls import url, include
|
from django.conf.urls import url, include
|
||||||
|
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
from rest_framework.exceptions import NotAcceptable, NotFound
|
from rest_framework.exceptions import NotAcceptable, NotFound
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from rest_framework import filters, generics, permissions
|
from rest_framework import filters, generics, permissions
|
||||||
|
from rest_framework import serializers
|
||||||
from django_q.tasks import async_task
|
from django_q.tasks import async_task
|
||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
@ -217,9 +219,122 @@ class UserSettingsDetail(generics.RetrieveUpdateAPIView):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
common_api_urls = [
|
class NotificationList(generics.ListAPIView):
|
||||||
path('webhook/<slug:endpoint>/', WebhookView.as_view(), name='api-webhook'),
|
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
|
# User settings
|
||||||
url(r'^user/', include([
|
url(r'^user/', include([
|
||||||
# User Settings Detail
|
# User Settings Detail
|
||||||
@ -237,5 +352,25 @@ common_api_urls = [
|
|||||||
# Global Settings List
|
# Global Settings List
|
||||||
url(r'^.*$', GlobalSettingsList.as_view(), name='api-global-setting-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'),
|
||||||
|
])),
|
||||||
|
|
||||||
]
|
]
|
||||||
|
33
InvenTree/common/migrations/0014_notificationmessage.py
Normal file
33
InvenTree/common/migrations/0014_notificationmessage.py
Normal 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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
@ -19,8 +19,13 @@ from datetime import datetime, timedelta
|
|||||||
|
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.contrib.auth.models import User, Group
|
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.db.utils import IntegrityError, OperationalError
|
||||||
from django.conf import settings
|
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.settings import CURRENCY_CHOICES
|
||||||
from djmoney.contrib.exchange.models import convert_money
|
from djmoney.contrib.exchange.models import convert_money
|
||||||
@ -1002,6 +1007,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'validator': bool,
|
'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
|
# Settings for plugin mixin features
|
||||||
'ENABLE_PLUGINS_URL': {
|
'ENABLE_PLUGINS_URL': {
|
||||||
'name': _('Enable URL integration'),
|
'name': _('Enable URL integration'),
|
||||||
@ -1699,3 +1711,90 @@ class NotificationEntry(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
entry.save()
|
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)
|
||||||
|
228
InvenTree/common/notifications.py
Normal file
228
InvenTree/common/notifications.py
Normal 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")
|
@ -6,10 +6,11 @@ JSON serializers for common components
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from InvenTree.serializers import InvenTreeModelSerializer
|
from InvenTree.serializers import InvenTreeModelSerializer
|
||||||
|
from InvenTree.helpers import get_objectreference
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from common.models import InvenTreeSetting, InvenTreeUserSetting
|
from common.models import InvenTreeSetting, InvenTreeUserSetting, NotificationMessage
|
||||||
|
|
||||||
|
|
||||||
class SettingsSerializer(InvenTreeModelSerializer):
|
class SettingsSerializer(InvenTreeModelSerializer):
|
||||||
@ -95,3 +96,59 @@ class UserSettingsSerializer(SettingsSerializer):
|
|||||||
'type',
|
'type',
|
||||||
'choices',
|
'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
|
||||||
|
112
InvenTree/common/test_notifications.py
Normal file
112
InvenTree/common/test_notifications.py
Normal 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
|
@ -2,18 +2,13 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
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.helpers
|
||||||
import InvenTree.tasks
|
import InvenTree.tasks
|
||||||
from InvenTree.ready import isImportingData
|
import common.notifications
|
||||||
|
|
||||||
import part.models
|
import part.models
|
||||||
|
|
||||||
@ -21,48 +16,26 @@ logger = logging.getLogger("inventree")
|
|||||||
|
|
||||||
|
|
||||||
def notify_low_stock(part: part.models.Part):
|
def notify_low_stock(part: part.models.Part):
|
||||||
"""
|
name = _("Low stock notification")
|
||||||
Notify users who have starred a part when its stock quantity falls below the minimum threshold
|
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
|
common.notifications.trigger_notifaction(
|
||||||
if isImportingData():
|
part,
|
||||||
return
|
'part.notify_low_stock',
|
||||||
|
target_fnc=part.get_subscribers,
|
||||||
# Check if we have notified recently...
|
context=context,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 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):
|
def notify_low_stock_if_required(part: part.models.Part):
|
||||||
"""
|
"""
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
from allauth.account.models import EmailAddress
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
@ -17,7 +18,7 @@ from .templatetags import inventree_extras
|
|||||||
|
|
||||||
import part.settings
|
import part.settings
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting, NotificationEntry, NotificationMessage
|
||||||
|
|
||||||
|
|
||||||
class TemplateTagTest(TestCase):
|
class TemplateTagTest(TestCase):
|
||||||
@ -464,3 +465,64 @@ class PartSubscriptionTests(TestCase):
|
|||||||
|
|
||||||
# Check part
|
# Check part
|
||||||
self.assertTrue(self.part.is_starred_by(self.user))
|
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)
|
||||||
|
@ -30,6 +30,15 @@ class PluginAppConfig(AppConfig):
|
|||||||
|
|
||||||
if not registry.is_loading:
|
if not registry.is_loading:
|
||||||
# this is the first startup
|
# 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.collect_plugins()
|
||||||
registry.load_plugins()
|
registry.load_plugins()
|
||||||
|
|
||||||
|
@ -11,6 +11,7 @@ import pkgutil
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import AppRegistryNotReady
|
from django.core.exceptions import AppRegistryNotReady
|
||||||
|
from django.db.utils import IntegrityError
|
||||||
|
|
||||||
|
|
||||||
# region logging / errors
|
# 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)
|
log_error({package_name: str(error)}, **log_kwargs)
|
||||||
|
|
||||||
if do_raise:
|
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))
|
raise IntegrationPluginError(package_name, str(error))
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
|
@ -8,6 +8,8 @@ Registry for loading and managing multiple plugins at run-time
|
|||||||
import importlib
|
import importlib
|
||||||
import pathlib
|
import pathlib
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
from typing import OrderedDict
|
from typing import OrderedDict
|
||||||
from importlib import reload
|
from importlib import reload
|
||||||
|
|
||||||
@ -211,6 +213,27 @@ class PluginsRegistry:
|
|||||||
# Log collected plugins
|
# Log collected plugins
|
||||||
logger.info(f'Collected {len(self.plugin_modules)} plugins!')
|
logger.info(f'Collected {len(self.plugin_modules)} plugins!')
|
||||||
logger.info(", ".join([a.__module__ for a in self.plugin_modules]))
|
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
|
# endregion
|
||||||
|
|
||||||
# region registry functions
|
# region registry functions
|
||||||
|
@ -16,7 +16,6 @@ from django.utils import timezone
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from plugin.models import PluginConfig, PluginSetting
|
from plugin.models import PluginConfig, PluginSetting
|
||||||
from InvenTree.config import get_plugin_file
|
|
||||||
from common.serializers import SettingsSerializer
|
from common.serializers import SettingsSerializer
|
||||||
|
|
||||||
|
|
||||||
@ -123,7 +122,7 @@ class PluginConfigInstallSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
# save plugin to plugin_file if installed successfull
|
# save plugin to plugin_file if installed successfull
|
||||||
if success:
|
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')
|
plugin_file.write(f'{" ".join(install_name)} # Installed {timezone.now()} by {str(self.context["request"].user)}\n')
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
@ -77,9 +77,11 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
|
|||||||
fixtures = PluginConfig.objects.all()
|
fixtures = PluginConfig.objects.all()
|
||||||
|
|
||||||
# check if plugins were registered -> in some test setups the startup has no db access
|
# 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:
|
if not fixtures:
|
||||||
registry.reload_plugins()
|
registry.reload_plugins()
|
||||||
fixtures = PluginConfig.objects.all()
|
fixtures = PluginConfig.objects.all()
|
||||||
|
print(f'Reloaded plugins - now {len(fixtures)} entries found')
|
||||||
|
|
||||||
print([str(a) for a in fixtures])
|
print([str(a) for a in fixtures])
|
||||||
fixtures = fixtures[0:1]
|
fixtures = fixtures[0:1]
|
||||||
|
@ -49,7 +49,7 @@
|
|||||||
{% include "filter_list.html" with id="buildorderallocation" %}
|
{% include "filter_list.html" with id="buildorderallocation" %}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
25
InvenTree/templates/InvenTree/notifications/history.html
Normal file
25
InvenTree/templates/InvenTree/notifications/history.html
Normal 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 %}
|
28
InvenTree/templates/InvenTree/notifications/inbox.html
Normal file
28
InvenTree/templates/InvenTree/notifications/inbox.html
Normal 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 %}
|
154
InvenTree/templates/InvenTree/notifications/notifications.html
Normal file
154
InvenTree/templates/InvenTree/notifications/notifications.html
Normal 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 %}
|
11
InvenTree/templates/InvenTree/notifications/sidebar.html
Normal file
11
InvenTree/templates/InvenTree/notifications/sidebar.html
Normal 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" %}
|
@ -91,7 +91,7 @@
|
|||||||
|
|
||||||
<!-- general InvenTree -->
|
<!-- general InvenTree -->
|
||||||
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
|
<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 -->
|
<!-- fontawesome -->
|
||||||
<script type='text/javascript' src="{% static 'fontawesome/js/solid.js' %}"></script>
|
<script type='text/javascript' src="{% static 'fontawesome/js/solid.js' %}"></script>
|
||||||
|
@ -128,6 +128,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% include 'modals.html' %}
|
{% include 'modals.html' %}
|
||||||
{% include 'about.html' %}
|
{% include 'about.html' %}
|
||||||
|
{% include "notifications.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
@ -161,7 +162,6 @@
|
|||||||
<script type='text/javascript' src="{% static 'script/randomColor.min.js' %}"></script>
|
<script type='text/javascript' src="{% static 'script/randomColor.min.js' %}"></script>
|
||||||
|
|
||||||
<!-- general InvenTree -->
|
<!-- general InvenTree -->
|
||||||
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
|
|
||||||
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
|
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
|
||||||
|
|
||||||
<!-- dynamic javascript templates -->
|
<!-- dynamic javascript templates -->
|
||||||
@ -189,8 +189,10 @@
|
|||||||
<script type='text/javascript' src="{% i18n_static 'plugin.js' %}"></script>
|
<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 'tables.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% i18n_static 'table_filters.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/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/brands.min.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% static 'fontawesome/js/fontawesome.min.js' %}"></script>
|
<script type='text/javascript' src="{% static 'fontawesome/js/fontawesome.min.js' %}"></script>
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% blocktrans with part=part.name %} The available stock for {{ part }} has fallen below the configured minimum level{% endblocktrans %}
|
{{ message }}
|
||||||
{% if link %}
|
{% if link %}
|
||||||
<p>{% trans "Click on the following link to view this part" %}: <a href="{{ link }}">{{ link }}</a></p>
|
<p>{% trans "Click on the following link to view this part" %}: <a href="{{ link }}">{{ link }}</a></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
313
InvenTree/templates/js/translated/notification.js
Normal file
313
InvenTree/templates/js/translated/notification.js
Normal 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);
|
||||||
|
}
|
@ -96,7 +96,18 @@
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% 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">
|
<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>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
|
14
InvenTree/templates/notifications.html
Normal file
14
InvenTree/templates/notifications.html
Normal 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>
|
@ -77,7 +77,7 @@
|
|||||||
|
|
||||||
<!-- general InvenTree -->
|
<!-- general InvenTree -->
|
||||||
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
|
<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 %}
|
{% block body_scripts_inventree %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@ -161,6 +161,7 @@ class RuleSet(models.Model):
|
|||||||
'common_webhookendpoint',
|
'common_webhookendpoint',
|
||||||
'common_webhookmessage',
|
'common_webhookmessage',
|
||||||
'common_notificationentry',
|
'common_notificationentry',
|
||||||
|
'common_notificationmessage',
|
||||||
'company_contact',
|
'company_contact',
|
||||||
'users_owner',
|
'users_owner',
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user