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