diff --git a/.bootstraprc b/.bootstraprc index 924360a9818097b167d91384b0effefbc2d47a14..e579effcaed034a57ec58ed2b2205a61833e0952 100644 --- a/.bootstraprc +++ b/.bootstraprc @@ -36,14 +36,17 @@ "type": true, "tooltip": true, "utilities": true, + "popover": true, }, "scripts": { "alert": true, "collapse": true, + "dropdown": true, "modal": true, "scrollspy": true, "tab": true, "tooltip": true, "util": true, + "popover": true, } } diff --git a/SciPost_v1/settings/base.py b/SciPost_v1/settings/base.py index f3a4ed8e5a68b10a591d4081d9ea2e8ec02948fa..8fd730d747a36290c629701311008135a53c02a9 100644 --- a/SciPost_v1/settings/base.py +++ b/SciPost_v1/settings/base.py @@ -90,6 +90,7 @@ INSTALLED_APPS = ( 'mails', 'mailing_lists', 'news', + 'notifications', 'scipost', 'submissions', 'theses', @@ -107,11 +108,6 @@ HAYSTACK_CONNECTIONS = { 'PATH': 'local_files/haystack/', 'EXCLUDED_INDEXES': ['sphinxdoc.search_indexes.DocumentIndex'], }, - # 'scipost': { - # 'ENGINE': 'haystack.backends.whoosh_backend.WhooshEngine', - # 'PATH': 'local_files/haystack_scipost/', - # 'EXCLUDED_INDEXES': ['sphinxdoc.search_indexes.DocumentIndex'], - # }, } # Brute force automatically re-index Haystack using post_save signals on all models. @@ -149,7 +145,7 @@ MATHJAX_CONFIG_DATA = { } } -MIDDLEWARE_CLASSES = ( +MIDDLEWARE = ( 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.locale.LocaleMiddleware', 'django.middleware.common.CommonMiddleware', @@ -158,7 +154,7 @@ MIDDLEWARE_CLASSES = ( 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'django.middleware.security.SecurityMiddleware', + 'django.middleware.security.SecurityMiddleware' ) ROOT_URLCONF = 'SciPost_v1.urls' diff --git a/SciPost_v1/settings/local_jorran.py b/SciPost_v1/settings/local_jorran.py index f1f650d012c570fc7a512b1dbc8269640a9e66f1..cd2ed99b46436673da26e9b69da2ec69da258b0d 100644 --- a/SciPost_v1/settings/local_jorran.py +++ b/SciPost_v1/settings/local_jorran.py @@ -7,7 +7,7 @@ DEBUG = True INSTALLED_APPS += ( 'debug_toolbar', ) -MIDDLEWARE_CLASSES += ( +MIDDLEWARE += ( 'debug_toolbar.middleware.DebugToolbarMiddleware', ) INTERNAL_IPS = ['127.0.0.1', '::1'] diff --git a/SciPost_v1/urls.py b/SciPost_v1/urls.py index fc2b471e09c717922963e7287c2600c115d356a1..ba767eb8166b0f60df1e78d25b141bf652b53732 100644 --- a/SciPost_v1/urls.py +++ b/SciPost_v1/urls.py @@ -41,6 +41,7 @@ urlpatterns = [ url(r'^thesis/', include('theses.urls', namespace="_theses")), url(r'^meetings/', include('virtualmeetings.urls', namespace="virtualmeetings")), url(r'^news/', include('news.urls', namespace="news")), + url(r'^notifications/', include('notifications.urls', namespace="notifications")), url(r'^production/', include('production.urls', namespace="production")), url(r'^partners/', include('partners.urls', namespace="partners")), diff --git a/notifications/__init__.py b/notifications/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d51321cada927770a881312f84365d972e19b924 --- /dev/null +++ b/notifications/__init__.py @@ -0,0 +1 @@ +default_app_config = 'notifications.apps.NotificationsConfig' diff --git a/notifications/admin.py b/notifications/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..2cf8c7c7b329a7a98b23bb5332d71059d5023d56 --- /dev/null +++ b/notifications/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin +from .models import Notification + + +class NotificationAdmin(admin.ModelAdmin): + raw_id_fields = ('recipient', ) + list_display = ('recipient', 'actor', + 'level', 'target', 'unread', 'public') + list_filter = ('level', 'unread', 'public', 'created', ) + + +admin.site.register(Notification, NotificationAdmin) diff --git a/notifications/apps.py b/notifications/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..5a0aef11beb47c7407ecf968d3eab794db559274 --- /dev/null +++ b/notifications/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + name = 'notifications' + + def ready(self): + super().ready() + import notifications.signals + notifications.notify = notifications.signals.notify diff --git a/notifications/managers.py b/notifications/managers.py new file mode 100644 index 0000000000000000000000000000000000000000..4ec6b22b904ec620247661c0314472a25ade2ba7 --- /dev/null +++ b/notifications/managers.py @@ -0,0 +1,77 @@ +from django.db import models + + +class NotificationQuerySet(models.query.QuerySet): + + def unsent(self): + return self.filter(emailed=False) + + def sent(self): + return self.filter(emailed=True) + + def unread(self): + """Return only unread items in the current queryset""" + return self.filter(unread=True) + + def read(self): + """Return only read items in the current queryset""" + return self.filter(unread=False) + + def mark_all_as_read(self, recipient=None): + """Mark as read any unread messages in the current queryset.""" + # We want to filter out read ones, as later we will store + # the time they were marked as read. + qs = self.unread() + if recipient: + qs = qs.filter(recipient=recipient) + + return qs.update(unread=False) + + def mark_all_as_unread(self, recipient=None): + """Mark as unread any read messages in the current queryset.""" + qs = self.read() + + if recipient: + qs = qs.filter(recipient=recipient) + + return qs.update(unread=True) + + def deleted(self): + """Return only deleted items in the current queryset""" + raise DeprecationWarning + return self.filter(deleted=True) + + def active(self): + """Return only active(un-deleted) items in the current queryset""" + raise DeprecationWarning + return self.filter(deleted=False) + + def mark_all_as_deleted(self, recipient=None): + """Mark current queryset as deleted.""" + raise DeprecationWarning + qs = self.active() + if recipient: + qs = qs.filter(recipient=recipient) + + return qs.update(deleted=True) + + def mark_all_as_active(self, recipient=None): + """Mark current queryset as active(un-deleted).""" + raise DeprecationWarning + qs = self.deleted() + if recipient: + qs = qs.filter(recipient=recipient) + + return qs.update(deleted=False) + + def mark_as_unsent(self, recipient=None): + qs = self.sent() + if recipient: + qs = self.filter(recipient=recipient) + return qs.update(emailed=False) + + def mark_as_sent(self, recipient=None): + qs = self.unsent() + if recipient: + qs = self.filter(recipient=recipient) + return qs.update(emailed=True) diff --git a/notifications/migrations/0001_initial.py b/notifications/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..8e4dfe442887f4f26d76b60d175abfafb98ce9bd --- /dev/null +++ b/notifications/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-11 18:33 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('level', models.CharField(choices=[('success', 'Success'), ('info', 'Info'), ('warning', 'Warning'), ('error', 'Error')], default='info', max_length=20)), + ('unread', models.BooleanField(default=True)), + ('actor_object_id', models.CharField(max_length=255)), + ('verb', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, null=True)), + ('target_object_id', models.CharField(blank=True, max_length=255, null=True)), + ('action_object_object_id', models.CharField(blank=True, max_length=255, null=True)), + ('created', models.DateTimeField(default=django.utils.timezone.now)), + ('public', models.BooleanField(default=True)), + ('emailed', models.BooleanField(default=False)), + ('action_object_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notify_action_object', to='contenttypes.ContentType')), + ('actor_content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notify_actor', to='contenttypes.ContentType')), + ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), + ('target_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notify_target', to='contenttypes.ContentType')), + ], + options={ + 'ordering': ('-created',), + }, + ), + ] diff --git a/notifications/migrations/__init__.py b/notifications/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/notifications/models.py b/notifications/models.py new file mode 100644 index 0000000000000000000000000000000000000000..ecec80cfea09c80841445b49b984d9b8cde60c25 --- /dev/null +++ b/notifications/models.py @@ -0,0 +1,111 @@ +from django.db import models +from django.core.urlresolvers import reverse +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.fields import GenericForeignKey +from django.utils import timezone + +from .managers import NotificationQuerySet + + +class Notification(models.Model): + """ + Action model describing the actor acting out a verb (on an optional + target). + Nomenclature based on http://activitystrea.ms/specs/atom/1.0/ + Generalized Format:: + <actor> <verb> <time> + <actor> <verb> <target> <time> + <actor> <verb> <action_object> <target> <time> + Examples:: + <justquick> <reached level 60> <1 minute ago> + <brosner> <commented on> <pinax/pinax> <2 hours ago> + <washingtontimes> <started follow> <justquick> <8 minutes ago> + <mitsuhiko> <closed> <issue 70> on <mitsuhiko/flask> <about 2 hours ago> + Unicode Representation:: + justquick reached level 60 1 minute ago + mitsuhiko closed issue 70 on mitsuhiko/flask 3 hours ago + """ + LEVELS = (('success', 'Success'), ('info', 'Info'), ('warning', 'Warning'), ('error', 'Error')) + level = models.CharField(choices=LEVELS, default='info', max_length=20) + + recipient = models.ForeignKey(settings.AUTH_USER_MODEL, blank=False, + related_name='notifications') + unread = models.BooleanField(default=True) + + actor_content_type = models.ForeignKey(ContentType, related_name='notify_actor') + actor_object_id = models.CharField(max_length=255) + actor = GenericForeignKey('actor_content_type', 'actor_object_id') + + verb = models.CharField(max_length=255) + description = models.TextField(blank=True, null=True) + + target_content_type = models.ForeignKey(ContentType, related_name='notify_target', + blank=True, null=True) + target_object_id = models.CharField(max_length=255, blank=True, null=True) + target = GenericForeignKey('target_content_type', 'target_object_id') + + action_object_content_type = models.ForeignKey(ContentType, blank=True, null=True, + related_name='notify_action_object') + action_object_object_id = models.CharField(max_length=255, blank=True, null=True) + action_object = GenericForeignKey('action_object_content_type', 'action_object_object_id') + + created = models.DateTimeField(default=timezone.now) + + public = models.BooleanField(default=True) + emailed = models.BooleanField(default=False) + + objects = NotificationQuerySet.as_manager() + + class Meta: + ordering = ('-created', ) + + def __str__(self): + ctx = { + 'actor': self.actor, + 'verb': self.verb, + 'action_object': self.action_object, + 'target': self.target, + 'timesince': self.timesince() + } + if self.target: + if self.action_object: + return u'%(actor)s %(verb)s %(action_object)s on %(target)s %(timesince)s ago' % ctx + return u'%(actor)s %(verb)s %(target)s %(timesince)s ago' % ctx + if self.action_object: + return u'%(actor)s %(verb)s %(action_object)s %(timesince)s ago' % ctx + return u'%(actor)s %(verb)s %(timesince)s ago' % ctx + + def get_absolute_url(self): + return reverse('notifications:forward', args=(self.slug,)) + + def timesince(self, now=None): + """ + Shortcut for the ``django.utils.timesince.timesince`` function of the + current timestamp. + """ + from django.utils.timesince import timesince as timesince_ + return timesince_(self.created, now) + + @property + def slug(self): + from .utils import id2slug + return id2slug(self.id) + + def mark_toggle(self): + if self.unread: + self.unread = False + self.save() + else: + self.unread = True + self.save() + + def mark_as_read(self): + if self.unread: + self.unread = False + self.save() + + def mark_as_unread(self): + if not self.unread: + self.unread = True + self.save() diff --git a/notifications/signals.py b/notifications/signals.py new file mode 100644 index 0000000000000000000000000000000000000000..af8be2f2807ae3a16573f49e4319d49b5a615dac --- /dev/null +++ b/notifications/signals.py @@ -0,0 +1,36 @@ +from django.dispatch import receiver, Signal + +from .models import Notification + + +notify = Signal(providing_args=[ + 'recipient', 'actor', 'verb', 'action_object', 'target', 'description', 'level' +]) + + +@receiver(notify) +def notify_receiver(sender, **kwargs): + if not type(kwargs['recipient']) == list: + recipient = [kwargs['recipient']] + else: + recipient = kwargs['recipient'] + + for user in recipient: + notification = Notification( + recipient=user, + actor=kwargs['actor'], + verb=kwargs['verb'], + action_object=kwargs.get('action_object'), + target=kwargs.get('target'), + description=kwargs.get('description'), + level=kwargs.get('level', 'info') + ) + notification.save() + print("Request finished!") + + +# Basic working method to send a notification to a user using signals: +# --- +# from notifications.signals import notify +# notify.send(user, recipient=user, verb='you reached level 10') +# --- diff --git a/notifications/templates/notifications/notification_list.html b/notifications/templates/notifications/notification_list.html new file mode 100644 index 0000000000000000000000000000000000000000..a2288eb3a70f25d59f831a98d4106600c140ab2a --- /dev/null +++ b/notifications/templates/notifications/notification_list.html @@ -0,0 +1,25 @@ +{% extends 'scipost/_personal_page_base.html' %} + +{% block breadcrumb_items %} + {{block.super}} + <span class="breadcrumb-item">My notifications</span> +{% endblock %} + +{% block pagetitle %}: My notifications{% endblock pagetitle %} + +{% block content %} + <h1>My notifications</h1> + <div class="mb-3"> + <a href="{% url 'notifications:mark_all_as_read' %}">Mark all as read</a> + </div> + + <ul class="list-group notifications"> + {% for notice in notifications %} + {% include 'notifications/partials/notice.html' %} + {% empty %} + <li class="list-group-item"> + <div class="text-center px-2 py-3"><i class="fa fa-star-o fa-2x" aria-hidden="true"></i><h3>You have no new notifications</h3></div> + </li> + {% endfor %} + </ul> +{% endblock %} diff --git a/notifications/templates/notifications/partials/notice.html b/notifications/templates/notifications/partials/notice.html new file mode 100644 index 0000000000000000000000000000000000000000..642acde443cb311d1726ee30fbbe899a4fab3680 --- /dev/null +++ b/notifications/templates/notifications/partials/notice.html @@ -0,0 +1,34 @@ +<div class="list-group-item list-group-item-action flex-column align-items-start{% if notice.unread %} active{% endif %} px-3 py-2"> + <div> + <div class="actions"> + <a href="{% url 'notifications:mark_toggle' notice.slug %}"> + {% if notice.unread %} + <i class="fa fa-circle" data-toggle="tooltip" data-placement="auto" title="Mark as unread" aria-hidden="true"></i> + {% else %} + <i class="fa fa-circle-o" data-toggle="tooltip" data-placement="auto" title="Mark as read" aria-hidden="true"></i> + {% endif %} + </a> + </div> + <h4> + <strong> + {% if notice.actor.first_name and notice.actor.last_name %} + {{ notice.actor.first_name}} {{ notice.actor.last_name }} + {% else %} + {{ notice.actor }} + {% endif %} + </strong> + {{ notice.verb }} + {% if notice.target %} + {{ notice.target }} + {% endif %} + </h4> + <div class="text-muted mb-1">{{ notice.created|timesince }} ago</div> + + </div> + {% if notice.description %} + <p class="mb-1">{{ notice.description|linebreaksbr }}</p> + {% endif %} + {% if notice.target %} + <a href="{{notice.target.get_absolute_url}}">Go to {{notice.target_content_type}}</a> + {% endif %} +</div> diff --git a/notifications/templatetags/notifications_tags.py b/notifications/templatetags/notifications_tags.py new file mode 100644 index 0000000000000000000000000000000000000000..42c62d09bfa42bf4735008e895623171f7f500ec --- /dev/null +++ b/notifications/templatetags/notifications_tags.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +from django.template import Library +from django.utils.html import format_html + +register = Library() + + +@register.assignment_tag(takes_context=True) +def notifications_unread(context): + user = user_context(context) + if not user: + return '' + return user.notifications.unread().count() + + +@register.simple_tag(takes_context=True) +def live_notify_badge(context, badge_class='live_notify_badge', classes=''): + user = user_context(context) + if not user: + return '' + + html = "<span class='{badge_class} {classes}' data-count='{unread}'>{unread}</span>".format( + badge_class=badge_class, unread=user.notifications.unread().count(), + classes=classes + ) + return format_html(html) + + +@register.simple_tag +def live_notify_list(list_class='live_notify_list', classes=''): + html = "<ul class='{list_class} {classes}'></ul>".format(list_class=list_class, + classes=classes) + return format_html(html) + + +def user_context(context): + if 'user' not in context: + return None + + request = context['request'] + user = request.user + if user.is_anonymous(): + return None + return user diff --git a/notifications/tests.py b/notifications/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..7ce503c2dd97ba78597f6ff6e4393132753573f6 --- /dev/null +++ b/notifications/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/notifications/urls.py b/notifications/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..23bb20e9e1e4aedb2bc6ec2191bf9dbf2f4c5101 --- /dev/null +++ b/notifications/urls.py @@ -0,0 +1,15 @@ +from django.conf.urls import url + +from . import views + + +urlpatterns = [ + url(r'^$', views.AllNotificationsList.as_view(), name='all'), + url(r'^redirect/(?P<slug>\d+)$', views.forward, name='forward'), + url(r'^mark-all-as-read/$', views.mark_all_as_read, name='mark_all_as_read'), + url(r'^mark-toggle/(?P<slug>\d+)/$', views.mark_toggle, name='mark_toggle'), + url(r'^delete/(?P<slug>\d+)/$', views.delete, name='delete'), + url(r'^api/unread_count/$', views.live_unread_notification_count, + name='live_unread_notification_count'), + url(r'^api/list/$', views.live_notification_list, name='live_unread_notification_list'), +] diff --git a/notifications/utils.py b/notifications/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..9f80b64365f8d617a8b0d6a3787f0b6b360df25e --- /dev/null +++ b/notifications/utils.py @@ -0,0 +1,6 @@ +def slug2id(slug): + return int(slug) - 9631 + + +def id2slug(id): + return id + 9631 diff --git a/notifications/views.py b/notifications/views.py new file mode 100644 index 0000000000000000000000000000000000000000..38dd0329b46e900f6b569d04e50cbb586c2fb517 --- /dev/null +++ b/notifications/views.py @@ -0,0 +1,137 @@ +from django.contrib.auth.decorators import login_required, user_passes_test +from django.contrib.auth.models import User +from django.forms import model_to_dict +from django.http import JsonResponse +from django.shortcuts import get_object_or_404, redirect +from django.utils.decorators import method_decorator +from django.views.generic import ListView + +from .models import Notification +from .utils import id2slug, slug2id + + +def is_test_user(user): + return user.groups.filter(name='Testers').exists() + + +@method_decorator(login_required, name='dispatch') +@method_decorator(user_passes_test(is_test_user), name='dispatch') +class NotificationViewList(ListView): + context_object_name = 'notifications' + + +class AllNotificationsList(NotificationViewList): + """ + Index page for authenticated user + """ + def get_queryset(self): + return self.request.user.notifications.all() + + +@login_required +@user_passes_test(is_test_user) +def forward(request, slug): + """ + Open the url of the target object of the notification and redirect. + In addition, mark the notification as read. + """ + notification = get_object_or_404(Notification, recipient=request.user, id=slug2id(slug)) + notification.mark_as_read() + return redirect(notification.target.get_absolute_url()) + + +@login_required +@user_passes_test(is_test_user) +def mark_all_as_read(request): + request.user.notifications.mark_all_as_read() + + _next = request.GET.get('next') + + if _next: + return redirect(_next) + return redirect('notifications:all') + + +@login_required +@user_passes_test(is_test_user) +def mark_toggle(request, slug=None): + id = slug2id(slug) + + notification = get_object_or_404(Notification, recipient=request.user, id=id) + notification.mark_toggle() + + _next = request.GET.get('next') + if _next: + return redirect(_next) + + if request.GET.get('json'): + return JsonResponse({'unread': notification.unread}) + + return redirect('notifications:all') + + +@login_required +@user_passes_test(is_test_user) +def delete(request, slug=None): + id = slug2id(slug) + + notification = get_object_or_404(Notification, recipient=request.user, id=id) + notification.delete() + + _next = request.GET.get('next') + + if _next: + return redirect(_next) + + return redirect('notifications:all') + + +def live_unread_notification_count(request): + if not request.user.is_authenticated(): + data = {'unread_count': 0} + else: + data = {'unread_count': request.user.notifications.unread().count()} + return JsonResponse(data) + + +def live_notification_list(request): + if not request.user.is_authenticated(): + data = { + 'unread_count': 0, + 'list': [] + } + return JsonResponse(data) + + try: + # Default to 5 as a max number of notifications + num_to_fetch = max(int(request.GET.get('max', 5)), 1) + num_to_fetch = min(num_to_fetch, 100) + except ValueError: + num_to_fetch = 5 + + list = [] + + for n in request.user.notifications.all()[:num_to_fetch]: + struct = model_to_dict(n) + struct['slug'] = id2slug(n.id) + if n.actor: + if isinstance(n.actor, User): + # Humanize if possible + struct['actor'] = '{f} {l}'.format(f=n.actor.first_name, l=n.actor.last_name) + else: + struct['actor'] = str(n.actor) + if n.target: + struct['target'] = str(n.target) + struct['forward_link'] = n.get_absolute_url() + if n.action_object: + struct['action_object'] = str(n.action_object) + struct['timesince'] = n.timesince() + + list.append(struct) + if request.GET.get('mark_as_read'): + n.mark_as_read() + data = { + 'unread_count': request.user.notifications.unread().count(), + 'list': list + } + return JsonResponse(data) diff --git a/production/__init__.py b/production/__init__.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..2418e502e4286e2cfcb4845419fa9fee9ea0e4cb 100644 --- a/production/__init__.py +++ b/production/__init__.py @@ -0,0 +1 @@ +default_app_config = 'production.apps.ProductionConfig' diff --git a/production/apps.py b/production/apps.py index d8fba60df00793d7fbaff3cf230a0ba937c9576a..549c6bd6bb6fac43a2edebae72c2e580cb66bb11 100644 --- a/production/apps.py +++ b/production/apps.py @@ -1,5 +1,14 @@ from django.apps import AppConfig +from django.db.models.signals import post_save class ProductionConfig(AppConfig): name = 'production' + + def ready(self): + super().ready() + + from .models import ProductionStream, ProductionEvent + from .signals import notify_new_stream, notify_new_event + post_save.connect(notify_new_stream, sender=ProductionStream) + post_save.connect(notify_new_event, sender=ProductionEvent) diff --git a/production/models.py b/production/models.py index 6b515e8c2b2f8c2c11e8396f6969210cb5554106..3d94ddaa295504fbb05aa1224937ee7436f2093b 100644 --- a/production/models.py +++ b/production/models.py @@ -5,12 +5,6 @@ from django.core.urlresolvers import reverse from .constants import PRODUCTION_STREAM_STATUS, PRODUCTION_STREAM_ONGOING, PRODUCTION_EVENTS from .managers import ProductionStreamManager, ProductionEventManager -from scipost.models import Contributor - - -############## -# Production # -############## class ProductionStream(models.Model): submission = models.OneToOneField('submissions.Submission', on_delete=models.CASCADE) @@ -22,7 +16,8 @@ class ProductionStream(models.Model): objects = ProductionStreamManager() def __str__(self): - return str(self.submission) + return '{arxiv}, {title}'.format(arxiv=self.submission.arxiv_identifier_w_vn_nr, + title=self.submission.title) def get_absolute_url(self): if self.status == PRODUCTION_STREAM_ONGOING: @@ -39,7 +34,7 @@ class ProductionEvent(models.Model): event = models.CharField(max_length=64, choices=PRODUCTION_EVENTS) comments = models.TextField(blank=True, null=True) noted_on = models.DateTimeField(default=timezone.now) - noted_by = models.ForeignKey(Contributor, on_delete=models.CASCADE) + noted_by = models.ForeignKey('scipost.Contributor', on_delete=models.CASCADE) duration = models.DurationField(blank=True, null=True) objects = ProductionEventManager() @@ -48,7 +43,7 @@ class ProductionEvent(models.Model): ordering = ['noted_on'] def __str__(self): - return '%s: %s' % (str(self.stream.submission), self.get_event_display()) + return '%s: %s' % (self.stream, self.get_event_display()) def get_absolute_url(self): return self.stream.get_absolute_url() diff --git a/production/signals.py b/production/signals.py new file mode 100644 index 0000000000000000000000000000000000000000..6a3102f9249f4a5a71f1a9603974a7590b1b4a31 --- /dev/null +++ b/production/signals.py @@ -0,0 +1,33 @@ +from django.contrib.auth.models import Group, User + +from notifications.signals import notify + + +def notify_new_stream(sender, instance, created, **kwargs): + """ + Notify the production team about a new Production Stream created. + """ + if created: + production_officers = User.objects.filter(groups__name='Production Officers') + editorial_college = Group.objects.get(name='Editorial College') + for user in production_officers: + notify.send(sender=sender, recipient=user, actor=editorial_college, verb=' accepted a submission. A new productionstream has been started.', target=instance) + + +def notify_new_event(sender, instance, created, **kwargs): + """ + Notify the production team about a new Production Event created. + """ + if created: + production_officers = User.objects.filter(groups__name='Production Officers') + for user in production_officers: + notify.send(sender=sender, recipient=user, actor=instance.noted_by.user, verb=' created a new Production Event ', target=instance) + + +def notify_stream_completed(sender, instance, **kwargs): + """ + Notify the production team about a Production Stream being completed. + """ + production_officers = User.objects.filter(groups__name='Production Officers') + for user in production_officers: + notify.send(sender=sender, recipient=user, actor=sender, verb=' marked Production Stream as completed.', target=instance) diff --git a/production/views.py b/production/views.py index 06c723470234e8d9acf18dddf4a589860526be55..517c09c116ecdd90c20cc0dcdc5ba199f194a202 100644 --- a/production/views.py +++ b/production/views.py @@ -13,6 +13,7 @@ from guardian.decorators import permission_required from .constants import PRODUCTION_STREAM_COMPLETED from .models import ProductionStream, ProductionEvent from .forms import ProductionEventForm +from .signals import notify_stream_completed from scipost.models import Contributor @@ -107,6 +108,8 @@ def mark_as_completed(request, stream_id): stream.status = PRODUCTION_STREAM_COMPLETED stream.closed = timezone.now() stream.save() + + notify_stream_completed(request.user, stream) return redirect(reverse('production:production')) diff --git a/scipost/models.py b/scipost/models.py index d6cac15fd9be45affe5c0bfbe9e9ba47ace74861..31ad4a40f60bb649e49f8819bd5fd17f9a286558 100644 --- a/scipost/models.py +++ b/scipost/models.py @@ -7,7 +7,6 @@ from django.core.urlresolvers import reverse from django.contrib.auth.models import User from django.contrib.postgres.fields import ArrayField from django.db import models -from django.template import Template, Context from django.utils import timezone from django_countries.fields import CountryField diff --git a/scipost/static/scipost/assets/config/preconfig.scss b/scipost/static/scipost/assets/config/preconfig.scss index 37775af6b78bb58872d74ecd493da60bfb6d8464..7ddb10372dd8fed7fd054288653e0a21ca3e1024 100644 --- a/scipost/static/scipost/assets/config/preconfig.scss +++ b/scipost/static/scipost/assets/config/preconfig.scss @@ -7,6 +7,7 @@ // $base-border-radius: 2px; $border-radius: 2px; +$border-radius-lg: 2px; // Alert // @@ -93,6 +94,9 @@ $list-group-bg: transparent; $list-group-item-padding-x: 0; $list-group-item-padding-y: 0; $list-group-border-color: #ddd; +$list-group-active-color: $scipost-darkblue; +$list-group-active-bg: $scipost-lightestblue; +$list-group-active-border-color: $scipost-lightestblue; // Fonts // @@ -137,6 +141,16 @@ $blockquote-font-size: $font-size-base; // $blockquote-border-color: #ececec; +// Popover +// +$popover-border-color: #dddddd; +$popover-arrow-width: 5px; +$popover-arrow-height: 5px; +// $popover-arrow-color: $popover-bg; +$popover-max-width: 500px; + +$popover-arrow-outer-width: ($popover-arrow-width + 1px); + // --- diff --git a/scipost/static/scipost/assets/css/_alert.scss b/scipost/static/scipost/assets/css/_alert.scss index b60845a33bf55c71b223feb8416b4d3f27a19902..67ca539f4142e341a56a4f97b659e6e17216339a 100644 --- a/scipost/static/scipost/assets/css/_alert.scss +++ b/scipost/static/scipost/assets/css/_alert.scss @@ -1,5 +1,5 @@ .alert { - padding: 0.75rem 3rem 0.75rem 1.25rem; + padding: 0.75rem 1.25rem; margin-bottom: 0.5rem; position: relative; clear: both; diff --git a/scipost/static/scipost/assets/css/_badge.scss b/scipost/static/scipost/assets/css/_badge.scss new file mode 100644 index 0000000000000000000000000000000000000000..13ed52bb69e315f34a811d44613535b4bc54404c --- /dev/null +++ b/scipost/static/scipost/assets/css/_badge.scss @@ -0,0 +1,4 @@ +.badge { + vertical-align: bottom; + +} diff --git a/scipost/static/scipost/assets/css/_navbar.scss b/scipost/static/scipost/assets/css/_navbar.scss index e267881bddb4c174f1510f04f081e0672ea64320..452b2716637529ae319a7b1506839cd1a91581ce 100644 --- a/scipost/static/scipost/assets/css/_navbar.scss +++ b/scipost/static/scipost/assets/css/_navbar.scss @@ -73,3 +73,32 @@ white-space: nowrap; } } + +.navbar-counter { + position: relative; + + a.dropdown-toggle { + min-width: 45px; + } + + a:hover { + text-decoration: none; + + .badge { + background-color: $scipost-darkblue; + } + + .fa-bell-o:before { + content: "\f0f3"; + } + } + + .badge { + vertical-align: top; + margin-left: -5px; + margin-top: -2px; + height: 16px; + min-width: 16px; + line-height: 12px; + } +} diff --git a/scipost/static/scipost/assets/css/_popover.scss b/scipost/static/scipost/assets/css/_popover.scss new file mode 100644 index 0000000000000000000000000000000000000000..6cc116b60479e5d4661291068b077b55cec1d9c6 --- /dev/null +++ b/scipost/static/scipost/assets/css/_popover.scss @@ -0,0 +1,43 @@ +.popover { + width: 500px; + box-shadow: #ccc 0px 1px 2px 1px; +} + +.notifications { + .popover-body { + padding: 0; + } + + &.popover .list-group-item { + padding: 9px 14px; + border-radius: 0; + border-top: 1px solid #fff; + } + + .actions { + display: block; + opacity: 0.0; + transition: opacity 0.1s; + width: 20px; + float: right; + height: 100%; + + a:hover { + .fa-circle-o:before { + content: '\f111'; + } + + .fa-circle:before { + content: '\f10c'; + } + } + } + + .list-group-item:hover .actions { + opacity: 1.0; + } +} + +.popover-header { + font-size: initial; +} diff --git a/scipost/static/scipost/assets/css/_type.scss b/scipost/static/scipost/assets/css/_type.scss index 6aba22c09b1129e47f6037081f719db50a8b3812..9f21c44547325bbbf1055366f00574a18cc4a3b2 100644 --- a/scipost/static/scipost/assets/css/_type.scss +++ b/scipost/static/scipost/assets/css/_type.scss @@ -26,7 +26,8 @@ h1 > a { color: $scipost-darkblue; } -h3 { +h3, +h4 { line-height: normal; } diff --git a/scipost/static/scipost/assets/css/style.scss b/scipost/static/scipost/assets/css/style.scss index a9ca9127664dd156d871fe1213d93ddd118ca978..5c8bb75eef61d1059c45fa6a2e6d1f86d20e8b1f 100644 --- a/scipost/static/scipost/assets/css/style.scss +++ b/scipost/static/scipost/assets/css/style.scss @@ -16,6 +16,7 @@ @import "mixins_labels"; @import "alert"; +@import "badge"; @import "breadcrumb"; @import "buttons"; @import "cards"; @@ -30,6 +31,7 @@ @import "navbar"; @import "nav"; @import "page_header"; +@import "popover"; @import "tables"; @import "tooltip"; @import "type"; diff --git a/scipost/static/scipost/assets/js/notifications.js b/scipost/static/scipost/assets/js/notifications.js new file mode 100644 index 0000000000000000000000000000000000000000..533c57a74a540a983911e7a9e267f2b03e6b34d9 --- /dev/null +++ b/scipost/static/scipost/assets/js/notifications.js @@ -0,0 +1,157 @@ +var notify_badge_class = 'live_notify_badge'; +var notify_menu_class = 'live_notify_list'; +var notify_api_url_count = '/notifications/api/unread_count/'; +var notify_api_url_list = '/notifications/api/list/'; +var notify_api_url_toggle_read = '/notifications/mark-toggle'; +var notify_fetch_count = '5'; +var notify_refresh_period = 15000; +var consecutive_misfires = 0; +var registered_functions = [fill_notification_badge]; + + +function initiate_popover(reinitiate=false) { + var notification_template = '<div class="popover notifications" role="tooltip"><div class="arrow"></div><h3 class="popover-header h2"></h3><div class="popover-body"></div></div>'; + + var get_notifications_title = function() { + return 'New notifications <div class="badge badge-warning live_notify_badge"></div><div class="mt-1"><small><a href="/notifications">See all my notifications</a></small></div>'; + } + + var get_notifications = function() { + var _str = '<ul id="notification-list" class="update_notifications list-group"><div class="w-100 text-center py-4"><i class="fa fa-circle-o-notch fa-2x fa-spin fa-fw"></i><span class="sr-only">Loading...</span></div></ul>'; + get_notification_list(); + return _str; + } + + $('.popover [data-toggle="tooltip"]').tooltip('dispose') + $('#notifications_badge').popover('dispose').popover({ + animation: false, + trigger: 'click', + title: get_notifications_title, + template: notification_template, + content: get_notifications, + container: 'body', + offset: '0, 9px', + placement: "bottom", + html: true + }).on('inserted.bs.popover', function() { + // Bloody js + setTimeout(function() { + $('.popover [data-toggle="tooltip"]').tooltip({ + animation: false, + fallbackPlacement: 'clockwise', + placement: 'bottom' + }); + $('.popover .actions a').on('click', function() { + mark_toggle(this) + }) + }, 1000); + }); + if (reinitiate) { + $('#notifications_badge').popover('show') + } +} + + +function mark_toggle(el) { + var r = new XMLHttpRequest(); + r.addEventListener('readystatechange', function(event){ + if (this.readyState == 4 && this.status == 200) { + fetch_api_data() + initiate_popover(reinitiate=true) + } + }) + r.open("GET", notify_api_url_toggle_read +'/' + $(el).data('slug') + '/?json=1', true); + r.send(); +} + + +function fill_notification_badge(data) { + var badges = document.getElementsByClassName(notify_badge_class); + if (badges) { + for(var i = 0; i < badges.length; i++){ + badges[i].innerHTML = data.unread_count; + } + } +} + +function get_notification_list() { + var data = fetch_api_data(notify_api_url_list, function(data) { + + var messages = data.list.map(function (item) { + var message = ''; + if(typeof item.actor !== 'undefined'){ + message += '<strong>' + item.actor + '</strong>'; + } + if(typeof item.verb !== 'undefined'){ + message += " " + item.verb; + } + if(typeof item.target !== 'undefined'){ + if(typeof item.forward_link !== 'undefined') { + message += " <a href='" + item.forward_link + "'>" + item.target + "</a>"; + } else { + message += " " + item.target; + } + } + if(typeof item.timesince !== 'undefined'){ + message += " <div class='text-muted'>" + item.timesince + " ago</div>"; + } + + if(item.unread) { + var mark_as_read = '<div class="actions"><a href="#" data-slug="' + item.slug + '"><i class="fa fa-circle" data-toggle="tooltip" data-placement="auto" title="Mark as unread" aria-hidden="true"></i></a></div>'; + } else { + var mark_as_read = '<div class="actions"><a href="#" data-slug="' + item.slug + '"><i class="fa fa-circle-o" data-toggle="tooltip" data-placement="auto" title="Mark as read" aria-hidden="true"></i></a></div>'; + } + return '<li class="list-group-item ' + (item.unread ? ' active' : '') + '">' + mark_as_read + message + '</li>'; + }).join(''); + + if (messages == '') { + messages = '<div class="text-center px-2 py-3"><i class="fa fa-star-o fa-2x" aria-hidden="true"></i><h3>You have no new notifications</h3></div>' + } + + document.getElementById('notification-list').innerHTML = messages; + }); +} + +function fetch_api_data(url=null, callback=null) { + if (!url) { + var url = notify_api_url_count; + } + + if (registered_functions.length > 0) { + //only fetch data if a function is setup + var r = new XMLHttpRequest(); + r.addEventListener('readystatechange', function(event){ + if (this.readyState === 4){ + if (this.status === 200){ + consecutive_misfires = 0; + var data = JSON.parse(r.responseText); + registered_functions.forEach(function (func) { func(data); }); + if (callback) { + return callback(data); + } + }else{ + consecutive_misfires++; + } + } + }) + r.open("GET", url +'?max='+notify_fetch_count, true); + r.send(); + } + if (consecutive_misfires < 10) { + setTimeout(fetch_api_data,notify_refresh_period); + } else { + var badges = document.getElementsByClassName(notify_badge_class); + if (badges) { + for (var i = 0; i < badges.length; i++){ + badges[i].innerHTML = "!"; + badges[i].title = "Connection lost!" + } + } + } +} + +setTimeout(fetch_api_data, 1000); + +$(function(){ + initiate_popover(); +}); diff --git a/scipost/static/scipost/assets/js/scripts.js b/scipost/static/scipost/assets/js/scripts.js index c17d95694a341b8d5df160393ae149ec8214f5f9..e16263ae7d94b480e54096a08385c61e00e893ba 100644 --- a/scipost/static/scipost/assets/js/scripts.js +++ b/scipost/static/scipost/assets/js/scripts.js @@ -1,4 +1,5 @@ import tooltip from './tooltip.js'; +import notifications from './notifications.js'; function hide_all_alerts() { $(".alert").fadeOut(300); @@ -43,5 +44,5 @@ $(function(){ // Auto-submit hook for general form elements $("form .auto-submit input").on('change', function(){ $(this).parents('form').submit() - }) + }); }); diff --git a/scipost/static/scipost/assets/js/tooltip.js b/scipost/static/scipost/assets/js/tooltip.js index 1542867e52fa2936c8a818e9fa08b33a293ed78c..f636b484f0f08f51adc9dcc92ad1d83a4f6985e2 100644 --- a/scipost/static/scipost/assets/js/tooltip.js +++ b/scipost/static/scipost/assets/js/tooltip.js @@ -1,5 +1,9 @@ -jQuery('[data-toggle="tooltip"]').tooltip({ - animation: false, - fallbackPlacement: 'clockwise', - placement: 'auto' -}); +var activate_tooltip = function() { + jQuery('[data-toggle="tooltip"]').tooltip({ + animation: false, + fallbackPlacement: 'clockwise', + placement: 'auto' + }); +} + +activate_tooltip(); diff --git a/scipost/templates/scipost/bare_base.html b/scipost/templates/scipost/bare_base.html index 663e6e2da76ba8114249f5374c40a1a68dc575a9..6b326d991c7cb9bd84acf26c42158e272b0e350f 100644 --- a/scipost/templates/scipost/bare_base.html +++ b/scipost/templates/scipost/bare_base.html @@ -21,6 +21,8 @@ <link rel="stylesheet" href="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/smoothness/jquery-ui.css"> <script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script> + <script src="https://use.fontawesome.com/1f60a2fe64.js"></script><!-- Temporary --> + {% block headsup %} {% endblock headsup %} </head> diff --git a/scipost/templates/scipost/navbar.html b/scipost/templates/scipost/navbar.html index ed33013d14f50075d450eba4b2c9916846f1a638..2095054a8911f5e1770c891c825649bae7cd2bef 100644 --- a/scipost/templates/scipost/navbar.html +++ b/scipost/templates/scipost/navbar.html @@ -1,4 +1,9 @@ -<nav class="navbar navbar-scroll navbar-light main-nav"> +{% load staticfiles %} +{% load notifications_tags %} +{% load scipost_extras %} + + +<nav class="navbar navbar-scroll navbar-light main-nav navbar-expand-lg"> <div class="navbar-scroll-inner"> <ul class="navbar-nav mr-auto"> <li class="nav-item{% if request.path == '/' %} active{% endif %}"> @@ -21,9 +26,26 @@ </li> {% if user.is_authenticated %} + {% if request.user|is_in_group:'Testers' %} + <li class="nav-item highlighted dropdown navbar-counter"> + <div class="nav-link"> + <span class="user">{% if user.last_name %}{% if user.contributor %}{{ user.contributor.get_title_display }} {% endif %}{{ user.first_name }} {{ user.last_name }}{% else %}{{ user.username }}{% endif %}</span> + <a href="javascript:;" class="d-inline-block ml-1 dropdown-toggle" id="notifications_badge"> + <i class="fa fa-bell-o" aria-hidden="true"></i> + {% live_notify_badge classes="badge badge-pill badge-primary" %} + </a> + {% live_notify_list classes="update_notifications d-none" %} + </div> + + </li> + {% else %} <li class="nav-item highlighted"> <span class="nav-link">Logged in as {{ user.username }}</span> </li> + {% endif %} + <li class="nav-item"> + <a class="nav-link" href="{% url 'scipost:logout' %}">Logout</a> + </li> {% if perms.scipost.can_oversee_refereeing %} <li class="nav-item{% if '/submissions/admin' in request.path %} active{% endif %}"> <a class="nav-link" href="{% url 'submissions:admin' %}">Editorial Administration</a> @@ -39,9 +61,6 @@ <a class="nav-link" href="{% url 'partners:dashboard' %}">Partner Page</a> </li> {% endif %} - <li class="nav-item"> - <a class="nav-link" href="{% url 'scipost:logout' %}">Logout</a> - </li> {% else %} <li class="nav-item{% if request.path == '/login/' %} active{% endif %}"> <a class="nav-link" href="{% url 'scipost:login' %}">Login</a> diff --git a/webpack.config.js b/webpack.config.js index 54f4a619ad056e5ab3e515ec862394c1aedff8f5..8de385d65a2d81ab99706192d981c190ef952239 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -48,10 +48,21 @@ module.exports = { }, plugins: [ new webpack.ProvidePlugin({ - $: "jquery", - jQuery: "jquery", - "window.jQuery": "jquery", - Util: "exports-loader?Util!bootstrap/js/dist/util", + $: 'jquery', + jQuery: 'jquery', + Tether: 'tether', + 'window.Tether': 'tether', + // Alert: 'exports-loader?Alert!bootstrap/js/dist/alert', + // Button: 'exports-loader?Button!bootstrap/js/dist/button', + // Carousel: 'exports-loader?Carousel!bootstrap/js/dist/carousel', + // Collapse: 'exports-loader?Collapse!bootstrap/js/dist/collapse', + // Dropdown: 'exports-loader?Dropdown!bootstrap/js/dist/dropdown', + // Modal: 'exports-loader?Modal!bootstrap/js/dist/modal', + // Popover: 'exports-loader?Popover!bootstrap/js/dist/popover', + // Scrollspy: 'exports-loader?Scrollspy!bootstrap/js/dist/scrollspy', + // Tab: 'exports-loader?Tab!bootstrap/js/dist/tab', + Tooltip: "exports-loader?Tooltip!bootstrap/js/dist/tooltip", + Util: 'exports-loader?Util!bootstrap/js/dist/util', Popper: ['popper.js', 'default'], }), new BundleTracker({ @@ -65,8 +76,8 @@ module.exports = { dry: false, exclude: [] }), - new webpack.optimize.UglifyJsPlugin(), - new webpack.optimize.OccurrenceOrderPlugin(), - new webpack.optimize.AggressiveMergingPlugin() + // new webpack.optimize.UglifyJsPlugin(), + // new webpack.optimize.OccurrenceOrderPlugin(), + // new webpack.optimize.AggressiveMergingPlugin() ], }