diff --git a/InvenTree/common/admin.py b/InvenTree/common/admin.py index c5950a0c0a..627dd0be83 100644 --- a/InvenTree/common/admin.py +++ b/InvenTree/common/admin.py @@ -43,6 +43,16 @@ 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.NotificationEntry, NotificationEntryAdmin) +admin.site.register(common.models.NotificationMessage, NotificationMessageAdmin) diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index 4ec6bf9441..df9cb6161c 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -130,6 +130,57 @@ class UserSettingsDetail(generics.RetrieveUpdateAPIView): ] +class NotificationList(generics.ListAPIView): + queryset = common.models.NotificationMessage.objects.all() + serializer_class = common.serializers.NotificationMessageSerializer + + filter_backends = [ + DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter, + ] + + ordering_fields = [ + #'age', # TODO enable ordering by age + 'category', + 'name', + ] + + search_fields = [ + 'name', + 'message', + ] + + 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.RetrieveDestroyAPIView): + """ + 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, + ] + + common_api_urls = [ # User settings @@ -148,6 +199,12 @@ common_api_urls = [ # Global Settings List url(r'^.*$', GlobalSettingsList.as_view(), name='api-global-setting-list'), - ])) + ])), + + # Notifications + url(r'^notifications/', include([ + url(r'^(?P\d+)/', NotificationDetail.as_view(), name='api-notifications-detail'), + url(r'^.*$', NotificationList.as_view(), name='api-notifications-list'), + ])), ] diff --git a/InvenTree/common/migrations/0013_notificationmessage.py b/InvenTree/common/migrations/0013_notificationmessage.py new file mode 100644 index 0000000000..eee0584848 --- /dev/null +++ b/InvenTree/common/migrations/0013_notificationmessage.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.5 on 2021-11-27 14:51 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0002_remove_content_type_name'), + ('common', '0012_notificationentry'), + ] + + 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=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 4028d352a0..96f3437ae6 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -13,8 +13,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 @@ -1419,3 +1424,89 @@ 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'), + ) + + 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=True, + null=False, + ) + + 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/serializers.py b/InvenTree/common/serializers.py index 60eb609dc1..958802755e 100644 --- a/InvenTree/common/serializers.py +++ b/InvenTree/common/serializers.py @@ -9,7 +9,7 @@ from InvenTree.serializers import InvenTreeModelSerializer from rest_framework import serializers -from common.models import InvenTreeSetting, InvenTreeUserSetting +from common.models import InvenTreeSetting, InvenTreeUserSetting, NotificationMessage class SettingsSerializer(InvenTreeModelSerializer): @@ -95,3 +95,39 @@ class UserSettingsSerializer(SettingsSerializer): 'type', 'choices', ] + + +class NotificationMessageSerializer(SettingsSerializer): + """ + Serializer for the InvenTreeUserSetting model + """ + + #content_object = serializers.PrimaryKeyRelatedField(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() + + age_human = serializers.CharField() + + class Meta: + model = NotificationMessage + fields = [ + 'pk', + #'content_object', + 'user', + 'category', + 'name', + 'message', + 'creation', + 'age', + 'age_human', + ] diff --git a/InvenTree/templates/InvenTree/notifications/notifications.html b/InvenTree/templates/InvenTree/notifications/notifications.html index d10c1dcf7b..e3d30117c6 100644 --- a/InvenTree/templates/InvenTree/notifications/notifications.html +++ b/InvenTree/templates/InvenTree/notifications/notifications.html @@ -22,52 +22,72 @@ {% block js_ready %} {{ block.super }} -$("#inbox-table").inventreeTable({ - url: "{% url 'api-part-parameter-template-list' %}", - queryParams: { - ordering: 'name', - }, - formatNoMatches: function() { return '{% trans "No part parameter templates found" %}'; }, - columns: [ - { - field: 'pk', - title: '{% trans "ID" %}', - visible: false, - switchable: false, - }, - { - field: 'name', - title: '{% trans "Name" %}', - sortable: 'true', - }, - { - field: 'units', - title: '{% trans "Units" %}', - sortable: 'true', - }, - { - formatter: function(value, row, index, field) { - var bEdit = ""; - var bDel = ""; +function loadNotificationTable(table, options={}) { - var html = "
" + bEdit + bDel + "
"; - - return html; + $(table).inventreeTable({ + url: options.url, + name: options.name, + groupBy: false, + search: true, + queryParams: { + ordering: 'age', + }, + paginationVAlign: 'bottom', + original: options.params, + 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: 'name', + title: '{% trans "Name" %}', + }, + { + field: 'message', + title: '{% trans "Message" %}', + }, + { + formatter: function(value, row, index, field) { + var bRead = ""; + var html = "
" + bRead + "
"; + return html; + } } - } - ] -}); - -$("#inbox-table").on('click', '.template-edit', function() { - var button = $(this); - - var url = "/part/parameter/template/" + button.attr('pk') + "/edit/"; - - launchModalForm(url, { - success: function() { - $("#inbox-table").bootstrapTable('refresh'); - } + ] }); + + $(table).on('click', '.notification-read', function() { + var url = "/notifications/" + $(this).attr('pk') + "/"; + + inventreeDelete(url, { + success: function() { + $(table).bootstrapTable('refresh'); + } + }); + }); +} + +loadNotificationTable("#inbox-table", { + name: 'inbox', + url: '{% url 'api-notifications-list' %}', + no_matches: function() { return '{% trans "No unread notifications found" %}'; }, }); $("#inbox-refresh").on('click', function() { @@ -75,40 +95,10 @@ $("#inbox-refresh").on('click', function() { }); -$("#history-table").inventreeTable({ - url: "{% url 'api-part-parameter-template-list' %}", - queryParams: { - ordering: 'name', - }, - formatNoMatches: function() { return '{% trans "No part parameter templates found" %}'; }, - columns: [ - { - field: 'pk', - title: '{% trans "ID" %}', - visible: false, - switchable: false, - }, - { - field: 'name', - title: '{% trans "Name" %}', - sortable: 'true', - }, - { - field: 'units', - title: '{% trans "Units" %}', - sortable: 'true', - }, - { - formatter: function(value, row, index, field) { - var bEdit = ""; - var bDel = ""; - - var html = "
" + bEdit + bDel + "
"; - - return html; - } - } - ] +loadNotificationTable("#history-table", { + name: 'history', + url: '{% url 'api-notifications-list' %}', + no_matches: function() { return '{% trans "No notification history found" %}'; }, }); $("#history-refresh").on('click', function() {