diff --git a/cms/djangoapps/contentstore/views/tests/test_tabs.py b/cms/djangoapps/contentstore/views/tests/test_tabs.py index 9f6f0a994c..11678489d5 100644 --- a/cms/djangoapps/contentstore/views/tests/test_tabs.py +++ b/cms/djangoapps/contentstore/views/tests/test_tabs.py @@ -203,7 +203,7 @@ class PrimitiveTabEdit(ModuleStoreTestCase): with self.assertRaises(ValueError): tabs.primitive_delete(course, 1) with self.assertRaises(IndexError): - tabs.primitive_delete(course, 6) + tabs.primitive_delete(course, 7) tabs.primitive_delete(course, 2) self.assertNotIn({u'type': u'textbooks'}, course.tabs) # Check that discussion has shifted up diff --git a/common/lib/xmodule/xmodule/tabs.py b/common/lib/xmodule/xmodule/tabs.py index fa1b110987..ffd98876c4 100644 --- a/common/lib/xmodule/xmodule/tabs.py +++ b/common/lib/xmodule/xmodule/tabs.py @@ -413,6 +413,7 @@ class CourseTabList(List): discussion_tab, CourseTab.load('wiki'), CourseTab.load('progress'), + CourseTab.load('dates'), ]) @staticmethod diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index a6f6ad06e4..b81b16ee6a 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -57,6 +57,8 @@ from util.date_utils import strftime_localized from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.x_module import STUDENT_VIEW +import lms.djangoapps.course_blocks.api as course_blocks_api +from openedx.features.content_type_gating.helpers import CONTENT_GATING_PARTITION_ID log = logging.getLogger(__name__) @@ -394,7 +396,8 @@ def get_course_info_section(request, user, course, section_key): return html -def get_course_date_blocks(course, user, request=None, include_past_dates=False, num_assignments=None): +def get_course_date_blocks(course, user, request=None, include_access=False, + include_past_dates=False, num_assignments=None): """ Return the list of blocks to display on the course info page, sorted by date. @@ -413,7 +416,9 @@ def get_course_date_blocks(course, user, request=None, include_past_dates=False, if DATE_WIDGET_V2_FLAG.is_enabled(course.id): blocks.append(CourseExpiredDate(course, user)) blocks.extend(get_course_assignment_due_dates( - course, user, request, num_return=num_assignments, include_past_dates=include_past_dates)) + course, user, request, num_return=num_assignments, + include_access=include_access, include_past_dates=include_past_dates, + )) return sorted((b for b in blocks if b.date and (b.is_enabled or include_past_dates)), key=date_block_key_fn) @@ -426,7 +431,8 @@ def date_block_key_fn(block): return block.date or datetime.max.replace(tzinfo=pytz.UTC) -def get_course_assignment_due_dates(course, user, request, num_return=None, include_past_dates=False): +def get_course_assignment_due_dates(course, user, request, num_return=None, + include_past_dates=False, include_access=False): """ Returns a list of assignment (at the subsection/sequential level) due date blocks for the given course. Will return num_return results or all results @@ -445,6 +451,9 @@ def get_course_assignment_due_dates(course, user, request, num_return=None, incl date_block = CourseAssignmentDate(course, user) date_block.date = date + if include_access: + date_block.requires_full_access = _requires_full_access(store, user, block_key) + block_url = None now = datetime.now().replace(tzinfo=pytz.UTC) assignment_released = item.start < now if item.start else None @@ -461,6 +470,22 @@ def get_course_assignment_due_dates(course, user, request, num_return=None, incl return date_blocks +def _requires_full_access(store, user, block_key): + """ + Returns a boolean if any child of the block_key specified has a group_access array consisting of just full_access + """ + child_block_keys = course_blocks_api.get_course_blocks(user, block_key) + for child_block_key in child_block_keys: + child_block = store.get_item(child_block_key) + # If group_access is set on the block, and the content gating is + # only full access, set the value on the CourseAssignmentDate object + if(child_block.group_access and child_block.group_access.get(CONTENT_GATING_PARTITION_ID) == [ + settings.CONTENT_TYPE_GATE_GROUP_IDS['full_access'] + ]): + return True + return False + + # TODO: Fix this such that these are pulled in as extra course-specific tabs. # arjun will address this by the end of October if no one does so prior to # then. diff --git a/lms/djangoapps/courseware/date_summary.py b/lms/djangoapps/courseware/date_summary.py index 5746495de7..fab8e711bc 100644 --- a/lms/djangoapps/courseware/date_summary.py +++ b/lms/djangoapps/courseware/date_summary.py @@ -346,6 +346,7 @@ class CourseAssignmentDate(DateSummary): self.assignment_date = None self.assignment_title = None self.assignment_title_html = None + self.requires_full_access = None @property def date(self): diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index 0d075e5325..88bf5d47e2 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -11,11 +11,21 @@ from django.utils.translation import ugettext_noop from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.entrance_exams import user_can_skip_entrance_exam +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag, WaffleFlagNamespace from openedx.core.lib.course_tabs import CourseTabPluginManager from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, default_course_url_name from student.models import CourseEnrollment from xmodule.tabs import CourseTab, CourseTabList, course_reverse_func_from_name_func, key_checker +COURSEWARE_TABS_NAMESPACE = WaffleFlagNamespace(name=u'courseware_tabs') + +ENABLE_DATES_TAB = CourseWaffleFlag( + waffle_namespace=COURSEWARE_TABS_NAMESPACE, + flag_name="enable_dates_tab", + flag_undefined_default=False +) + class EnrolledTab(CourseTab): """ @@ -307,6 +317,25 @@ class SingleTextbookTab(CourseTab): raise NotImplementedError('SingleTextbookTab should not be serialized.') +class DatesTab(CourseTab): + """ + A tab representing the relevant dates for a course. + """ + type = "dates" + title = ugettext_noop( + "Dates") # We don't have the user in this context, so we don't want to translate it at this level. + view_name = "dates" + is_dynamic = True + + @classmethod + def is_enabled(cls, course, user=None): + """Returns true if this tab is enabled.""" + # We want to only limit this feature to instructor led courses for now + if ENABLE_DATES_TAB.is_enabled(course.id): + return CourseOverview.get_from_id(course.id) == 'instructor' + return False + + def get_course_tab_list(user, course): """ Retrieves the course tab list from xmodule.tabs and manipulates the set as necessary diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index aa88525002..46c31bb737 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -8,6 +8,7 @@ import itertools import json import unittest from datetime import datetime, timedelta +from pytz import utc from uuid import uuid4 import ddt @@ -60,6 +61,7 @@ from lms.djangoapps.commerce.models import CommerceConfiguration from lms.djangoapps.commerce.utils import EcommerceService from lms.djangoapps.grades.config.waffle import ASSUME_ZERO_GRADE_IF_ABSENT from lms.djangoapps.grades.config.waffle import waffle as grades_waffle +from lms.djangoapps.verify_student.models import VerificationDeadline from lms.djangoapps.verify_student.services import IDVerificationService from opaque_keys.edx.keys import CourseKey, UsageKey from openedx.core.djangoapps.catalog.tests.factories import CourseFactory as CatalogCourseFactory @@ -77,6 +79,7 @@ from openedx.features.course_duration_limits.models import CourseDurationLimitCo from openedx.features.course_experience import ( COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, COURSE_OUTLINE_PAGE_FLAG, + DATE_WIDGET_V2_FLAG, UNIFIED_COURSE_TAB_FLAG ) from openedx.features.course_experience.tests.views.helpers import add_course_mode @@ -3122,3 +3125,88 @@ class AccessUtilsTestCase(ModuleStoreTestCase): course = CourseFactory.create(start=start_date) self.assertEqual(bool(check_course_open_for_learner(staff_user, course)), expected_value) + + +@ddt.ddt +class DatesTabTestCase(ModuleStoreTestCase): + """ + Ensure that the dates page renders with the correct data for both a verified and audit learner + """ + + def setUp(self): + super(DatesTabTestCase, self).setUp() + self.user = UserFactory.create() + + now = datetime.now(utc) + self.course = CourseFactory.create(start=now + timedelta(days=-1)) + self.course.end = now + timedelta(days=3) + + CourseModeFactory(course_id=self.course.id, mode_slug=CourseMode.AUDIT) + CourseModeFactory( + course_id=self.course.id, + mode_slug=CourseMode.VERIFIED, + expiration_datetime=now + timedelta(days=1) + ) + VerificationDeadline.objects.create( + course_key=self.course.id, + deadline=now + timedelta(days=2) + ) + + def _get_response(self, course): + """ Returns the HTML for the progress page """ + return self.client.get(reverse('dates', args=[six.text_type(course.id)])) + + @override_waffle_flag(DATE_WIDGET_V2_FLAG, active=True) + def test_defaults(self): + request = RequestFactory().request() + request.user = self.user + enrollment = CourseEnrollmentFactory(course_id=self.course.id, user=self.user, mode=CourseMode.VERIFIED) + now = datetime.now(utc) + with self.store.bulk_operations(self.course.id): + section = ItemFactory.create(category='chapter', parent_location=self.course.location) + subsection = ItemFactory.create( + category='sequential', + display_name='Released', + parent_location=section.location, + start=now - timedelta(days=1), + due=now, # Setting this today so it'll show the 'Due Today' pill + graded=True, + ) + + with patch('lms.djangoapps.courseware.courses.get_dates_for_course') as mock_get_dates: + with patch('lms.djangoapps.courseware.views.views.get_enrollment') as mock_get_enrollment: + mock_get_dates.return_value = { + (subsection.location, 'due'): subsection.due, + (subsection.location, 'start'): subsection.start, + } + mock_get_enrollment.return_value = { + 'mode': enrollment.mode + } + response = self._get_response(self.course) + self.assertContains(response, subsection.display_name) + # Show the Verification Deadline for everyone + self.assertContains(response, 'Verification Deadline') + # Make sure pill exists for assignment due today + self.assertContains(response, '