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
This commit is contained in:
Matthias Mair 2022-11-10 02:20:06 +01:00 committed by GitHub
parent f6cfc12343
commit fb77158496
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 291 additions and 33 deletions

View File

@ -26,6 +26,8 @@ from sentry_sdk.integrations.django import DjangoIntegration
from . import config from . import config
from .config import get_boolean_setting, get_custom_file, get_setting 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" # Determine if we are running in "test" mode e.g. "manage.py test"
TESTING = 'test' in sys.argv TESTING = 'test' in sys.argv

View File

@ -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'^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'^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'^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'^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'^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'), re_path(r'^notification.js', DynamicJsView.as_view(template_name='js/translated/notification.js'), name='notification.js'),

View File

@ -55,9 +55,16 @@ class NotificationMessageAdmin(admin.ModelAdmin):
search_fields = ('name', 'category', 'message', ) 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.InvenTreeSetting, SettingsAdmin)
admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin) admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin)
admin.site.register(common.models.WebhookEndpoint, WebhookAdmin) admin.site.register(common.models.WebhookEndpoint, WebhookAdmin)
admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin) admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin)
admin.site.register(common.models.NotificationEntry, NotificationEntryAdmin) admin.site.register(common.models.NotificationEntry, NotificationEntryAdmin)
admin.site.register(common.models.NotificationMessage, NotificationMessageAdmin) admin.site.register(common.models.NotificationMessage, NotificationMessageAdmin)
admin.site.register(common.models.NewsFeedEntry, NewsFeedEntryAdmin)

View File

@ -11,6 +11,7 @@ from django_filters.rest_framework import DjangoFilterBackend
from django_q.tasks import async_task from django_q.tasks import async_task
from rest_framework import filters, permissions, serializers from rest_framework import filters, permissions, serializers
from rest_framework.exceptions import NotAcceptable, NotFound from rest_framework.exceptions import NotAcceptable, NotFound
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
@ -255,21 +256,20 @@ class NotificationUserSettingsDetail(RetrieveUpdateAPI):
queryset = NotificationUserSetting.objects.all() queryset = NotificationUserSetting.objects.all()
serializer_class = NotificationUserSettingSerializer serializer_class = NotificationUserSettingSerializer
permission_classes = [UserSettingsPermissions, ]
permission_classes = [
UserSettingsPermissions,
]
class NotificationList(BulkDeleteMixin, ListAPI): class NotificationMessageMixin:
"""List view for all notifications of the current user.""" """Generic mixin for NotificationMessage."""
queryset = common.models.NotificationMessage.objects.all() queryset = common.models.NotificationMessage.objects.all()
serializer_class = common.serializers.NotificationMessageSerializer 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 = [ filter_backends = [
DjangoFilterBackend, DjangoFilterBackend,
@ -312,29 +312,16 @@ class NotificationList(BulkDeleteMixin, ListAPI):
return queryset return queryset
class NotificationDetail(RetrieveUpdateDestroyAPI): class NotificationDetail(NotificationMessageMixin, RetrieveUpdateDestroyAPI):
"""Detail view for an individual notification object. """Detail view for an individual notification object.
- User can only view / delete their own notification objects - 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(NotificationMessageMixin, CreateAPI):
class NotificationReadEdit(CreateAPI):
"""General API endpoint to manipulate read state of a notification.""" """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): def get_serializer_context(self):
"""Add instance to context so it can be accessed in the serializer.""" """Add instance to context so it can be accessed in the serializer."""
context = super().get_serializer_context() context = super().get_serializer_context()
@ -362,15 +349,9 @@ class NotificationUnread(NotificationReadEdit):
target = False target = False
class NotificationReadAll(RetrieveAPI): class NotificationReadAll(NotificationMessageMixin, RetrieveAPI):
"""API endpoint to mark all notifications as read.""" """API endpoint to mark all notifications as read."""
queryset = common.models.NotificationMessage.objects.all()
permission_classes = [
UserSettingsPermissions,
]
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""Set all messages for the current user as read.""" """Set all messages for the current user as read."""
try: try:
@ -380,6 +361,40 @@ class NotificationReadAll(RetrieveAPI):
raise serializers.ValidationError(detail=serializers.as_serializer_error(exc)) 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 = [ settings_api_urls = [
# User settings # User settings
re_path(r'^user/', include([ re_path(r'^user/', include([
@ -428,4 +443,13 @@ common_api_urls = [
re_path(r'^.*$', NotificationList.as_view(), name='api-notifications-list'), re_path(r'^.*$', NotificationList.as_view(), name='api-notifications-list'),
])), ])),
# News
re_path(r'^news/', include([
re_path(r'^(?P<pk>\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'),
])),
] ]

View File

@ -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')),
],
),
]

View File

@ -1560,6 +1560,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
}, },
'HOMEPAGE_NEWS': {
'name': _('Show News'),
'description': _('Show news on the homepage'),
'default': False,
'validator': bool,
},
"LABEL_INLINE": { "LABEL_INLINE": {
'name': _('Inline label display'), 'name': _('Inline label display'),
'description': _('Display PDF labels in the browser, instead of downloading as a file'), '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): def age_human(self):
"""Humanized age.""" """Humanized age."""
return naturaltime(self.creation) 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
)

View File

@ -5,7 +5,7 @@ from django.urls import reverse
from rest_framework import serializers from rest_framework import serializers
from common.models import (InvenTreeSetting, InvenTreeUserSetting, from common.models import (InvenTreeSetting, InvenTreeUserSetting,
NotificationMessage) NewsFeedEntry, NotificationMessage)
from InvenTree.helpers import construct_absolute_url, get_objectreference from InvenTree.helpers import construct_absolute_url, get_objectreference
from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeModelSerializer
@ -211,3 +211,24 @@ class NotificationReadSerializer(NotificationMessageSerializer):
self.instance = self.context['instance'] # set instance that should be returned self.instance = self.context['instance'] # set instance that should be returned
self._validated_data = True self._validated_data = True
return 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',
]

View File

@ -3,8 +3,11 @@
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.conf import settings
from django.core.exceptions import AppRegistryNotReady from django.core.exceptions import AppRegistryNotReady
import feedparser
from InvenTree.tasks import ScheduledTask, scheduled_task from InvenTree.tasks import ScheduledTask, scheduled_task
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -26,3 +29,41 @@ def delete_old_notifications():
# Delete notification records before the specified date # Delete notification records before the specified date
NotificationEntry.objects.filter(updated__lte=before).delete() 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')

View File

@ -306,6 +306,17 @@ loadSalesOrderTable("#table-so-overdue", {
{% endif %} {% 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( enableSidebar(
'index', 'index',
{ {

View File

@ -36,6 +36,8 @@
<tr><td colspan='5'></td></tr> <tr><td colspan='5'></td></tr>
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_SO_OUTSTANDING" user_setting=True %} {% 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_SO_OVERDUE" user_setting=True %}
<tr><td colspan='5'></td></tr>
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_NEWS" user_setting=True %}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -167,6 +167,7 @@
<script defer type='text/javascript' src="{% i18n_static 'search.js' %}"></script> <script defer type='text/javascript' src="{% i18n_static 'search.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'stock.js' %}"></script> <script defer type='text/javascript' src="{% i18n_static 'stock.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'plugin.js' %}"></script> <script defer type='text/javascript' src="{% i18n_static 'plugin.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'news.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'tables.js' %}"></script> <script defer type='text/javascript' src="{% i18n_static 'tables.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'table_filters.js' %}"></script> <script defer type='text/javascript' src="{% i18n_static 'table_filters.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'notification.js' %}"></script> <script defer type='text/javascript' src="{% i18n_static 'notification.js' %}"></script>

View File

@ -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 `<a href="` + row.link + `">` + value + `</a>`;
}
},
{
field: 'summary',
title: '{% trans "Summary" %}',
},
{
field: 'author',
title: '{% trans "Author" %}',
},
{
field: 'published',
title: '{% trans "Published" %}',
sortable: 'true',
formatter: function(value) {
return renderDate(value);
}
},
]
});
}

View File

@ -76,6 +76,7 @@ class RuleSet(models.Model):
'plugin_pluginconfig', 'plugin_pluginconfig',
'plugin_pluginsetting', 'plugin_pluginsetting',
'plugin_notificationusersetting', 'plugin_notificationusersetting',
'common_newsfeedentry',
], ],
'part_category': [ 'part_category': [
'part_partcategory', 'part_partcategory',

View File

@ -25,6 +25,7 @@ django-user-sessions # user sessions in DB
django-weasyprint # django weasyprint integration django-weasyprint # django weasyprint integration
djangorestframework # DRF framework djangorestframework # DRF framework
django-xforwardedfor-middleware # IP forwarding metadata django-xforwardedfor-middleware # IP forwarding metadata
feedparser # RSS newsfeed parser
gunicorn # Gunicorn web server gunicorn # Gunicorn web server
pdf2image # PDF to image conversion pdf2image # PDF to image conversion
pillow # Image manipulation pillow # Image manipulation

View File

@ -124,6 +124,8 @@ djangorestframework==3.14.0
# via -r requirements.in # via -r requirements.in
et-xmlfile==1.1.0 et-xmlfile==1.1.0
# via openpyxl # via openpyxl
feedparser==6.0.10
# via -r requirements.in
fonttools[woff]==4.37.4 fonttools[woff]==4.37.4
# via weasyprint # via weasyprint
gunicorn==20.1.0 gunicorn==20.1.0
@ -209,6 +211,8 @@ requests-oauthlib==1.3.1
# via django-allauth # via django-allauth
sentry-sdk==1.9.10 sentry-sdk==1.9.10
# via -r requirements.in # via -r requirements.in
sgmllib3k==1.0.0
# via feedparser
six==1.16.0 six==1.16.0
# via # via
# bleach # bleach