From a5d8cbb8e91101bacda5ebd1ce70bce63f5c9b54 Mon Sep 17 00:00:00 2001 From: Gregory Martin Date: Mon, 12 Feb 2018 13:45:16 -0500 Subject: [PATCH] Add "last completed block" to dropdown --- common/djangoapps/student/cookies.py | 10 ++ .../djangoapps/student/tests/test_cookies.py | 2 + common/djangoapps/student/tests/test_views.py | 14 ++- lms/templates/header/user_dropdown.html | 7 ++ .../user_api/accounts/tests/test_utils.py | 91 ++++++++++++++++++- .../djangoapps/user_api/accounts/utils.py | 82 +++++++++++++++++ .../tests/views/test_course_outline.py | 2 + 7 files changed, 202 insertions(+), 6 deletions(-) diff --git a/common/djangoapps/student/cookies.py b/common/djangoapps/student/cookies.py index 27719c476b..a2103877e1 100644 --- a/common/djangoapps/student/cookies.py +++ b/common/djangoapps/student/cookies.py @@ -8,10 +8,12 @@ import time import six from django.conf import settings +from django.contrib.auth.models import User from django.core.urlresolvers import NoReverseMatch, reverse from django.dispatch import Signal from django.utils.http import cookie_date +from openedx.core.djangoapps.user_api.accounts.utils import retrieve_last_sitewide_block_completed from student.models import CourseEnrollment CREATE_LOGON_COOKIE = Signal(providing_args=['user', 'response']) @@ -58,6 +60,8 @@ def set_logged_in_cookies(request, response, user): "username": "test-user", "header_urls": { "account_settings": "https://example.com/account/settings", + "resume_block": + "https://example.com//courses/org.0/course_0/Run_0/jump_to/i4x://org.0/course_0/vertical/vertical_4" "learner_profile": "https://example.com/u/test-user", "logout": "https://example.com/logout" } @@ -165,6 +169,12 @@ def get_user_info_cookie_data(request): except NoReverseMatch: pass + # Add 'resume course' last completed block + try: + header_urls['resume_block'] = retrieve_last_sitewide_block_completed(user) + except User.DoesNotExist: + pass + # Convert relative URL paths to absolute URIs for url_name, url_path in six.iteritems(header_urls): header_urls[url_name] = request.build_absolute_uri(url_path) diff --git a/common/djangoapps/student/tests/test_cookies.py b/common/djangoapps/student/tests/test_cookies.py index 026c094c78..20577a91a1 100644 --- a/common/djangoapps/student/tests/test_cookies.py +++ b/common/djangoapps/student/tests/test_cookies.py @@ -6,6 +6,7 @@ from django.conf import settings from django.core.urlresolvers import reverse from django.test import RequestFactory +from openedx.core.djangoapps.user_api.accounts.utils import retrieve_last_sitewide_block_completed from student.cookies import get_user_info_cookie_data from student.models import CourseEnrollment from student.tests.factories import UserFactory @@ -26,6 +27,7 @@ class CookieTests(SharedModuleStoreTestCase): def _get_expected_header_urls(self, request): expected_header_urls = { 'logout': reverse('logout'), + 'resume_block': retrieve_last_sitewide_block_completed(self.user.username) } # Studio (CMS) does not have the URLs below diff --git a/common/djangoapps/student/tests/test_views.py b/common/djangoapps/student/tests/test_views.py index 61e522180c..c5bb4959eb 100644 --- a/common/djangoapps/student/tests/test_views.py +++ b/common/djangoapps/student/tests/test_views.py @@ -39,7 +39,7 @@ from util.milestones_helpers import (get_course_milestones, from util.testing import UrlResetMixin from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory PASSWORD = 'test' @@ -704,7 +704,11 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, course_key = course.id block_keys = [ - course_key.make_usage_key('video', unicode(number)) + ItemFactory.create( + category='video', + parent_location=course.location, + display_name='Video {0}'.format(unicode(number)) + ).location for number in xrange(5) ] @@ -773,7 +777,11 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, # Submit completed course blocks in even-numbered courses. if isEven(i): block_keys = [ - course_key.make_usage_key('video', unicode(number)) + ItemFactory.create( + category='video', + parent_location=course.location, + display_name='Video {0}'.format(unicode(number)) + ).location for number in xrange(5) ] last_completed_block_string = str(block_keys[-1]) diff --git a/lms/templates/header/user_dropdown.html b/lms/templates/header/user_dropdown.html index fe80bba634..9d1c04c791 100644 --- a/lms/templates/header/user_dropdown.html +++ b/lms/templates/header/user_dropdown.html @@ -12,11 +12,14 @@ from django.core.urlresolvers import reverse from django.utils.translation import ugettext as _ from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user +from openedx.core.djangoapps.user_api.accounts.utils import retrieve_last_sitewide_block_completed + %> <% profile_image_url = get_profile_image_urls_for_user(self.real_user)['medium'] username = self.real_user.username + resume_block = retrieve_last_sitewide_block_completed(username) %> @@ -32,7 +35,11 @@ from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_ diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py index da1a7bf017..94e39bd7bc 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py @@ -1,11 +1,22 @@ """ Unit tests for custom UserProfile properties. """ +from __future__ import absolute_import, division, print_function, unicode_literals + import ddt - from django.test import TestCase -from openedx.core.djangolib.testing.utils import skip_unless_lms +from django.test.utils import override_settings +from mock import patch -from ..utils import validate_social_link, format_social_link +from completion import models +from completion.test_utils import CompletionWaffleTestMixin +from openedx.core.djangoapps.user_api.accounts.utils import retrieve_last_sitewide_block_completed +from openedx.core.djangolib.testing.utils import skip_unless_lms +from student.models import CourseEnrollment +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + +from ..utils import format_social_link, validate_social_link @ddt.ddt @@ -51,3 +62,77 @@ class UserAccountSettingsTest(TestCase): self.assertEqual(is_valid_expected, self.validate_social_link(platform_name, link_input)) self.assertEqual(formatted_link_expected, format_social_link(platform_name, link_input)) + + +@ddt.ddt +class CompletionUtilsTestCase(SharedModuleStoreTestCase, CompletionWaffleTestMixin, TestCase): + """ + Test completion utility functions + """ + def setUp(self): + """ + Creates a test course that can be used for non-destructive tests + """ + super(CompletionUtilsTestCase, self).setUp() + self.override_waffle_switch(True) + self.engaged_user = UserFactory.create() + self.cruft_user = UserFactory.create() + self.course = self.create_test_course() + self.submit_faux_completions() + + def create_test_course(self): + """ + Create, populate test course. + """ + course = CourseFactory.create() + with self.store.bulk_operations(course.id): + self.chapter = ItemFactory.create(category='chapter', parent_location=course.location) + self.sequential = ItemFactory.create(category='sequential', parent_location=self.chapter.location) + self.vertical1 = ItemFactory.create(category='vertical', parent_location=self.sequential.location) + self.vertical2 = ItemFactory.create(category='vertical', parent_location=self.sequential.location) + course.children = [self.chapter] + self.chapter.children = [self.sequential] + self.sequential.children = [self.vertical1, self.vertical2] + + if hasattr(self, 'user_one'): + CourseEnrollment.enroll(self.engaged_user, course.id) + if hasattr(self, 'user_two'): + CourseEnrollment.enroll(self.cruft_user, course.id) + return course + + def submit_faux_completions(self): + """ + Submit completions (only for user_one)g + """ + for block in self.course.children[0].children[0].children: + models.BlockCompletion.objects.submit_completion( + user=self.engaged_user, + course_key=self.course.id, + block_key=block.location, + completion=1.0 + ) + + @override_settings(LMS_ROOT_URL='test_url:9999') + @patch('completion.waffle.get_current_site') + @ddt.data(True, False) + def test_retrieve_last_sitewide_block_completed(self, use_username, get_patched_current_site): # pylint: disable=unused-argument + """ + Test that the method returns a URL for the "last completed" block + when sending a user object + """ + block_url = retrieve_last_sitewide_block_completed( + self.engaged_user.username if use_username else self.engaged_user + ) + empty_block_url = retrieve_last_sitewide_block_completed( + self.cruft_user.username if use_username else self.cruft_user + ) + self.assertEqual( + block_url, + u'test_url:9999/courses/{org}/{course}/{run}/jump_to/i4x://{org}/{course}/vertical/{vertical_id}'.format( + org=self.course.location.course_key.org, + course=self.course.location.course_key.course, + run=self.course.location.course_key.run, + vertical_id=self.vertical2.location.block_id, + ) + ) + self.assertEqual(empty_block_url, None) diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index c69f90ed9f..d1387934c7 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -5,7 +5,15 @@ import re from urlparse import urlparse from django.conf import settings +from django.contrib.auth.models import User from django.utils.translation import ugettext as _ +from six import text_type + +from completion import waffle as completion_waffle +from completion.models import BlockCompletion +from openedx.core.djangoapps.site_configuration.models import SiteConfiguration +from openedx.core.djangoapps.theming.helpers import get_config_value_from_site_or_settings, get_current_site +from xmodule.modulestore.django import modulestore def validate_social_link(platform_name, new_social_link): @@ -89,3 +97,77 @@ def _is_valid_social_username(value): in the username. """ return '/' not in value + + +def retrieve_last_sitewide_block_completed(username): + """ + Completion utility + From a string 'username' or object User retrieve + the last course block marked as 'completed' and construct a URL + + :param username: str(username) or obj(User) + :return: block_lms_url + + """ + if not completion_waffle.waffle().is_enabled(completion_waffle.ENABLE_COMPLETION_TRACKING): + return + + if not isinstance(username, User): + userobj = User.objects.get(username=username) + else: + userobj = username + latest_completions_by_course = BlockCompletion.latest_blocks_completed_all_courses(userobj) + + known_site_configs = [ + other_site_config.get_value('course_org_filter') for other_site_config in SiteConfiguration.objects.all() + if other_site_config.get_value('course_org_filter') + ] + + current_site_configuration = get_config_value_from_site_or_settings( + name='course_org_filter', + site=get_current_site() + ) + + # courses.edx.org has no 'course_org_filter' + # however the courses within DO, but those entries are not found in + # known_site_configs, which are White Label sites + # This is necessary because the WL sites and courses.edx.org + # have the same AWS RDS mySQL instance + candidate_course = None + candidate_block_key = None + latest_date = None + # Go through dict, find latest + for course, [modified_date, block_key] in latest_completions_by_course.items(): + if not current_site_configuration: + # This is a edx.org + if course.org in known_site_configs: + continue + if not latest_date or modified_date > latest_date: + candidate_course = course + candidate_block_key = block_key + latest_date = modified_date + + else: + # This is a White Label site, and we should find candidates from the same site + if course.org not in current_site_configuration: + # Not the same White Label, or a edx.org course + continue + if not latest_date or modified_date > latest_date: + candidate_course = course + candidate_block_key = block_key + latest_date = modified_date + + if not candidate_course: + return + + lms_root = SiteConfiguration.get_value_for_org(candidate_course.org, "LMS_ROOT_URL", settings.LMS_ROOT_URL) + item = modulestore().get_item(candidate_block_key, depth=1) + + if not lms_root: + return + + return u"{lms_root}/courses/{course_key}/jump_to/{location}".format( + lms_root=lms_root, + course_key=text_type(item.location.course_key), + location=text_type(item.location), + ) diff --git a/openedx/features/course_experience/tests/views/test_course_outline.py b/openedx/features/course_experience/tests/views/test_course_outline.py index dc4263c178..1bbbde067a 100644 --- a/openedx/features/course_experience/tests/views/test_course_outline.py +++ b/openedx/features/course_experience/tests/views/test_course_outline.py @@ -10,6 +10,7 @@ from completion.models import BlockCompletion from completion.test_utils import CompletionWaffleTestMixin from django.contrib.sites.models import Site from django.core.urlresolvers import reverse +from django.test import override_settings from mock import Mock, patch from six import text_type @@ -402,6 +403,7 @@ class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase, CompletionWaffleT ), active=True ) + @override_settings(LMS_BASE='test_url:9999') @patch('completion.waffle.get_current_site') def test_resume_course_with_completion_api(self, get_patched_current_site): """