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
|
||||
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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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?
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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
|
||||
N_SCRIPT_FILES = 36
|
||||
N_SCRIPT_FILES = 37
|
||||
|
||||
content = self.get_index_page()
|
||||
|
||||
|
@ -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'),
|
||||
|
||||
|
@ -904,3 +904,10 @@ class DatabaseStatsView(AjaxView):
|
||||
"""
|
||||
|
||||
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', )
|
||||
|
||||
|
||||
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)
|
||||
|
@ -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'),
|
||||
])),
|
||||
|
||||
]
|
||||
|
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.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)
|
||||
|
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 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
|
||||
|
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
|
||||
|
||||
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):
|
||||
"""
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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]
|
||||
|
@ -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 %}
|
||||
|
||||
|
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 -->
|
||||
<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>
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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 %}
|
||||
|
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>
|
||||
</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>
|
||||
|
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 -->
|
||||
<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 %}
|
||||
|
||||
|
@ -161,6 +161,7 @@ class RuleSet(models.Model):
|
||||
'common_webhookendpoint',
|
||||
'common_webhookmessage',
|
||||
'common_notificationentry',
|
||||
'common_notificationmessage',
|
||||
'company_contact',
|
||||
'users_owner',
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user