From 3f22ce3031a7e417a2919891b2778e9b3d976185 Mon Sep 17 00:00:00 2001
From: sandroroux <aroux@edx.org>
Date: Fri, 23 Feb 2018 12:12:59 -0500
Subject: [PATCH] These changes add a resume button to course cards on the
 student dashboard.

---
 common/djangoapps/student/tests/test_views.py | 226 +++++++++++++++++-
 common/djangoapps/student/views/dashboard.py  |  31 +++
 lms/templates/dashboard.html                  |   3 +-
 .../dashboard/_dashboard_course_listing.html  |  31 ++-
 .../completion_integration/test_models.py     |  33 +--
 requirements/edx/base.txt                     |   2 +-
 6 files changed, 290 insertions(+), 36 deletions(-)

diff --git a/common/djangoapps/student/tests/test_views.py b/common/djangoapps/student/tests/test_views.py
index cf63aa82d1c..61e522180c2 100644
--- a/common/djangoapps/student/tests/test_views.py
+++ b/common/djangoapps/student/tests/test_views.py
@@ -3,10 +3,12 @@ Test the student dashboard view.
 """
 import itertools
 import json
+import re
 import unittest
 from datetime import timedelta
 
 import ddt
+from completion.test_utils import submit_completions_for_testing, CompletionWaffleTestMixin
 from django.conf import settings
 from django.core.urlresolvers import reverse
 from django.test import RequestFactory, TestCase
@@ -241,7 +243,7 @@ class LogoutTests(TestCase):
 
 @ddt.ddt
 @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
-class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin):
+class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, CompletionWaffleTestMixin):
     """
     Tests for the student dashboard.
     """
@@ -603,6 +605,228 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin):
         response = self.client.get(self.path)
         self.assertEqual(pq(response.content)(self.EMAIL_SETTINGS_ELEMENT_ID).length, 0)
 
+    @staticmethod
+    def _remove_whitespace_from_html_string(html):
+        return ''.join(html.split())
+
+    @staticmethod
+    def _pull_course_run_from_course_key(course_key_string):
+        search_results = re.search(r'Run_[0-9]+$', course_key_string)
+        assert search_results
+        course_run_string = search_results.group(0).replace('_', ' ')
+        return course_run_string
+
+    @staticmethod
+    def _get_html_for_view_course_button(course_key_string, course_run_string):
+        return '''
+            <a href="/courses/{course_key}/course/"
+               class="enter-course "
+               data-course-key="{course_key}">
+              View Course
+              <span class="sr">
+                &nbsp;{course_run}
+              </span>
+            </a>
+        '''.format(course_key=course_key_string, course_run=course_run_string)
+
+    @staticmethod
+    def _get_html_for_resume_course_button(course_key_string, resume_block_key_string, course_run_string):
+        return '''
+            <a href="/courses/{course_key}/jump_to/{url_to_block}"
+               class="enter-course "
+               data-course-key="{course_key}">
+              Resume Course
+              <span class="sr">
+                &nbsp;{course_run}
+              </span>
+            </a>
+        '''.format(
+            course_key=course_key_string,
+            url_to_block=resume_block_key_string,
+            course_run=course_run_string
+        )
+
+    def test_view_course_appears_on_dashboard(self):
+        """
+        When a course doesn't have completion data, its course card should
+        display a "View Course" button.
+        """
+        self.override_waffle_switch(True)
+
+        course = CourseFactory.create()
+        CourseEnrollmentFactory.create(
+            user=self.user,
+            course_id=course.id
+        )
+
+        response = self.client.get(reverse('dashboard'))
+
+        course_key_string = str(course.id)
+        # No completion data means there's no block from which to resume.
+        resume_block_key_string = ''
+        course_run_string = self._pull_course_run_from_course_key(course_key_string)
+
+        view_button_html = self._get_html_for_view_course_button(
+            course_key_string,
+            course_run_string
+        )
+        resume_button_html = self._get_html_for_resume_course_button(
+            course_key_string,
+            resume_block_key_string,
+            course_run_string
+        )
+
+        view_button_html = self._remove_whitespace_from_html_string(view_button_html)
+        resume_button_html = self._remove_whitespace_from_html_string(resume_button_html)
+        dashboard_html = self._remove_whitespace_from_html_string(response.content)
+
+        self.assertIn(
+            view_button_html,
+            dashboard_html
+        )
+        self.assertNotIn(
+            resume_button_html,
+            dashboard_html
+        )
+
+    def test_resume_course_appears_on_dashboard(self):
+        """
+        When a course has completion data, its course card should display a
+        "Resume Course" button.
+        """
+        self.override_waffle_switch(True)
+
+        course = CourseFactory.create()
+        CourseEnrollmentFactory.create(
+            user=self.user,
+            course_id=course.id
+        )
+
+        course_key = course.id
+        block_keys = [
+            course_key.make_usage_key('video', unicode(number))
+            for number in xrange(5)
+        ]
+
+        submit_completions_for_testing(self.user, course_key, block_keys)
+
+        with patch('completion.utilities.visual_progress_enabled', return_value=True):
+            response = self.client.get(reverse('dashboard'))
+
+        course_key_string = str(course_key)
+        resume_block_key_string = str(block_keys[-1])
+        course_run_string = self._pull_course_run_from_course_key(course_key_string)
+
+        view_button_html = self._get_html_for_view_course_button(
+            course_key_string,
+            course_run_string
+        )
+        resume_button_html = self._get_html_for_resume_course_button(
+            course_key_string,
+            resume_block_key_string,
+            course_run_string
+        )
+
+        view_button_html = self._remove_whitespace_from_html_string(view_button_html)
+        resume_button_html = self._remove_whitespace_from_html_string(resume_button_html)
+        dashboard_html = self._remove_whitespace_from_html_string(response.content)
+
+        self.assertIn(
+            resume_button_html,
+            dashboard_html
+        )
+        self.assertNotIn(
+            view_button_html,
+            dashboard_html
+        )
+
+    def test_dashboard_with_resume_buttons_and_view_buttons(self):
+        '''
+        The Test creates a four-course-card dashboard. The user completes course
+        blocks in the even-numbered course cards. The test checks that courses
+        with completion data have course cards with "Resume Course" buttons;
+        those without have "View Course" buttons.
+
+        '''
+        self.override_waffle_switch(True)
+
+        isEven = lambda n: n % 2 == 0
+
+        num_course_cards = 4
+
+        html_for_view_buttons = []
+        html_for_resume_buttons = []
+
+        for i in range(num_course_cards):
+            course = CourseFactory.create()
+            course_enrollment = CourseEnrollmentFactory(
+                user=self.user,
+                course_id=course.id
+            )
+
+            course_key = course_enrollment.course_id
+            course_key_string = str(course_key)
+            last_completed_block_string = ''
+            course_run_string = self._pull_course_run_from_course_key(
+                course_key_string)
+
+            # Submit completed course blocks in even-numbered courses.
+            if isEven(i):
+                block_keys = [
+                    course_key.make_usage_key('video', unicode(number))
+                    for number in xrange(5)
+                ]
+                last_completed_block_string = str(block_keys[-1])
+
+                submit_completions_for_testing(self.user, course_key, block_keys)
+
+            html_for_view_buttons.append(
+                self._get_html_for_view_course_button(
+                    course_key_string,
+                    course_run_string
+                )
+            )
+            html_for_resume_buttons.append(
+                self._get_html_for_resume_course_button(
+                    course_key_string,
+                    last_completed_block_string,
+                    course_run_string
+                )
+            )
+
+        with patch('completion.utilities.visual_progress_enabled', return_value=True):
+            response = self.client.get(reverse('dashboard'))
+
+        html_for_view_buttons = [
+            self._remove_whitespace_from_html_string(button)
+            for button in html_for_view_buttons
+        ]
+        html_for_resume_buttons = [
+            self._remove_whitespace_from_html_string(button)
+            for button in html_for_resume_buttons
+        ]
+        dashboard_html = self._remove_whitespace_from_html_string(response.content)
+
+        for i in range(num_course_cards):
+            expected_button = None
+            unexpected_button = None
+
+            if isEven(i):
+                expected_button = html_for_resume_buttons[i]
+                unexpected_button = html_for_view_buttons[i]
+            else:
+                expected_button = html_for_view_buttons[i]
+                unexpected_button = html_for_resume_buttons[i]
+
+            self.assertIn(
+                expected_button,
+                dashboard_html
+            )
+            self.assertNotIn(
+                unexpected_button,
+                dashboard_html
+            )
+
 
 @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
 @override_settings(BRANCH_IO_KEY='test_key')
diff --git a/common/djangoapps/student/views/dashboard.py b/common/djangoapps/student/views/dashboard.py
index 2e2ed1cdac4..2aaebc99a07 100644
--- a/common/djangoapps/student/views/dashboard.py
+++ b/common/djangoapps/student/views/dashboard.py
@@ -6,6 +6,8 @@ import datetime
 import logging
 from collections import defaultdict
 
+from completion.exceptions import UnavailableCompletionData
+from completion.utilities import get_key_to_last_completed_course_block
 from django.conf import settings
 from django.contrib import messages
 from django.contrib.auth.decorators import login_required
@@ -13,6 +15,7 @@ from django.core.urlresolvers import NoReverseMatch, reverse, reverse_lazy
 from django.shortcuts import redirect
 from django.utils.translation import ugettext as _
 from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
+
 from opaque_keys.edx.keys import CourseKey
 from pytz import UTC
 from six import text_type, iteritems
@@ -453,6 +456,24 @@ def _credit_statuses(user, course_enrollments):
     return statuses
 
 
+def _get_urls_for_resume_buttons(user, enrollments):
+    '''
+    Checks whether a user has made progress in any of a list of enrollments.
+    '''
+    resume_button_urls = []
+    for enrollment in enrollments:
+        try:
+            block_key = get_key_to_last_completed_course_block(user, enrollment.course_id)
+            url_to_block = reverse(
+                'jump_to',
+                kwargs={'course_id': enrollment.course_id, 'location': block_key}
+            )
+        except UnavailableCompletionData:
+            url_to_block = ''
+        resume_button_urls.append(url_to_block)
+    return resume_button_urls
+
+
 @login_required
 @ensure_csrf_cookie
 def student_dashboard(request):
@@ -473,6 +494,7 @@ def student_dashboard(request):
         return redirect(reverse('account_settings'))
 
     platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME)
+
     enable_verified_certificates = configuration_helpers.get_value(
         'ENABLE_VERIFIED_CERTIFICATES',
         settings.FEATURES.get('ENABLE_VERIFIED_CERTIFICATES')
@@ -756,6 +778,15 @@ def student_dashboard(request):
             'ecommerce_payment_page': ecommerce_service.payment_page_url(),
         })
 
+    # Gather urls for course card resume buttons.
+    resume_button_urls = _get_urls_for_resume_buttons(user, course_enrollments)
+    # There must be enough urls for dashboard.html. Template creates course
+    # cards for "enrollments + entitlements".
+    resume_button_urls += ['' for entitlement in course_entitlements]
+    context.update({
+        'resume_button_urls': resume_button_urls
+    })
+
     response = render_to_response('dashboard.html', context)
     set_user_info_cookie(response, request)
     return response
diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html
index d3ca6622fb6..8bf97b67203 100644
--- a/lms/templates/dashboard.html
+++ b/lms/templates/dashboard.html
@@ -185,8 +185,9 @@ from student.models import CourseEnrollment
                 related_programs = inverted_programs.get(unicode(entitlement.course_uuid if is_unfulfilled_entitlement else session_id))
                 show_consent_link = (session_id in consent_required_courses)
                 course_overview = enrollment.course_overview
+                resume_button_url = resume_button_urls[dashboard_index]
               %>
-              <%include file='dashboard/_dashboard_course_listing.html' args='course_overview=course_overview, course_card_index=dashboard_index, enrollment=enrollment, is_unfulfilled_entitlement=is_unfulfilled_entitlement, is_fulfilled_entitlement=is_fulfilled_entitlement, entitlement=entitlement, entitlement_session=entitlement_session, entitlement_available_sessions=entitlement_available_sessions, entitlement_expiration_date=entitlement_expiration_date, entitlement_expired_at=entitlement_expired_at, show_courseware_link=show_courseware_link, cert_status=cert_status, can_refund_entitlement=can_refund_entitlement, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs, display_course_modes_on_dashboard=display_course_modes_on_dashboard, show_consent_link=show_consent_link, enterprise_customer_name=enterprise_customer_name' />
+              <%include file='dashboard/_dashboard_course_listing.html' args='course_overview=course_overview, course_card_index=dashboard_index, enrollment=enrollment, is_unfulfilled_entitlement=is_unfulfilled_entitlement, is_fulfilled_entitlement=is_fulfilled_entitlement, entitlement=entitlement, entitlement_session=entitlement_session, entitlement_available_sessions=entitlement_available_sessions, entitlement_expiration_date=entitlement_expiration_date, entitlement_expired_at=entitlement_expired_at, show_courseware_link=show_courseware_link, cert_status=cert_status, can_refund_entitlement=can_refund_entitlement, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs, display_course_modes_on_dashboard=display_course_modes_on_dashboard, show_consent_link=show_consent_link, enterprise_customer_name=enterprise_customer_name, resume_button_url=resume_button_url' />
             % endfor
 
             </ul>
diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html
index 2d900e2019d..23310040319 100644
--- a/lms/templates/dashboard/_dashboard_course_listing.html
+++ b/lms/templates/dashboard/_dashboard_course_listing.html
@@ -1,4 +1,4 @@
-<%page args="course_overview, enrollment, entitlement, entitlement_session, course_card_index, is_unfulfilled_entitlement, is_fulfilled_entitlement, entitlement_available_sessions, entitlement_expiration_date, entitlement_expired_at, show_courseware_link, cert_status, can_refund_entitlement, can_unenroll, credit_status, show_email_settings, course_mode_info, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings, related_programs, display_course_modes_on_dashboard, show_consent_link, enterprise_customer_name" expression_filter="h"/>
+<%page args="course_overview, enrollment, entitlement, entitlement_session, course_card_index, is_unfulfilled_entitlement, is_fulfilled_entitlement, entitlement_available_sessions, entitlement_expiration_date, entitlement_expired_at, show_courseware_link, cert_status, can_refund_entitlement, can_unenroll, credit_status, show_email_settings, course_mode_info, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings, related_programs, display_course_modes_on_dashboard, show_consent_link, enterprise_customer_name, resume_button_url" expression_filter="h"/>
 
 <%!
 import urllib
@@ -172,11 +172,34 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_
                 % else:
                   <a class="enter-course-blocked archived" data-course-key="${enrollment.course_id}">${_('View Archived Course')}<span class="sr">&nbsp;${course_overview.display_name_with_default}</span></a>
                 % endif
+
               % else:
-                % if not is_course_blocked:
-                  <a href="${course_target}" class="enter-course ${'hidden' if is_unfulfilled_entitlement else ''}" data-course-key="${enrollment.course_id}">${_('View Course')}<span class="sr">&nbsp;${course_overview.display_name_with_default}</span></a>
+                % if resume_button_url != '':
+                  <a href="${resume_button_url}"
+                     class="enter-course ${'hidden' if is_unfulfilled_entitlement else ''}"
+                     data-course-key="${enrollment.course_id}">
+                    ${_('Resume Course')}
+                    <span class="sr">
+                      &nbsp;${course_overview.display_name_with_default}
+                    </span>
+                  </a>
+                % elif not is_course_blocked:
+                  <a href="${course_target}" 
+                     class="enter-course ${'hidden' if is_unfulfilled_entitlement else ''}" 
+                     data-course-key="${enrollment.course_id}">
+                    ${_('View Course')}
+                    <span class="sr">
+                      &nbsp;${course_overview.display_name_with_default}
+                    </span>
+                  </a>
                 % else:
-                  <a class="enter-course-blocked" data-course-key="${enrollment.course_id}">${_('View Course')}<span class="sr">&nbsp;${course_overview.display_name_with_default}</span></a>
+                  <a class="enter-course-blocked"
+                     data-course-key="${enrollment.course_id}">
+                    ${_('View Course')}
+                    <span class="sr">
+                      &nbsp;${course_overview.display_name_with_default}
+                    </span>
+                  </a>
                 % endif
               % endif
             % endif
diff --git a/openedx/tests/completion_integration/test_models.py b/openedx/tests/completion_integration/test_models.py
index 99de2a9e637..e1aa9737050 100644
--- a/openedx/tests/completion_integration/test_models.py
+++ b/openedx/tests/completion_integration/test_models.py
@@ -5,7 +5,7 @@ Test models, managers, and validators.
 from __future__ import absolute_import, division, print_function, unicode_literals
 
 from completion import models, waffle
-from completion.test_utils import CompletionWaffleTestMixin
+from completion.test_utils import CompletionWaffleTestMixin, submit_completions_for_testing
 from django.core.exceptions import ValidationError
 from django.test import TestCase
 from opaque_keys.edx.keys import CourseKey, UsageKey
@@ -200,34 +200,9 @@ class BatchCompletionMethodTests(CompletionWaffleTestMixin, TestCase):
         self.other_course_key = CourseKey.from_string("course-v1:ReedX+Hum110+1904")
         self.block_keys = [UsageKey.from_string("i4x://edX/MOOC101/video/{}".format(number)) for number in xrange(5)]
 
-        self.submit_fake_completions()
-
-    def submit_fake_completions(self):
-        """
-        Submit completions for given runtime, run at setup
-        """
-        for idx, block_key in enumerate(self.block_keys[:3]):
-            models.BlockCompletion.objects.submit_completion(
-                user=self.user,
-                course_key=self.course_key,
-                block_key=block_key,
-                completion=1.0 - (0.2 * idx),
-            )
-
-        for idx, block_key in enumerate(self.block_keys[2:]):  # Wrong user
-            models.BlockCompletion.objects.submit_completion(
-                user=self.other_user,
-                course_key=self.course_key,
-                block_key=block_key,
-                completion=0.9 - (0.2 * idx),
-            )
-
-        models.BlockCompletion.objects.submit_completion(  # Wrong course
-            user=self.user,
-            course_key=self.other_course_key,
-            block_key=self.block_keys[4],
-            completion=0.75,
-        )
+        submit_completions_for_testing(self.user, self.course_key, self.block_keys[:3])
+        submit_completions_for_testing(self.other_user, self.course_key, self.block_keys[2:])
+        submit_completions_for_testing(self.user, self.other_course_key, [self.block_keys[4]])
 
     def test_get_course_completions_missing_runs(self):
         actual_completions = models.BlockCompletion.get_course_completions(self.user, self.course_key)
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index 517284ab7b9..d075e423413 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -56,7 +56,7 @@ git+https://github.com/cpennington/pylint-django@fix-field-inference-during-monk
 enum34==1.1.6
 edx-django-oauth2-provider==1.2.5
 edx-django-sites-extensions==2.3.0
-edx-completion==0.0.6
+edx-completion==0.0.9
 edx-enterprise==0.65.7
 edx-milestones==0.1.13
 edx-oauth2-provider==1.2.2
-- 
GitLab