From cb7059b0d43bb69dba1b43e69f8aed42bc770c88 Mon Sep 17 00:00:00 2001
From: George Hickman <george@ghickman.co.uk>
Date: Mon, 22 Jul 2019 15:55:30 +0100
Subject: [PATCH] Add leaderboard view and table

---
 .../templates/funds/review_leaderboard.html   | 30 ++++++++++++++++
 hypha/apply/funds/tables.py                   | 36 +++++++++++++++++--
 hypha/apply/funds/tests/test_views.py         | 32 ++++++++++++++++-
 hypha/apply/funds/urls.py                     |  2 ++
 hypha/apply/funds/views.py                    | 35 +++++++++++++++++-
 hypha/apply/users/tests/factories.py          | 18 +++++++++-
 6 files changed, 148 insertions(+), 5 deletions(-)
 create mode 100644 hypha/apply/api/v1/templates/funds/review_leaderboard.html

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 000000000..36131fa70
--- /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 3ae089117..9ad96cdd6 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 36213ca12..5590ad44d 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 fc8321ede..da28bd33e 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 5c45c7178..d77ab9755 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 f9b4b2312..f1c46e42e 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))
-- 
GitLab