diff --git a/hypha/apply/api/v1/templates/funds/review_leaderboard.html b/hypha/apply/api/v1/templates/funds/review_leaderboard.html new file mode 100644 index 0000000000000000000000000000000000000000..36131fa7015ee44d29bc1282cfdf09cccf5275e5 --- /dev/null +++ b/hypha/apply/api/v1/templates/funds/review_leaderboard.html @@ -0,0 +1,30 @@ +{% extends "funds/base_submissions_table.html" %} + +{% load static %} +{% load render_table from django_tables2 %} + +{% block title %}Submissions{% 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>TAG LINE</h5> + </div> + {% 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 use_search=True filter_action=filter_action use_batch_actions=False heading="All Submissions" %} + + {% render_table table %} + <div class="all-submissions-table__more"> + <a href="{% url 'apply:submissions:list' %}">Show all</a> + </div> + {% endblock %} +</div> +{% endblock %} diff --git a/hypha/apply/funds/tables.py b/hypha/apply/funds/tables.py index 3ae089117073673c9ea628347f158fdf654f9b79..9ad96cdd6f6665d47a72d672667c92f130eb1fab 100644 --- a/hypha/apply/funds/tables.py +++ b/hypha/apply/funds/tables.py @@ -16,6 +16,7 @@ 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 @@ -23,6 +24,9 @@ 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)) statuses = [ @@ -171,13 +175,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 +361,33 @@ class RoundsFilter(filters.FilterSet): lead = Select2ModelMultipleChoiceFilter(queryset=get_round_leads, label='Leads') active = ActiveRoundFilter(label='Active') round_state = OpenRoundFilter(label='Open') + + +class LeaderboardTable(tables.Table): + full_name = tables.Column(verbose_name="Reviewer") + + class Meta: + fields = [ + 'full_name', + 'total', + 'ninety_days', + 'this_year', + 'last_year', + 'most_recent', + ] + model = User + orderable = False + + def render_most_recent(self, record): + review = (Review.objects.filter(author__reviewer=record) + .order_by('-created_at') + .first()) + + if review is None: + return None + + return format_html( + '<a href="{}">{}</a>', + review.revision.submission.get_absolute_url(), + review.revision.submission.title, + ) diff --git a/hypha/apply/funds/tests/test_views.py b/hypha/apply/funds/tests/test_views.py index 36213ca12ad78a1d106a508771f3c12f84eda103..5590ad44df8be568c12782613ec9bdd41eb2b2a6 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='opentech.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_can_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, 200) + + 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..da28bd33e1bd11ea9545fee68e9b2348cbea8c70 100644 --- a/hypha/apply/funds/urls.py +++ b/hypha/apply/funds/urls.py @@ -4,6 +4,7 @@ from hypha.apply.projects import urls as projects_urls from .views import ( ReminderDeleteView, + ReviewLeaderboard, RevisionCompareView, RevisionListView, RoundListView, @@ -43,6 +44,7 @@ submission_urls = ([ path('', SubmissionUserFlaggedView.as_view(), name="flagged"), path('staff/', SubmissionStaffFlaggedView.as_view(), name="staff_flagged"), ])), + path('reviews/', ReviewLeaderboard.as_view(), name="leaderboard"), 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..d77ab97551bd89f7bea567e9b960dd9f56d1e8ed 100644 --- a/hypha/apply/funds/views.py +++ b/hypha/apply/funds/views.py @@ -1,4 +1,5 @@ from copy import copy +from datetime import timedelta from statistics import mean from django.contrib import messages @@ -6,10 +7,11 @@ 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 from django.core.exceptions import PermissionDenied -from django.db.models import Count, F, Q +from django.db.models import Count, F, OuterRef, Q, Subquery 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 +43,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 +79,7 @@ from .models import ( from .permissions import is_user_has_access_to_view_submission from .tables import ( AdminSubmissionsTable, + LeaderboardTable, ReviewerSubmissionsTable, RoundsFilter, RoundsTable, @@ -1179,3 +1183,32 @@ class SubmissionResultView(FilterView): average = round(mean(values)) return {'total': total, 'average': average} + + +@method_decorator(login_required, name='dispatch') +class ReviewLeaderboard(SingleTableMixin, FilterView): + table_class = LeaderboardTable + table_pagination = False + template_name = 'funds/review_leaderboard.html' + + def dispatch(self, request, *args, **kwargs): + is_staff = request.user.is_apply_staff + is_reviewer = request.user.is_reviewer + + if not (is_staff or is_reviewer): + raise PermissionDenied + + return super().dispatch(request, *args, **kwargs) + + def get_table_data(self): + ninety_days_ago = timezone.now() - timedelta(days=90) + this_year = timezone.now().year + last_year = timezone.now().year - 1 + latest_reviews = Review.objects.filter(author__reviewer_id=OuterRef('pk')).order_by('-created_at') + return super().get_table_data().filter(submissions_reviewer__isnull=False).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)), + most_recent=Subquery(latest_reviews.values('id')[:1]) + ) diff --git a/hypha/apply/users/tests/factories.py b/hypha/apply/users/tests/factories.py index f9b4b231293629c2be50c9c0d2b26d26f66d656e..f1c46e42e9a5a58672d073357a9eda74850d370e 100644 --- a/hypha/apply/users/tests/factories.py +++ b/hypha/apply/users/tests/factories.py @@ -8,8 +8,10 @@ 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, + 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))