diff --git a/hypha/apply/activity/context_processors.py b/hypha/apply/activity/context_processors.py new file mode 100644 index 0000000000000000000000000000000000000000..975680a3c1a58d4bec894d1d395eb519410fe01c --- /dev/null +++ b/hypha/apply/activity/context_processors.py @@ -0,0 +1,9 @@ +from .models import Activity + + +def notification_context(request): + context_data = dict() + if hasattr(request, 'user'): + if request.user.is_authenticated and request.user.is_apply_staff: + context_data['latest_notifications'] = Activity.objects.latest().order_by('-timestamp')[:5] + return context_data diff --git a/hypha/apply/activity/filters.py b/hypha/apply/activity/filters.py new file mode 100644 index 0000000000000000000000000000000000000000..899c5d5f533e0a77f8afca289760c49f2cbfcba6 --- /dev/null +++ b/hypha/apply/activity/filters.py @@ -0,0 +1,43 @@ +from datetime import timedelta + +import django_filters +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +from django_filters.filters import DateRangeFilter, _truncate + +from .models import Activity + + +class NotificationFilter(django_filters.FilterSet): + timestamp_choices = [ + ('today', _('Today')), + ('yesterday', _('Yesterday')), + ('week', _('Past 7 days')), + ('month', _('This month')) + ] + timestamp_filters = { + 'today': lambda qs, name: qs.filter(**{ + '%s__year' % name: now().year, + '%s__month' % name: now().month, + '%s__day' % name: now().day + }), + 'yesterday': lambda qs, name: qs.filter(**{ + '%s__year' % name: (now() - timedelta(days=1)).year, + '%s__month' % name: (now() - timedelta(days=1)).month, + '%s__day' % name: (now() - timedelta(days=1)).day, + }), + 'week': lambda qs, name: qs.filter(**{ + '%s__gte' % name: _truncate(now() - timedelta(days=7)), + '%s__lt' % name: _truncate(now() + timedelta(days=1)), + }), + 'month': lambda qs, name: qs.filter(**{ + '%s__year' % name: now().year, + '%s__month' % name: now().month + }) + } + + date = DateRangeFilter(field_name='timestamp', choices=timestamp_choices, filters=timestamp_filters) + + class Meta: + model = Activity + fields = {} diff --git a/hypha/apply/activity/models.py b/hypha/apply/activity/models.py index 2b6c99ff21cfe71e350242cb2ac4637f180fc20a..99a4010044e866db751d13ca0cb5314fb4a382b3 100644 --- a/hypha/apply/activity/models.py +++ b/hypha/apply/activity/models.py @@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType from django.db import models from django.db.models import Case, Value, When from django.db.models.functions import Concat +from django.utils import timezone from .options import MESSAGES @@ -54,6 +55,9 @@ class ActivityQuerySet(BaseActivityQuerySet): def actions(self): return self.filter(type=ACTION) + def latest(self): + return self.filter(timestamp__gte=(timezone.now() - timezone.timedelta(days=30))) + class ActivityBaseManager(models.Manager): def create(self, **kwargs): diff --git a/hypha/apply/activity/templates/activity/include/notifications_dropdown.html b/hypha/apply/activity/templates/activity/include/notifications_dropdown.html new file mode 100644 index 0000000000000000000000000000000000000000..b6212ab5db6c3c7ca63196ff53007a25f0a64aa8 --- /dev/null +++ b/hypha/apply/activity/templates/activity/include/notifications_dropdown.html @@ -0,0 +1,19 @@ +{% load i18n activity_tags bleach_tags markdown_tags submission_tags apply_tags %} + +<div class="notifications notifications--dropdown"> + <a href="#" class="button button--contains-icons notifications__bell" aria-label="{% trans "Notifications" %}" aria-haspopup="activity" aria-expanded="false" role="button"> + <svg class="icon"><use xlink:href="#bell-icon"></use></svg> + </a> + <div class="notifications__content zeta hidden" role="activity"> + <h5>Notifications</h5> + {% for activity in latest_notifications %} + <p class="notifications__item"> + <strong>{{ activity.source_content_type.name|source_type }} </strong> + <a href="{{ activity.source.get_absolute_url }}{% ifequal activity.type 'comment' %}#communications{% endifequal %}">{{ activity.source.title|capfirst|truncatechars:15 }}</a> + : {{ activity.user.get_full_name }} {% ifequal activity.type 'comment' %}{% trans "made a comment" %}{% else %} {{ activity|display_for:request.user }}{% endifequal %} + {% if activity.related_object %}<a href="{{ activity.related_object.get_absolute_url }}">{{ activity.related_object|model_verbose_name }}</a>{% endif %} + </p> + {% endfor %} + <p class="notifications__more"><a href="{% url "activity:notifications" %}">Show All</a></p> + </div> +</div> diff --git a/hypha/apply/activity/templates/activity/notifications.html b/hypha/apply/activity/templates/activity/notifications.html new file mode 100644 index 0000000000000000000000000000000000000000..1b9f19f82253ded4721e1016ba938cbd32c16bb5 --- /dev/null +++ b/hypha/apply/activity/templates/activity/notifications.html @@ -0,0 +1,72 @@ +{% extends "base-apply.html" %} +{% load i18n static activity_tags apply_tags bleach_tags markdown_tags submission_tags %} + +{% block content %} +<div class="admin-bar"> + <div class="admin-bar__inner"> + <div class="admin-bar__inner--with-button"> + <h1 class="gamma heading heading--no-margin heading--bold">{% trans "Notifications" %}</h1> + <form class="form notifications__filters" method="get"> + {{ filter.form }} + <button class="button button--primary" type="submit" value="Filter">{% trans "Filter" %}</button> + </form> + </div> + + <div class="tabs js-tabs"> + <div class="tabs__container"> + <a class="tab__item" href="#comments" data-tab="tab-1"> + {% trans "Communications" %} + </a> + + <a class="tab__item" href="#actions" data-tab="tab-2"> + {% trans "Activity Feed" %} + </a> + </div> + </div> + </div> +</div> + +<div class="wrapper wrapper--large wrapper--tabs js-tabs-content"> + {# Tab 1 #} + <div class="tabs__content" id="tab-1"> + {% for activity in object_list %} + {% if activity.type == 'comment' %} + <div class="feed__item feed__item--{{ activity.type }}"> + <div class="feed__pre-content"> + <p class="feed__label feed__label--{{ activity.source_content_type.name|source_type|lower }}">{{ activity.source_content_type.name|source_type }}</p> + </div> + <div class="feed__content js-feed-content"> + <div class="feed__meta js-feed-meta"> + <p class="feed__meta-item"><a href="{{ activity.source.get_absolute_url }}#communications">{{ activity.source.title|capfirst }}</a> + : {{ activity.user.get_full_name }} {% trans "made a comment" %} – {{ activity.timestamp|date:"SHORT_DATE_FORMAT" }}</p> + </div> + </div> + </div> + {% endif %} + {% endfor %} + </div> + {# Tab 2 #} + <div class="tabs__content" id="tab-2"> + {% for activity in object_list %} + {% if activity.type == 'action' %} + <div class="feed__item feed__item--{{ activity.type }}"> + <div class="feed__pre-content"> + <p class="feed__label feed__label--{{ activity.source_content_type.name|source_type|lower }}">{{ activity.source_content_type.name|source_type }}</p> + </div> + <div class="feed__content js-feed-content"> + <div class="feed__meta js-feed-meta"> + <p class="feed__meta-item"><a href="{{ activity.source.get_absolute_url }}">{{ activity.source.title|capfirst }}</a> + : {{ activity.user.get_full_name }} – {{ activity.timestamp|date:"SHORT_DATE_FORMAT" }} – {{ activity|display_for:request.user }} {% if activity.related_object %}<a href="{{ activity.related_object.get_absolute_url }}" class="feed__related-item">{{ activity.related_object|model_verbose_name }} <svg><use xlink:href="#arrow-head-pixels--solid"></use></svg></a>{% endif %}</p> + </div> + </div> + </div> + {% endif %} + {% endfor %} + </div> +</div> + +{% endblock %} + +{% block extra_js %} + <script src="{% static 'js/apply/tabs.js' %}"></script> +{% endblock %} diff --git a/hypha/apply/activity/templatetags/activity_tags.py b/hypha/apply/activity/templatetags/activity_tags.py index 2df2a15d1c8c19f9020ff1f86e0bc93ac3e36fff..5b0dc4b115d4c15938af7ca91326b02de096ec16 100644 --- a/hypha/apply/activity/templatetags/activity_tags.py +++ b/hypha/apply/activity/templatetags/activity_tags.py @@ -55,3 +55,10 @@ def display_for(activity, user): def visibility_options(activity, user): choices = activity.visibility_choices_for(user) return json.dumps(choices) + + +@register.filter +def source_type(value): + if value and "submission" in value: + return "Submission" + return str(value).capitalize() diff --git a/hypha/apply/activity/urls.py b/hypha/apply/activity/urls.py index 19c0855275d45f3e4ee2837471121ea044a2d764..4ba4a55f802e7aea772d0985530b43e7576a69ee 100644 --- a/hypha/apply/activity/urls.py +++ b/hypha/apply/activity/urls.py @@ -1,8 +1,11 @@ from django.urls import include, path +from .views import NotificationsView + app_name = 'activity' urlpatterns = [ path('anymail/', include('anymail.urls')), + path('notifications/', NotificationsView.as_view(), name='notifications') ] diff --git a/hypha/apply/activity/views.py b/hypha/apply/activity/views.py index 7b75221f084be8092a1fbf1062a7b649c4cf138e..0af55e2b554adbd9aa64bce00b9415388a0cdf3c 100644 --- a/hypha/apply/activity/views.py +++ b/hypha/apply/activity/views.py @@ -1,8 +1,11 @@ from django.utils import timezone -from django.views.generic import CreateView +from django.utils.decorators import method_decorator +from django.views.generic import CreateView, ListView +from hypha.apply.users.decorators import staff_required from hypha.apply.utils.views import DelegatedViewMixin +from .filters import NotificationFilter from .forms import CommentForm from .messaging import MESSAGES, messenger from .models import COMMENT, Activity @@ -57,3 +60,21 @@ class CommentFormView(DelegatedViewMixin, CreateView): kwargs = super().get_form_kwargs() kwargs.pop('instance') return kwargs + + +@method_decorator(staff_required, name='dispatch') +class NotificationsView(ListView): + model = Activity + template_name = 'activity/notifications.html' + filterset_class = NotificationFilter + + def get_queryset(self): + # List only last 30 days' activities + queryset = Activity.objects.latest() + self.filterset = self.filterset_class(self.request.GET, queryset=queryset) + return self.filterset.qs.distinct().order_by('-timestamp') + + def get_context_data(self, *, object_list=None, **kwargs): + context = super(NotificationsView, self).get_context_data() + context['filter'] = self.filterset + return context diff --git a/hypha/settings/base.py b/hypha/settings/base.py index 07e5af2b9dc97fad7fbfc133c753e5d03903e143..7524adc3ed3fff2b1c147312881f99f6209d6386 100644 --- a/hypha/settings/base.py +++ b/hypha/settings/base.py @@ -179,6 +179,7 @@ TEMPLATES = [ 'social_django.context_processors.login_redirect', 'hypha.apply.projects.context_processors.projects_enabled', 'hypha.cookieconsent.context_processors.cookies_accepted', + 'hypha.apply.activity.context_processors.notification_context', ], }, }, diff --git a/hypha/static_src/src/javascript/apply/notifications.js b/hypha/static_src/src/javascript/apply/notifications.js new file mode 100644 index 0000000000000000000000000000000000000000..a24bcd16282a16723886992f228f7b78a8189d96 --- /dev/null +++ b/hypha/static_src/src/javascript/apply/notifications.js @@ -0,0 +1,21 @@ +(function () { + + 'use strict'; + + // Open/close dropdown when users clicks the bell. + document.querySelector('.notifications__bell').addEventListener('click', function () { + document.querySelector('.notifications__content').classList.toggle('hidden'); + }); + + // Close the dropdown menu if the user clicks outside of it. + window.onclick = function (event) { + if (!event.target.matches('.notifications--dropdown, .notifications--dropdown *')) { + const dropdown = document.querySelector('.notifications__content'); + if (!dropdown.classList.contains('hidden')) { + dropdown.classList.add('hidden'); + } + } + }; + + +})(); diff --git a/hypha/static_src/src/sass/apply/components/_activity-notifications.scss b/hypha/static_src/src/sass/apply/components/_activity-notifications.scss new file mode 100644 index 0000000000000000000000000000000000000000..132a6f3739624909f37c61c1aec06c40cf12ff5c --- /dev/null +++ b/hypha/static_src/src/sass/apply/components/_activity-notifications.scss @@ -0,0 +1,54 @@ +.notifications { + &--dropdown { + position: relative; + display: inline-block; + z-index: 200; + } + + &__bell { + padding: 7px 12px; + cursor: pointer; + } + + &__content { + position: absolute; + right: 1em; + padding: 1em; + margin-top: .5em; + background-color: $color--light-grey; + min-width: 400px; + box-shadow: 2px 2px 6px 1px $color--dark-grey; + } + + &__item { + padding-bottom: 1em; + border-bottom: 1px solid $color--dark-grey; + } + + &__more { + text-align: center; + font-weight: $weight--semibold; + } + + &__filters { + display: flex; + align-items: center; + padding: 4px; + justify-content: space-between; + + label { + font-weight: $weight--semibold; + padding-right: 1em; + } + + select { + padding-right: 1em; + } + + .form { + &__select { + margin-right: 1em; + } + } + } +} diff --git a/hypha/static_src/src/sass/apply/components/_feed.scss b/hypha/static_src/src/sass/apply/components/_feed.scss index 881f1f7366ca0fe62684aed28299d765d4ca500e..83d342d4b649be9649f3864790c004b368c1cb52 100644 --- a/hypha/static_src/src/sass/apply/components/_feed.scss +++ b/hypha/static_src/src/sass/apply/components/_feed.scss @@ -52,6 +52,14 @@ background-color: $color--mint; } + &--submission { + background-color: $color--green; + } + + &--project { + background-color: $color--mint; + } + &--mobile { display: block; margin-right: 10px; @@ -152,6 +160,8 @@ } &__related-item { + white-space: nowrap; + svg { width: 10px; height: 14px; diff --git a/hypha/static_src/src/sass/apply/main.scss b/hypha/static_src/src/sass/apply/main.scss index 150d659960e74af3bfc4b8da72db452846c236db..c0a9ee4695a03f7b752a13e6e2885f2ef8a5329e 100644 --- a/hypha/static_src/src/sass/apply/main.scss +++ b/hypha/static_src/src/sass/apply/main.scss @@ -68,6 +68,7 @@ @import 'components/reminder-sidebar'; @import 'components/two-factor'; @import 'components/determination'; +@import 'components/activity-notifications'; // Layout @import 'layout/header'; diff --git a/hypha/templates/base-apply.html b/hypha/templates/base-apply.html index 33f22e725619cd2612d509ed6b8ef55fdaa7d3b1..ce30affbfe32ab0159909b4e437e530651f3c3f1 100644 --- a/hypha/templates/base-apply.html +++ b/hypha/templates/base-apply.html @@ -30,6 +30,9 @@ <script src="{% static 'js/jquery.min.js' %}"></script> <script src="{% static 'js/js.cookie.min.js' %}"></script> <script src="{% static 'js/main-top.js' %}"></script> + {% if latest_notifications %} + <script defer src="{% static 'js/apply/notifications.js' %}"></script> + {% endif %} {% if COOKIES_ACCEPTED and MATOMO_URL and MATOMO_SITEID %} {# we are only expecting strings, so make sure we escape the values #} <script> @@ -104,6 +107,9 @@ </section> <div class="header__button-container"> + {% if latest_notifications %} + {% include "activity/include/notifications_dropdown.html" %} + {% endif %} <a href="{% url 'users:account' %}" class="button button--transparent button--narrow button--contains-icons"> <svg class="icon icon--person"><use xlink:href="#person-icon"></use></svg> {{ request.user }} diff --git a/hypha/templates/includes/sprites.html b/hypha/templates/includes/sprites.html index dbd3555ac3bc152ae8e659e76d767b12d7d20373..ed6584bc88c0f78eb1e250d10e41e6fb3f2cb877 100644 --- a/hypha/templates/includes/sprites.html +++ b/hypha/templates/includes/sprites.html @@ -119,6 +119,10 @@ <path d="M17 9.81V16h-3.644v-5.776c0-1.45-.527-2.441-1.846-2.441-1.006 0-1.605.667-1.87 1.313-.095.23-.12.552-.12.875V16H5.875s.05-9.782 0-10.796H9.52v1.53l-.024.035h.024v-.035c.484-.734 1.349-1.783 3.284-1.783C15.202 4.95 17 6.494 17 9.81zM2.062 0C.816 0 0 .806 0 1.865 0 2.9.792 3.73 2.014 3.73h.024c1.272 0 2.062-.83 2.062-1.866C4.076.805 3.31 0 2.062 0zM.216 16H3.86V5.204H.216V16z" fill-rule="nonzero" /> </symbol> + <symbol id="bell-icon" viewBox="0 0 16 16"> + <path d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2zm.995-14.901a1 1 0 1 0-1.99 0A5.002 5.002 0 0 0 3 6c0 1.098-.5 6-2 7h14c-1.5-1-2-5.902-2-7 0-2.42-1.72-4.44-4.005-4.901z"/> + </symbol> + <symbol id="arrow-head-pixels--transparent" viewBox="0 0 50 75"> <g fill="#25AAE1" fill-rule="evenodd"> <path opacity=".2" d="M0 50h25v25H0z" />