diff --git a/hypha/apply/funds/tables.py b/hypha/apply/funds/tables.py index 3ae089117073673c9ea628347f158fdf654f9b79..4b0644e11709cad8988bdecd05513c9c14456c85 100644 --- a/hypha/apply/funds/tables.py +++ b/hypha/apply/funds/tables.py @@ -16,12 +16,15 @@ from wagtail.core.models import Page from hypha.apply.categories.models import MetaTerm from hypha.apply.funds.models import ApplicationSubmission, Round, ScreeningStatus from hypha.apply.funds.workflow import STATUSES, get_review_active_statuses +from hypha.apply.review.models import Review from hypha.apply.users.groups import STAFF_GROUP_NAME from hypha.apply.utils.image import generate_image_tag from hypha.images.models import CustomImage from .widgets import Select2MultiCheckboxesWidget +User = get_user_model() + def review_filter_for_user(user): review_states = set(get_review_active_statuses(user)) @@ -49,7 +52,11 @@ def render_actions(table, record): def render_title(record): - return textwrap.shorten(record.title, width=30, placeholder="...") + try: + title = record.title + except AttributeError: + title = record.submission.title + return textwrap.shorten(title, width=30, placeholder="...") class SubmissionsTable(tables.Table): @@ -171,13 +178,11 @@ def get_used_funds(request): def get_round_leads(request): - User = get_user_model() return User.objects.filter(submission_lead__isnull=False).distinct() def get_reviewers(request): """ All assigned reviewers, staff or admin """ - User = get_user_model() return User.objects.filter(Q(submissions_reviewer__isnull=False) | Q(groups__name=STAFF_GROUP_NAME) | Q(is_superuser=True)).distinct() @@ -359,3 +364,82 @@ class RoundsFilter(filters.FilterSet): lead = Select2ModelMultipleChoiceFilter(queryset=get_round_leads, label='Leads') active = ActiveRoundFilter(label='Active') round_state = OpenRoundFilter(label='Open') + + +class ReviewerLeaderboardFilterForm(forms.ModelForm): + """ + Form to "clean" a list of User objects to their PKs. + + The Reviewer Leaderboard table is a list of User objects, however we also want + the ability to filter down to N Users (reviewers). Django filter is converting + the selected PKs to User objects, however we can't filter a User QuerySet + with User objects. So this form converts back to a list of User PKs using + the clean_reviewer method. + """ + class Meta: + fields = ["id"] + model = User + + def clean_reviewer(self): + return [u.id for u in self.cleaned_data['reviewer']] + + +class ReviewerLeaderboardFilter(filters.FilterSet): + query = filters.CharFilter(field_name='full_name', lookup_expr="icontains", widget=forms.HiddenInput) + + reviewer = Select2ModelMultipleChoiceFilter( + field_name='pk', + label='Reviewers', + queryset=get_reviewers, + ) + funds = Select2ModelMultipleChoiceFilter( + field_name='submission__page', + label='Funds', + queryset=get_used_funds, + ) + rounds = Select2ModelMultipleChoiceFilter( + field_name='submission__round', + label='Rounds', + queryset=get_used_rounds, + ) + + class Meta: + fields = [ + 'reviewer', + 'funds', + 'rounds', + ] + form = ReviewerLeaderboardFilterForm + model = User + + +class ReviewerLeaderboardTable(tables.Table): + full_name = tables.LinkColumn('funds:submissions:reviewer_leaderboard_detail', args=[A('pk')], orderable=True, verbose_name="Reviewer", attrs={'td': {'class': 'title'}}) + + class Meta: + model = User + fields = [ + 'full_name', + 'total', + 'ninety_days', + 'this_year', + 'last_year', + ] + order_by = ('-total',) + attrs = {'class': 'all-reviews-table'} + empty_text = _('No reviews available') + + +class ReviewerLeaderboardDetailTable(tables.Table): + title = tables.LinkColumn('funds:submissions:reviews:review', text=render_title, args=[A('submission_id'), A('pk')], orderable=True, verbose_name="Submission", attrs={'td': {'data-title-tooltip': lambda record: record.submission.title, 'class': 'title js-title'}}) + + class Meta: + model = Review + fields = [ + 'title', + 'recommendation', + 'created_at', + ] + order_by = ('-created_at',) + attrs = {'class': 'all-reviews-table'} + empty_text = _('No reviews available') diff --git a/hypha/apply/funds/templates/funds/reviewer_leaderboard.html b/hypha/apply/funds/templates/funds/reviewer_leaderboard.html new file mode 100644 index 0000000000000000000000000000000000000000..a4b345da1d8651a0b31ffddb6234defeda1061a9 --- /dev/null +++ b/hypha/apply/funds/templates/funds/reviewer_leaderboard.html @@ -0,0 +1,29 @@ +{% extends "funds/submissions_overview.html" %} +{% load static %} +{% load render_table from django_tables2 %} + +{% block title %}Reviews{% endblock %} + +{% block content %} +<div class="admin-bar"> + <div class="admin-bar__inner wrapper--search"> + {% block page_header %} + <div> + <h1 class="gamma heading heading--no-margin heading--bold">Reviewer Leaderboard</h1> + <h5>Track and explore the reviews</h5> + </div> + {% endblock %} + {% block page_header_tabs %} + {{ block.super }} + {% endblock %} + </div> +</div> + +<div class="wrapper wrapper--large wrapper--inner-space-medium"> + {% block table %} + {% include "funds/includes/table_filter_and_search.html" with filter_form=filter_form search_term=search_term search_placeholder="reviewers" use_search=True filter_action=filter_action use_batch_actions=False heading="All reviewers" %} + + {% render_table table %} + {% endblock %} +</div> +{% endblock %} diff --git a/hypha/apply/funds/templates/funds/reviewer_leaderboard_detail.html b/hypha/apply/funds/templates/funds/reviewer_leaderboard_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..2c063c3761eac442f2edef32997a488aecd12197 --- /dev/null +++ b/hypha/apply/funds/templates/funds/reviewer_leaderboard_detail.html @@ -0,0 +1,27 @@ +{% extends "funds/submissions_overview.html" %} +{% load static %} +{% load render_table from django_tables2 %} + +{% block title %}Reviews{% endblock %} + +{% block content %} +<div class="admin-bar"> + <div class="admin-bar__inner wrapper--search"> + {% block page_header %} + <div> + <h1 class="gamma heading heading--no-margin heading--bold">Reviews by {{ object }}</h1> + <h5>Track and explore the reviews</h5> + </div> + {% endblock %} + {% block page_header_tabs %} + {{ block.super }} + {% endblock %} + </div> +</div> + +<div class="wrapper wrapper--large wrapper--inner-space-medium"> + {% block table %} + {% render_table table %} + {% endblock %} +</div> +{% endblock %} diff --git a/hypha/apply/funds/templates/funds/submissions_overview.html b/hypha/apply/funds/templates/funds/submissions_overview.html index b83f169b8d51f75cb78d742ef53ffb4157eed90b..ed04c2dc743df2fd3db0de08f02a48e02fc19393 100644 --- a/hypha/apply/funds/templates/funds/submissions_overview.html +++ b/hypha/apply/funds/templates/funds/submissions_overview.html @@ -10,9 +10,14 @@ <h1 class="gamma heading heading--no-margin heading--bold">Submissions</h1> <h5>Track and explore recent submissions</h5> </div> + {% endblock %} + {% block page_header_tabs %} {% if request.user.is_apply_staff %} <div class="tabs"> <div class="tabs__container"> + <a class="tab__item tab__item--right" href="{% url 'apply:submissions:reviewer_leaderboard' %}"> + Reviews + </a> <a class="tab__item tab__item--right" href="{% url 'apply:submissions:result' %}"> Results </a> diff --git a/hypha/apply/funds/templates/funds/submissions_result.html b/hypha/apply/funds/templates/funds/submissions_result.html index 6510c3a8d294e3b2bced8e5ea18d0ed1a2ea472f..8b5afd483b1f64bf45ed2335791a9fc99707fc7c 100644 --- a/hypha/apply/funds/templates/funds/submissions_result.html +++ b/hypha/apply/funds/templates/funds/submissions_result.html @@ -1,19 +1,19 @@ -{% extends "base-apply.html" %} +{% extends "funds/submissions_overview.html" %} {% load static %} {% block title %}Submissions results{% endblock %} -{% block extra_css %} -{{ filter.form.media.css }} -{% endblock %} - {% block content %} <div class="admin-bar"> <div class="admin-bar__inner wrapper--search"> {% block page_header %} <div> <h1 class="gamma heading heading--no-margin heading--bold">Submissions results</h1> + <h5>Track and explore the results</h5> </div> {% endblock %} + {% block page_header_tabs %} + {{ block.super }} + {% endblock %} </div> </div> @@ -27,9 +27,3 @@ </div> </div> {% endblock %} - -{% block extra_js %} - {{ filter.form.media.js }} - <script src="https://cdnjs.cloudflare.com/ajax/libs/url-search-params/1.1.0/url-search-params.js"></script> - <script src="{% static 'js/apply/submission-filters.js' %}"></script> -{% endblock %} diff --git a/hypha/apply/funds/tests/test_views.py b/hypha/apply/funds/tests/test_views.py index 36213ca12ad78a1d106a508771f3c12f84eda103..46ad7bd97eef0f514cee1a31f1470bd8999a62b7 100644 --- a/hypha/apply/funds/tests/test_views.py +++ b/hypha/apply/funds/tests/test_views.py @@ -5,7 +5,7 @@ from bs4 import BeautifulSoup from django.contrib.auth.models import AnonymousUser from django.core.exceptions import PermissionDenied from django.http import Http404 -from django.test import RequestFactory, TestCase +from django.test import RequestFactory, TestCase, override_settings from django.urls import reverse from django.utils import timezone from django.utils.text import slugify @@ -30,6 +30,8 @@ from hypha.apply.projects.tests.factories import ProjectFactory from hypha.apply.review.tests.factories import ReviewFactory from hypha.apply.users.tests.factories import ( ApplicantFactory, + CommunityReviewerFactory, + PartnerFactory, ReviewerFactory, StaffFactory, SuperUserFactory, @@ -1214,3 +1216,31 @@ class TestUserReminderDeleteView(BaseProjectDeleteTestCase): reminder = ReminderFactory() response = self.get_page(reminder) self.assertEqual(response.status_code, 403) + + +@override_settings(ROOT_URLCONF='hypha.apply.urls') +class TestReviewerLeaderboard(TestCase): + def test_applicant_cannot_access_reviewer_leaderboard(self): + self.client.force_login(ApplicantFactory()) + response = self.client.get('/apply/submissions/reviews/', follow=True, secure=True) + self.assertEqual(response.status_code, 403) + + def test_community_reviewer_cannot_access_reviewer_leaderboard(self): + self.client.force_login(CommunityReviewerFactory()) + response = self.client.get('/apply/submissions/reviews/', follow=True, secure=True) + self.assertEqual(response.status_code, 403) + + def test_partner_cannot_access_reviewer_leaderboard(self): + self.client.force_login(PartnerFactory()) + response = self.client.get('/apply/submissions/reviews/', follow=True, secure=True) + self.assertEqual(response.status_code, 403) + + def test_reviewer_cannot_access_leader_board(self): + self.client.force_login(ReviewerFactory()) + response = self.client.get('/apply/submissions/reviews/', follow=True, secure=True) + self.assertEqual(response.status_code, 403) + + def test_staff_can_access_leaderboard(self): + self.client.force_login(StaffFactory()) + response = self.client.get('/apply/submissions/reviews/', follow=True, secure=True) + self.assertEqual(response.status_code, 200) diff --git a/hypha/apply/funds/urls.py b/hypha/apply/funds/urls.py index fc8321ededdefe1c62fde713eb714f114e5d3826..d63cbdf5b699e81ced2c6cb1813754a184497c09 100644 --- a/hypha/apply/funds/urls.py +++ b/hypha/apply/funds/urls.py @@ -4,6 +4,8 @@ from hypha.apply.projects import urls as projects_urls from .views import ( ReminderDeleteView, + ReviewerLeaderboard, + ReviewerLeaderboardDetail, RevisionCompareView, RevisionListView, RoundListView, @@ -43,6 +45,10 @@ submission_urls = ([ path('', SubmissionUserFlaggedView.as_view(), name="flagged"), path('staff/', SubmissionStaffFlaggedView.as_view(), name="staff_flagged"), ])), + path('reviews/', include([ + path('', ReviewerLeaderboard.as_view(), name="reviewer_leaderboard"), + path('<int:pk>/', ReviewerLeaderboardDetail.as_view(), name="reviewer_leaderboard_detail"), + ])), path('<int:pk>/', include([ path('', SubmissionDetailView.as_view(), name="detail"), path('edit/', SubmissionEditView.as_view(), name="edit"), diff --git a/hypha/apply/funds/views.py b/hypha/apply/funds/views.py index 5c45c7178bbd4208a94df2bc55e123b5186f6afa..c8af701c23e6da546f50938f5a399b6c4ae93e97 100644 --- a/hypha/apply/funds/views.py +++ b/hypha/apply/funds/views.py @@ -1,7 +1,9 @@ from copy import copy +from datetime import timedelta from statistics import mean from django.contrib import messages +from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required, permission_required from django.contrib.auth.mixins import UserPassesTestMixin from django.contrib.humanize.templatetags.humanize import intcomma @@ -10,6 +12,7 @@ from django.db.models import Count, F, Q from django.http import FileResponse, Http404, HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.urls import reverse_lazy +from django.utils import timezone from django.utils.decorators import method_decorator from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ @@ -41,6 +44,7 @@ from hypha.apply.determinations.views import ( ) from hypha.apply.projects.forms import CreateProjectForm from hypha.apply.projects.models import Project +from hypha.apply.review.models import Review from hypha.apply.review.views import ReviewContextMixin from hypha.apply.users.decorators import staff_required from hypha.apply.utils.pdfs import draw_submission_content, make_pdf @@ -76,6 +80,9 @@ from .models import ( from .permissions import is_user_has_access_to_view_submission from .tables import ( AdminSubmissionsTable, + ReviewerLeaderboardDetailTable, + ReviewerLeaderboardFilter, + ReviewerLeaderboardTable, ReviewerSubmissionsTable, RoundsFilter, RoundsTable, @@ -1179,3 +1186,53 @@ class SubmissionResultView(FilterView): average = round(mean(values)) return {'total': total, 'average': average} + + +@method_decorator(staff_required, name='dispatch') +class ReviewerLeaderboard(SingleTableMixin, FilterView): + filterset_class = ReviewerLeaderboardFilter + filter_action = '' + table_class = ReviewerLeaderboardTable + table_pagination = False + template_name = 'funds/reviewer_leaderboard.html' + + def get_context_data(self, **kwargs): + search_term = self.request.GET.get('query') + + return super().get_context_data( + search_term=search_term, + filter_action=self.filter_action, + **kwargs, + ) + + def get_queryset(self): + # Only list reviewers. + return self.filterset_class._meta.model.objects.reviewers() + + def get_table_data(self): + ninety_days_ago = timezone.now() - timedelta(days=90) + this_year = timezone.now().year + last_year = timezone.now().year - 1 + return super().get_table_data().annotate( + total=Count('assignedreviewers__review'), + ninety_days=Count('assignedreviewers__review', filter=Q(assignedreviewers__review__created_at__date__gte=ninety_days_ago)), + this_year=Count('assignedreviewers__review', filter=Q(assignedreviewers__review__created_at__year=this_year)), + last_year=Count('assignedreviewers__review', filter=Q(assignedreviewers__review__created_at__year=last_year)), + ) + + +@method_decorator(staff_required, name='dispatch') +class ReviewerLeaderboardDetail(SingleTableMixin, ListView): + model = Review + table_class = ReviewerLeaderboardDetailTable + paginator_class = LazyPaginator + table_pagination = {'per_page': 25} + template_name = 'funds/reviewer_leaderboard_detail.html' + + def get_context_data(self, **kwargs): + User = get_user_model() + obj = User.objects.get(pk=self.kwargs.get('pk')) + return super().get_context_data(object=obj, **kwargs) + + def get_table_data(self): + return super().get_table_data().filter(author__reviewer_id=self.kwargs.get('pk')).select_related('submission') diff --git a/hypha/apply/review/models.py b/hypha/apply/review/models.py index 0f91ec9def67aa6df9c0c0a339c9218fa5f455f3..6d782e85512b25adf15897b88c8bb6d987992066 100644 --- a/hypha/apply/review/models.py +++ b/hypha/apply/review/models.py @@ -101,6 +101,9 @@ class ReviewQuerySet(models.QuerySet): def by_partners(self): return self.submitted()._by_group(PARTNER_GROUP_NAME) + def by_user(self, user): + return self.submitted().filter(author__reviewer=user).order_by('-created_at') + def staff_score(self): return self.by_staff().score() diff --git a/hypha/apply/users/tests/factories.py b/hypha/apply/users/tests/factories.py index f9b4b231293629c2be50c9c0d2b26d26f66d656e..59fb930943b0aefa62d578d68fd6782e9f2e748b 100644 --- a/hypha/apply/users/tests/factories.py +++ b/hypha/apply/users/tests/factories.py @@ -8,6 +8,8 @@ from django.utils.text import slugify from ..groups import ( APPLICANT_GROUP_NAME, APPROVER_GROUP_NAME, + COMMUNITY_REVIEWER_GROUP_NAME, + PARTNER_GROUP_NAME, REVIEWER_GROUP_NAME, STAFF_GROUP_NAME, ) @@ -90,3 +92,17 @@ class ApplicantFactory(UserFactory): def groups(self, create, extracted, **kwargs): if create: self.groups.add(GroupFactory(name=APPLICANT_GROUP_NAME)) + + +class CommunityReviewerFactory(UserFactory): + @factory.post_generation + def groups(self, create, extracted, **kwargs): + if create: + self.groups.add(GroupFactory(name=COMMUNITY_REVIEWER_GROUP_NAME)) + + +class PartnerFactory(UserFactory): + @factory.post_generation + def groups(self, create, extracted, **kwargs): + if create: + self.groups.add(GroupFactory(name=PARTNER_GROUP_NAME)) diff --git a/hypha/static_src/src/sass/apply/abstracts/_mixins.scss b/hypha/static_src/src/sass/apply/abstracts/_mixins.scss index ec269cd16c687695bcba7835739154ed2a8aae54..d8069921a066642723a2af2e30148271525553a7 100644 --- a/hypha/static_src/src/sass/apply/abstracts/_mixins.scss +++ b/hypha/static_src/src/sass/apply/abstracts/_mixins.scss @@ -196,7 +196,7 @@ &::after { position: absolute; - top: 32px; + top: 50%; margin-left: 3px; } diff --git a/hypha/static_src/src/sass/apply/components/_all-reviews-table.scss b/hypha/static_src/src/sass/apply/components/_all-reviews-table.scss new file mode 100644 index 0000000000000000000000000000000000000000..bf46e5a2167580bd23847268f3a8ab110f508333 --- /dev/null +++ b/hypha/static_src/src/sass/apply/components/_all-reviews-table.scss @@ -0,0 +1,72 @@ +.all-reviews-table { + @include table-ordering-styles; + $root: &; + font-size: 14px; + + + thead { + display: none; + + @include media-query($table-breakpoint) { + display: table-header-group; + } + + tr { + &:hover { + box-shadow: none; + } + } + } + + tbody { + td { + &.title { + position: relative; + padding-top: 15px; + padding-left: 10px; + font-weight: $weight--bold; + + @include media-query($table-breakpoint) { + display: flex; + align-items: center; + } + + @include media-query(desktop) { + display: table-cell; + } + + &.has-tooltip { + @include media-query($table-breakpoint) { + &::before { + position: absolute; + top: 50px; + left: 45px; + z-index: -1; + width: 200px; + padding: 5px; + font-size: 12px; + font-weight: $weight--normal; + white-space: normal; + background: $color--sky-blue; + border: 1px solid $color--marine; + content: attr(data-title-tooltip); + opacity: 0; + transition: opacity $transition; + } + + &:hover { + &::before { + z-index: 10; + opacity: 1; + } + } + } + } + + a { + color: $color--primary; + } + } + } + } +} diff --git a/hypha/static_src/src/sass/apply/main.scss b/hypha/static_src/src/sass/apply/main.scss index 4c68827bf29f9e444a594f9417c9a10d71697735..2ac36be09675a5081891c818447450973a9d1d63 100644 --- a/hypha/static_src/src/sass/apply/main.scss +++ b/hypha/static_src/src/sass/apply/main.scss @@ -10,6 +10,7 @@ // Components @import 'components/alert'; @import 'components/all-submissions-table'; +@import 'components/all-reviews-table'; @import 'components/admin-bar'; @import 'components/actions-bar'; @import 'components/card';