diff --git a/common/djangoapps/student/tests/test_views.py b/common/djangoapps/student/tests/test_views.py index cf63aa82d1c9f550abf43d653d28355147e51801..61e522180c2802cf73ed0044358b412260b896ab 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"> + {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"> + {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 2e2ed1cdac49b5933b9d4c71114f3570657289cd..2aaebc99a071e6a574c3bd93a279669a85c0dcbb 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 d3ca6622fb61b3c00f7cfa11d41d9448d31da5f0..8bf97b672033d6b55ff77c9bd720bfa23a8d66be 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 2d900e2019d347afcc2f8cb80a5c344b054bf789..23310040319aed699b75b4a470603f62ef5ca52e 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"> ${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"> ${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"> + ${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"> + ${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"> ${course_overview.display_name_with_default}</span></a> + <a class="enter-course-blocked" + data-course-key="${enrollment.course_id}"> + ${_('View Course')} + <span class="sr"> + ${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 99de2a9e63769a3af04fa1f1e40712d3eb07ee1d..e1aa9737050fa8a5ccd0851e49e6c0386ae9e2f5 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 517284ab7b92730bc0cc0aeeff4a0d1fcc8db20a..d075e4234136803fceab925529fa1e9811c2bd0b 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