From fb771584965f67300dec4300d02c03124451912a Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Thu, 10 Nov 2022 02:20:06 +0100 Subject: [PATCH] Add news reader (#3445) * add model for feed entries * add task to update feed entries * Add API routes * Fix name in model * rename model * fix read endpoint * reduce duplication in NewsFeed API endpoints * reduce duplicated code * add ui elements to index * add missing migrations * add ressource route * add new model to admin * reorder fields * format timestamp * make title linked * reduce migrations to 1 * fix merge * fix js style * add model to ruleset --- InvenTree/InvenTree/settings.py | 2 + InvenTree/InvenTree/urls.py | 1 + InvenTree/common/admin.py | 7 ++ InvenTree/common/api.py | 88 ++++++++++++------- .../common/migrations/0015_newsfeedentry.py | 26 ++++++ InvenTree/common/models.py | 58 ++++++++++++ InvenTree/common/serializers.py | 23 ++++- InvenTree/common/tasks.py | 41 +++++++++ InvenTree/templates/InvenTree/index.html | 11 +++ .../InvenTree/settings/user_homepage.html | 2 + InvenTree/templates/base.html | 1 + InvenTree/templates/js/translated/news.js | 58 ++++++++++++ InvenTree/users/models.py | 1 + requirements.in | 1 + requirements.txt | 4 + 15 files changed, 291 insertions(+), 33 deletions(-) create mode 100644 InvenTree/common/migrations/0015_newsfeedentry.py create mode 100644 InvenTree/templates/js/translated/news.js diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 5e6a782948..9c180a166a 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -26,6 +26,8 @@ from sentry_sdk.integrations.django import DjangoIntegration from . import config from .config import get_boolean_setting, get_custom_file, get_setting +INVENTREE_NEWS_URL = 'https://inventree.org/news/feed.atom' + # Determine if we are running in "test" mode e.g. "manage.py test" TESTING = 'test' in sys.argv diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index a4a64f3ad8..b9b3cec1a2 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -111,6 +111,7 @@ translated_javascript_urls = [ re_path(r'^search.js', DynamicJsView.as_view(template_name='js/translated/search.js'), name='search.js'), re_path(r'^stock.js', DynamicJsView.as_view(template_name='js/translated/stock.js'), name='stock.js'), re_path(r'^plugin.js', DynamicJsView.as_view(template_name='js/translated/plugin.js'), name='plugin.js'), + re_path(r'^news.js', DynamicJsView.as_view(template_name='js/translated/news.js'), name='news.js'), re_path(r'^tables.js', DynamicJsView.as_view(template_name='js/translated/tables.js'), name='tables.js'), re_path(r'^table_filters.js', DynamicJsView.as_view(template_name='js/translated/table_filters.js'), name='table_filters.js'), re_path(r'^notification.js', DynamicJsView.as_view(template_name='js/translated/notification.js'), name='notification.js'), diff --git a/InvenTree/common/admin.py b/InvenTree/common/admin.py index 021704615b..c2c3939b9b 100644 --- a/InvenTree/common/admin.py +++ b/InvenTree/common/admin.py @@ -55,9 +55,16 @@ class NotificationMessageAdmin(admin.ModelAdmin): search_fields = ('name', 'category', 'message', ) +class NewsFeedEntryAdmin(admin.ModelAdmin): + """Admin settings for NewsFeedEntry.""" + + list_display = ('title', 'author', 'published', 'summary', ) + + 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) +admin.site.register(common.models.NewsFeedEntry, NewsFeedEntryAdmin) diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index 3f5a1ad3a4..4b825549a5 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -11,6 +11,7 @@ from django_filters.rest_framework import DjangoFilterBackend from django_q.tasks import async_task from rest_framework import filters, permissions, serializers from rest_framework.exceptions import NotAcceptable, NotFound +from rest_framework.permissions import IsAdminUser from rest_framework.response import Response from rest_framework.views import APIView @@ -255,21 +256,20 @@ class NotificationUserSettingsDetail(RetrieveUpdateAPI): queryset = NotificationUserSetting.objects.all() serializer_class = NotificationUserSettingSerializer - - permission_classes = [ - UserSettingsPermissions, - ] + permission_classes = [UserSettingsPermissions, ] -class NotificationList(BulkDeleteMixin, ListAPI): - """List view for all notifications of the current user.""" - +class NotificationMessageMixin: + """Generic mixin for NotificationMessage.""" queryset = common.models.NotificationMessage.objects.all() serializer_class = common.serializers.NotificationMessageSerializer + permission_classes = [UserSettingsPermissions, ] - permission_classes = [ - permissions.IsAuthenticated, - ] + +class NotificationList(NotificationMessageMixin, BulkDeleteMixin, ListAPI): + """List view for all notifications of the current user.""" + + permission_classes = [permissions.IsAuthenticated, ] filter_backends = [ DjangoFilterBackend, @@ -312,29 +312,16 @@ class NotificationList(BulkDeleteMixin, ListAPI): return queryset -class NotificationDetail(RetrieveUpdateDestroyAPI): +class NotificationDetail(NotificationMessageMixin, RetrieveUpdateDestroyAPI): """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(CreateAPI): +class NotificationReadEdit(NotificationMessageMixin, CreateAPI): """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): """Add instance to context so it can be accessed in the serializer.""" context = super().get_serializer_context() @@ -362,15 +349,9 @@ class NotificationUnread(NotificationReadEdit): target = False -class NotificationReadAll(RetrieveAPI): +class NotificationReadAll(NotificationMessageMixin, RetrieveAPI): """API endpoint to mark all notifications as read.""" - queryset = common.models.NotificationMessage.objects.all() - - permission_classes = [ - UserSettingsPermissions, - ] - def get(self, request, *args, **kwargs): """Set all messages for the current user as read.""" try: @@ -380,6 +361,40 @@ class NotificationReadAll(RetrieveAPI): raise serializers.ValidationError(detail=serializers.as_serializer_error(exc)) +class NewsFeedMixin: + """Generic mixin for NewsFeedEntry.""" + queryset = common.models.NewsFeedEntry.objects.all() + serializer_class = common.serializers.NewsFeedEntrySerializer + permission_classes = [IsAdminUser, ] + + +class NewsFeedEntryList(NewsFeedMixin, BulkDeleteMixin, ListAPI): + """List view for all news items.""" + filter_backends = [ + DjangoFilterBackend, + filters.OrderingFilter, + ] + + ordering_fields = [ + 'published', + 'author', + 'read', + ] + + filterset_fields = [ + 'read', + ] + + +class NewsFeedEntryDetail(NewsFeedMixin, RetrieveUpdateDestroyAPI): + """Detail view for an individual news feed object.""" + + +class NewsFeedEntryRead(NewsFeedMixin, NotificationReadEdit): + """API endpoint to mark a news item as read.""" + target = True + + settings_api_urls = [ # User settings re_path(r'^user/', include([ @@ -428,4 +443,13 @@ common_api_urls = [ re_path(r'^.*$', NotificationList.as_view(), name='api-notifications-list'), ])), + # News + re_path(r'^news/', include([ + re_path(r'^(?P\d+)/', include([ + re_path(r'^read/', NewsFeedEntryRead.as_view(), name='api-news-read'), + re_path(r'.*$', NewsFeedEntryDetail.as_view(), name='api-news-detail'), + ])), + re_path(r'^.*$', NewsFeedEntryList.as_view(), name='api-news-list'), + ])), + ] diff --git a/InvenTree/common/migrations/0015_newsfeedentry.py b/InvenTree/common/migrations/0015_newsfeedentry.py new file mode 100644 index 0000000000..b96ac7f0c1 --- /dev/null +++ b/InvenTree/common/migrations/0015_newsfeedentry.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.14 on 2022-07-31 19:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0014_notificationmessage'), + ] + + operations = [ + migrations.CreateModel( + name='NewsFeedEntry', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('feed_id', models.CharField(max_length=250, unique=True, verbose_name='Id')), + ('title', models.CharField(max_length=250, verbose_name='Title')), + ('link', models.URLField(max_length=250, verbose_name='Link')), + ('published', models.DateTimeField(max_length=250, verbose_name='Published')), + ('author', models.CharField(max_length=250, verbose_name='Author')), + ('summary', models.CharField(max_length=250, verbose_name='Summary')), + ('read', models.BooleanField(default=False, help_text='Was this news item read?', verbose_name='Read')), + ], + ), + ] diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 388afa9e36..d4e50c596c 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -1560,6 +1560,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): 'validator': bool, }, + 'HOMEPAGE_NEWS': { + 'name': _('Show News'), + 'description': _('Show news on the homepage'), + 'default': False, + 'validator': bool, + }, + "LABEL_INLINE": { 'name': _('Inline label display'), 'description': _('Display PDF labels in the browser, instead of downloading as a file'), @@ -2285,3 +2292,54 @@ class NotificationMessage(models.Model): def age_human(self): """Humanized age.""" return naturaltime(self.creation) + + +class NewsFeedEntry(models.Model): + """A NewsFeedEntry represents an entry on the RSS/Atom feed that is generated for InvenTree news. + + Attributes: + - feed_id: Unique id for the news item + - title: Title for the news item + - link: Link to the news item + - published: Date of publishing of the news item + - author: Author of news item + - summary: Summary of the news items content + - read: Was this iteam already by a superuser? + """ + + feed_id = models.CharField( + verbose_name=_('Id'), + unique=True, + max_length=250, + ) + + title = models.CharField( + verbose_name=_('Title'), + max_length=250, + ) + + link = models.URLField( + verbose_name=_('Link'), + max_length=250, + ) + + published = models.DateTimeField( + verbose_name=_('Published'), + max_length=250, + ) + + author = models.CharField( + verbose_name=_('Author'), + max_length=250, + ) + + summary = models.CharField( + verbose_name=_('Summary'), + max_length=250, + ) + + read = models.BooleanField( + verbose_name=_('Read'), + help_text=_('Was this news item read?'), + default=False + ) diff --git a/InvenTree/common/serializers.py b/InvenTree/common/serializers.py index 4ff04de738..3965998950 100644 --- a/InvenTree/common/serializers.py +++ b/InvenTree/common/serializers.py @@ -5,7 +5,7 @@ from django.urls import reverse from rest_framework import serializers from common.models import (InvenTreeSetting, InvenTreeUserSetting, - NotificationMessage) + NewsFeedEntry, NotificationMessage) from InvenTree.helpers import construct_absolute_url, get_objectreference from InvenTree.serializers import InvenTreeModelSerializer @@ -211,3 +211,24 @@ class NotificationReadSerializer(NotificationMessageSerializer): self.instance = self.context['instance'] # set instance that should be returned self._validated_data = True return True + + +class NewsFeedEntrySerializer(InvenTreeModelSerializer): + """Serializer for the NewsFeedEntry model.""" + + read = serializers.BooleanField(read_only=True) + + class Meta: + """Meta options for NewsFeedEntrySerializer.""" + + model = NewsFeedEntry + fields = [ + 'pk', + 'feed_id', + 'title', + 'link', + 'published', + 'author', + 'summary', + 'read', + ] diff --git a/InvenTree/common/tasks.py b/InvenTree/common/tasks.py index e600136560..3db5811d64 100644 --- a/InvenTree/common/tasks.py +++ b/InvenTree/common/tasks.py @@ -3,8 +3,11 @@ import logging from datetime import datetime, timedelta +from django.conf import settings from django.core.exceptions import AppRegistryNotReady +import feedparser + from InvenTree.tasks import ScheduledTask, scheduled_task logger = logging.getLogger('inventree') @@ -26,3 +29,41 @@ def delete_old_notifications(): # Delete notification records before the specified date NotificationEntry.objects.filter(updated__lte=before).delete() + + +@scheduled_task(ScheduledTask.DAILY) +def update_news_feed(): + """Update the newsfeed.""" + try: + from common.models import NewsFeedEntry + except AppRegistryNotReady: # pragma: no cover + logger.info("Could not perform 'update_news_feed' - App registry not ready") + return + + # Fetch and parse feed + try: + d = feedparser.parse(settings.INVENTREE_NEWS_URL) + except Exception as entry: # pragma: no cover + logger.warning("update_news_feed: Error parsing the newsfeed", entry) + return + + # Get a reference list + id_list = [a.feed_id for a in NewsFeedEntry.objects.all()] + + # Iterate over entries + for entry in d.entries: + # Check if id already exsists + if entry.id in id_list: + continue + + # Create entry + NewsFeedEntry.objects.create( + feed_id=entry.id, + title=entry.title, + link=entry.link, + published=entry.published, + author=entry.author, + summary=entry.summary, + ) + + logger.info('update_news_feed: Sync done') diff --git a/InvenTree/templates/InvenTree/index.html b/InvenTree/templates/InvenTree/index.html index 836da5f870..5a26c53fe5 100644 --- a/InvenTree/templates/InvenTree/index.html +++ b/InvenTree/templates/InvenTree/index.html @@ -306,6 +306,17 @@ loadSalesOrderTable("#table-so-overdue", { {% endif %} +{% settings_value 'HOMEPAGE_NEWS' user=request.user as setting_news %} +{% if setting_news and user.is_staff %} + +addHeaderTitle('{% trans "InvenTree News" %}'); + +addHeaderAction('news', '{% trans "Current News" %}', 'fa-newspaper'); +loadNewsFeedTable("#table-news", { + url: "{% url 'api-news-list' %}", +}); +{% endif %} + enableSidebar( 'index', { diff --git a/InvenTree/templates/InvenTree/settings/user_homepage.html b/InvenTree/templates/InvenTree/settings/user_homepage.html index 17cbdfe2a5..c3ee51c5da 100644 --- a/InvenTree/templates/InvenTree/settings/user_homepage.html +++ b/InvenTree/templates/InvenTree/settings/user_homepage.html @@ -36,6 +36,8 @@ {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_SO_OUTSTANDING" user_setting=True %} {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_SO_OVERDUE" user_setting=True %} + + {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_NEWS" user_setting=True %} diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html index 7b6f0fb187..ee22e16c13 100644 --- a/InvenTree/templates/base.html +++ b/InvenTree/templates/base.html @@ -167,6 +167,7 @@ + diff --git a/InvenTree/templates/js/translated/news.js b/InvenTree/templates/js/translated/news.js new file mode 100644 index 0000000000..4069177c60 --- /dev/null +++ b/InvenTree/templates/js/translated/news.js @@ -0,0 +1,58 @@ +{% load i18n %} +{% load inventree_extras %} + +/* exported + loadNewsFeedTable, +*/ + +/* + * Load notification table + */ +function loadNewsFeedTable(table, options={}, enableDelete=false) { + setupFilterList('news', table); + + $(table).inventreeTable({ + url: options.url, + name: 'news', + groupBy: false, + queryParams: { + ordering: 'published', + }, + paginationVAlign: 'bottom', + formatNoMatches: function() { + return '{% trans "No news found" %}'; + }, + columns: [ + { + field: 'pk', + title: '{% trans "ID" %}', + visible: false, + switchable: false, + }, + { + field: 'title', + title: '{% trans "Title" %}', + sortable: 'true', + formatter: function(value, row) { + return `` + value + ``; + } + }, + { + field: 'summary', + title: '{% trans "Summary" %}', + }, + { + field: 'author', + title: '{% trans "Author" %}', + }, + { + field: 'published', + title: '{% trans "Published" %}', + sortable: 'true', + formatter: function(value) { + return renderDate(value); + } + }, + ] + }); +} diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 959cccfeae..b03c9742f6 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -76,6 +76,7 @@ class RuleSet(models.Model): 'plugin_pluginconfig', 'plugin_pluginsetting', 'plugin_notificationusersetting', + 'common_newsfeedentry', ], 'part_category': [ 'part_partcategory', diff --git a/requirements.in b/requirements.in index 677d0b2b24..cdae788e6f 100644 --- a/requirements.in +++ b/requirements.in @@ -25,6 +25,7 @@ django-user-sessions # user sessions in DB django-weasyprint # django weasyprint integration djangorestframework # DRF framework django-xforwardedfor-middleware # IP forwarding metadata +feedparser # RSS newsfeed parser gunicorn # Gunicorn web server pdf2image # PDF to image conversion pillow # Image manipulation diff --git a/requirements.txt b/requirements.txt index a42003aa11..bda8ab60b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -124,6 +124,8 @@ djangorestframework==3.14.0 # via -r requirements.in et-xmlfile==1.1.0 # via openpyxl +feedparser==6.0.10 + # via -r requirements.in fonttools[woff]==4.37.4 # via weasyprint gunicorn==20.1.0 @@ -209,6 +211,8 @@ requests-oauthlib==1.3.1 # via django-allauth sentry-sdk==1.9.10 # via -r requirements.in +sgmllib3k==1.0.0 + # via feedparser six==1.16.0 # via # bleach