diff --git a/SciPost_v1/settings/base.py b/SciPost_v1/settings/base.py index f3a4ed8e5a68b10a591d4081d9ea2e8ec02948fa..03d9cf3ce8d1e8936c1642b72c4e9ea0e7df3b2c 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. 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..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 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..9c260e0b160c191b17c8e13b85986e5368eda49e --- /dev/null +++ b/notifications/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + name = 'notifications' diff --git a/notifications/managers.py b/notifications/managers.py new file mode 100644 index 0000000000000000000000000000000000000000..31346a0c7d4bde14029fe817ebe24138d81a5cb9 --- /dev/null +++ b/notifications/managers.py @@ -0,0 +1,76 @@ +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..04e2aff1e78dcfd1b8ddb67ae9a6977aa6487abc --- /dev/null +++ b/notifications/models.py @@ -0,0 +1,99 @@ +from django.db import models +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 __unicode__(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 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.timestamp, now) + + @property + def slug(self): + from .utils import id2slug + return id2slug(self.id) + + 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/templates/notifications/notification_list.html b/notifications/templates/notifications/notification_list.html new file mode 100644 index 0000000000000000000000000000000000000000..6b3192b643d838bbb37bc8d549bd3a2bf9638233 --- /dev/null +++ b/notifications/templates/notifications/notification_list.html @@ -0,0 +1,16 @@ +{% extends 'scipost/_personal_page_base.html' %} + +{% block breadcrumb_items %} + {{block.super}} + <span class="breadcrumb-item">My notifications ({{notifications|length}})</span> +{% endblock %} + +{% block pagetitle %}: My notifications ({{notifications|length}}){% endblock pagetitle %} + +{% block content %} + <ul class="list-group notifications"> + {% for notice in notifications %} + {% include 'notifications/partials/notice.html' %} + {% 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..2855a53ed7a4bfb4a2436008a54a2ee549d186e6 --- /dev/null +++ b/notifications/templates/notifications/partials/notice.html @@ -0,0 +1,18 @@ +<div class="list-group-item list-group-item-action flex-column align-items-start{% if notice.unread %} active{% endif %} px-3 py-2"> + <div class=""> + <a href="{% url 'notifications:mark_as_read' notice.slug %}" class="close" aria-hidden="true">×</a> + <h4 class="mb-1x"> + <strong>{{ notice.actor }}</strong> + {{ notice.verb }} + {% if notice.target %} + {{ notice.target }} + {% endif %} + </h4> + <div class="text-muted">{{ notice.created|timesince }} ago</div> + + </div> + <p class="mb-1">{{ notice.description|linebreaksbr }}</p> + {% if notice.target %} + <a href="{{notice.target.get_absolute_url}}">Go to {{notice.target_content_type}}</a> + {% endif %} +</div> 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..20f0e4db98910b81f95e8eaaf34da119970863eb --- /dev/null +++ b/notifications/urls.py @@ -0,0 +1,17 @@ +from django.conf.urls import url + +from . import views + + +urlpatterns = [ + url(r'^$', views.AllNotificationsList.as_view(), name='all'), + url(r'^unread/$', views.UnreadNotificationsList.as_view(), name='unread'), + url(r'^mark-all-as-read/$', views.mark_all_as_read, name='mark_all_as_read'), + url(r'^mark-as-read/(?P<slug>\d+)/$', views.mark_as_read, name='mark_as_read'), + url(r'^mark-as-unread/(?P<slug>\d+)/$', views.mark_as_unread, name='mark_as_unread'), + 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/unread_list/$', views.live_unread_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..d583504aa2776011b448bac76bcc59ae70b70779 --- /dev/null +++ b/notifications/views.py @@ -0,0 +1,129 @@ +from django.contrib.auth.decorators import login_required +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 + + +@method_decorator(login_required, 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() + + +class UnreadNotificationsList(NotificationViewList): + def get_queryset(self): + return self.request.user.notifications.unread() + + +@login_required +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:unread') + + +@login_required +def mark_as_read(request, slug=None): + id = slug2id(slug) + + notification = get_object_or_404(Notification, recipient=request.user, id=id) + notification.mark_as_read() + + _next = request.GET.get('next') + + if _next: + return redirect(_next) + + return redirect('notifications:unread') + + +@login_required +def mark_as_unread(request, slug=None): + id = slug2id(slug) + + notification = get_object_or_404(Notification, recipient=request.user, id=id) + notification.mark_as_unread() + + _next = request.GET.get('next') + + if _next: + return redirect(_next) + + return redirect('notifications:unread') + + +@login_required +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_unread_notification_list(request): + if not request.user.is_authenticated(): + data = { + 'unread_count': 0, + 'unread_list': [] + } + return JsonResponse(data) + + try: + # Default to 5 as a max number of notifications + num_to_fetch = max(int(request.GET.get('max', 5))) + num_to_fetch = min(num_to_fetch, 100) + except ValueError: + num_to_fetch = 5 + + unread_list = [] + + for n in request.user.notifications.unread()[:num_to_fetch]: + struct = model_to_dict(n) + struct['slug'] = id2slug(n.id) + if n.actor: + struct['actor'] = str(n.actor) + if n.target: + struct['target'] = str(n.target) + if n.action_object: + struct['action_object'] = str(n.action_object) + if n.data: + struct['data'] = n.data + unread_list.append(struct) + if request.GET.get('mark_as_read'): + n.mark_as_read() + data = { + 'unread_count': request.user.notifications.unread().count(), + 'unread_list': unread_list + } + return JsonResponse(data) diff --git a/scipost/static/scipost/assets/config/preconfig.scss b/scipost/static/scipost/assets/config/preconfig.scss index 37775af6b78bb58872d74ecd493da60bfb6d8464..de82cbe66450c12f521a9d19fe1e880c38d43283 100644 --- a/scipost/static/scipost/assets/config/preconfig.scss +++ b/scipost/static/scipost/assets/config/preconfig.scss @@ -93,6 +93,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 // 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;