From 858154a7d65cdf06b3354413d7359fc515ee5288 Mon Sep 17 00:00:00 2001 From: Dillon Dumesnil Date: Mon, 27 Apr 2020 14:06:07 -0700 Subject: [PATCH] AA-99: Adding in new date pills for the dates tab --- lms/djangoapps/course_api/blocks/api.py | 7 +- .../blocks/transformers/__init__.py | 2 + lms/djangoapps/course_blocks/api.py | 3 +- .../course_blocks/transformers/start_date.py | 2 +- lms/djangoapps/course_blocks/usage_info.py | 8 +- lms/djangoapps/courseware/courses.py | 74 ++++----- lms/djangoapps/courseware/date_summary.py | 4 +- .../courseware/tests/test_date_summary.py | 151 ++++++++---------- lms/djangoapps/courseware/tests/test_views.py | 76 ++++----- lms/djangoapps/courseware/views/views.py | 10 +- lms/static/sass/course/_dates.scss | 56 ++++++- lms/templates/courseware/dates.html | 73 +++++---- .../features/calendar_sync/tests/test_ics.py | 7 +- .../content_type_gating/block_transformers.py | 13 ++ openedx/features/course_experience/utils.py | 11 +- .../views/course_home_messages.py | 2 +- scripts/thresholds.sh | 2 +- 17 files changed, 280 insertions(+), 221 deletions(-) diff --git a/lms/djangoapps/course_api/blocks/api.py b/lms/djangoapps/course_api/blocks/api.py index 39103a1d9f..1befdb2def 100644 --- a/lms/djangoapps/course_api/blocks/api.py +++ b/lms/djangoapps/course_api/blocks/api.py @@ -29,6 +29,7 @@ def get_blocks( return_type='dict', block_types_filter=None, hide_access_denials=False, + allow_start_dates_in_future=False, ): """ Return a serialized representation of the course blocks. @@ -58,6 +59,9 @@ def get_blocks( hide_access_denials (bool): When True, filter out any blocks that were denied access to the user, even if they have access denial messages attached. + allow_start_dates_in_future (bool): When True, will allow blocks to be + returned that can bypass the StartDateTransformer's filter to show + blocks with start dates in the future. """ if HIDE_ACCESS_DENIALS_FLAG.is_enabled(): @@ -101,7 +105,8 @@ def get_blocks( transformers += [BlockCompletionTransformer()] # transform - blocks = course_blocks_api.get_course_blocks(user, usage_key, transformers) + blocks = course_blocks_api.get_course_blocks( + user, usage_key, transformers, allow_start_dates_in_future=allow_start_dates_in_future) # filter blocks by types if block_types_filter: diff --git a/lms/djangoapps/course_api/blocks/transformers/__init__.py b/lms/djangoapps/course_api/blocks/transformers/__init__.py index 5ef9850c0b..27c4ecd065 100644 --- a/lms/djangoapps/course_api/blocks/transformers/__init__.py +++ b/lms/djangoapps/course_api/blocks/transformers/__init__.py @@ -42,7 +42,9 @@ SUPPORTED_FIELDS = [ SupportedFieldType('display_name', default_value=''), SupportedFieldType('graded'), SupportedFieldType('format'), + SupportedFieldType('start'), SupportedFieldType('due'), + SupportedFieldType('contains_gated_content'), SupportedFieldType('has_score'), SupportedFieldType('weight'), SupportedFieldType('show_correctness'), diff --git a/lms/djangoapps/course_blocks/api.py b/lms/djangoapps/course_blocks/api.py index f1b65d0d9e..05a9614a4b 100644 --- a/lms/djangoapps/course_blocks/api.py +++ b/lms/djangoapps/course_blocks/api.py @@ -57,6 +57,7 @@ def get_course_blocks( starting_block_usage_key, transformers=None, collected_block_structure=None, + allow_start_dates_in_future=False, ): """ A higher order function implemented on top of the @@ -90,7 +91,7 @@ def get_course_blocks( """ if not transformers: transformers = BlockStructureTransformers(get_course_block_access_transformers(user)) - transformers.usage_info = CourseUsageInfo(starting_block_usage_key.course_key, user) + transformers.usage_info = CourseUsageInfo(starting_block_usage_key.course_key, user, allow_start_dates_in_future) return get_block_structure_manager(starting_block_usage_key.course_key).get_transformed( transformers, diff --git a/lms/djangoapps/course_blocks/transformers/start_date.py b/lms/djangoapps/course_blocks/transformers/start_date.py index 06932819e2..03182e8568 100644 --- a/lms/djangoapps/course_blocks/transformers/start_date.py +++ b/lms/djangoapps/course_blocks/transformers/start_date.py @@ -71,7 +71,7 @@ class StartDateTransformer(FilteringTransformerMixin, BlockStructureTransformer) def transform_block_filters(self, usage_info, block_structure): # Users with staff access bypass the Start Date check. - if usage_info.has_staff_access: + if usage_info.has_staff_access or usage_info.allow_start_dates_in_future: return [block_structure.create_universal_filter()] removal_condition = lambda block_key: not check_start_date( diff --git a/lms/djangoapps/course_blocks/usage_info.py b/lms/djangoapps/course_blocks/usage_info.py index 0d347120e5..457fc00f0a 100644 --- a/lms/djangoapps/course_blocks/usage_info.py +++ b/lms/djangoapps/course_blocks/usage_info.py @@ -14,13 +14,19 @@ class CourseUsageInfo(object): an instance of it in calls to BlockStructureTransformer.transform methods. ''' - def __init__(self, course_key, user): + def __init__(self, course_key, user, allow_start_dates_in_future=False): # Course identifier (opaque_keys.edx.keys.CourseKey) self.course_key = course_key # User object (django.contrib.auth.models.User) self.user = user + # Sometimes we want to allow blocks to be returned that can bypass the + # StartDateTransformer's filter to show blocks with start dates in the future. + # One use case of this is for the Dates page where we want to display + # assignments that have not yet been released. + self.allow_start_dates_in_future = allow_start_dates_in_future + # Cached value of whether the user has staff access (bool/None) self._has_staff_access = None diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index f670eb6576..0403f81ef3 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -6,7 +6,7 @@ courseware. import logging from collections import defaultdict, namedtuple -from datetime import datetime +from datetime import datetime, timedelta import pytz import six @@ -17,7 +17,6 @@ from django.http import Http404, QueryDict from django.urls import reverse from django.utils.translation import ugettext as _ from edx_django_utils.monitoring import function_trace -from edx_when.api import get_dates_for_course from fs.errors import ResourceNotFound from opaque_keys.edx.keys import UsageKey from path import Path as path @@ -58,7 +57,8 @@ from openedx.core.djangoapps.enrollments.api import get_course_enrollment_detail from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.lib.api.view_utils import LazySequence from openedx.features.course_duration_limits.access import AuditExpiredError -from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, RELATIVE_DATES_FLAG +from openedx.features.course_experience import RELATIVE_DATES_FLAG +from openedx.features.course_experience.utils import get_course_outline_block_tree from static_replace import replace_static_urls from student.models import CourseEnrollment from survey.utils import SurveyRequiredAccessError, check_survey_required_and_unanswered @@ -66,14 +66,14 @@ 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__) # Used by get_course_assignments below. You shouldn't need to use this type directly. -_Assignment = namedtuple('Assignment', ['block_key', 'title', 'url', 'date', 'requires_full_access']) +_Assignment = namedtuple( + 'Assignment', ['block_key', 'title', 'url', 'date', 'contains_gated_content', 'complete', 'past_due'] +) def get_course(course_id, depth=0): @@ -500,7 +500,9 @@ def get_course_assignment_date_blocks(course, user, request, num_return=None, for assignment in get_course_assignments(course.id, user, request, include_access=include_access): date_block = CourseAssignmentDate(course, user) date_block.date = assignment.date - date_block.requires_full_access = assignment.requires_full_access + date_block.contains_gated_content = assignment.contains_gated_content + date_block.complete = assignment.complete + date_block.past_due = assignment.past_due date_block.set_title(assignment.title, link=assignment.url) date_blocks.append(date_block) date_blocks = sorted((b for b in date_blocks if b.is_enabled or include_past_dates), key=date_block_key_fn) @@ -513,52 +515,38 @@ def get_course_assignments(course_key, user, request, include_access=False): """ Returns a list of assignment (at the subsection/sequential level) due dates for the given course. - Each returned object is a namedtuple with fields: block_key, title, url, date, requires_full_access + Each returned object is a namedtuple with fields: title, url, date, contains_gated_content, complete, past_due """ - store = modulestore() - all_course_dates = get_dates_for_course(course_key, user) - block_data = course_blocks_api.get_course_blocks(user, store.make_course_usage_key(course_key)) assignments = [] - for (block_key, date_type), date in all_course_dates.items(): - if date_type != 'due' or block_key.block_type != 'sequential': - continue + # Ideally this function is always called with a request being passed in, but because it is also + # a subfunction of `get_course_date_blocks` which does not require a request, we are being defensive here. + if not request: + return assignments - if block_key not in block_data: - continue + now = datetime.now(pytz.UTC) + course_root_block = get_course_outline_block_tree(request, str(course_key), user, allow_start_dates_in_future=True) + for section in course_root_block.get('children', []): + for subsection in section.get('children', []): + if not subsection.get('due') or not subsection.get('graded'): + continue - block = block_data[block_key] - if not block.graded: - continue + contains_gated_content = include_access and subsection.get('contains_gated_content', False) + title = subsection.get('display_name', _('Assignment')) - requires_full_access = include_access and _requires_full_access(block_data, block, user) - title = block.display_name or _('Assignment') + url = None + assignment_released = not subsection.get('start') or subsection.get('start') < now + if assignment_released: + url = subsection.get('lms_web_url') - url = None - assignment_released = not block.start or block.start < datetime.now(pytz.UTC) - if assignment_released: - url = reverse('jump_to', args=[course_key, block_key]) - url = request and request.build_absolute_uri(url) - - assignments.append(_Assignment(block_key, title, url, date, requires_full_access)) + complete = subsection.get('complete') + past_due = not complete and subsection.get('due', now + timedelta(1)) < now + assignments.append(_Assignment( + subsection.get('id'), title, url, subsection.get('due'), contains_gated_content, complete, past_due + )) return assignments -def _requires_full_access(block_data, block, user): - """ - Returns a boolean if any child of the block_key specified has a group_access array consisting of just full_access - """ - for child_block_key in block_data.get_children(block.location): - group_access = block_data.get_xblock_field(child_block_key, 'group_access') - # If group_access is set on the block, and the content gating is - # only full access, set the value on the CourseAssignmentDate object - if(group_access and 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 4877e13e74..e82e711837 100644 --- a/lms/djangoapps/courseware/date_summary.py +++ b/lms/djangoapps/courseware/date_summary.py @@ -346,7 +346,9 @@ class CourseAssignmentDate(DateSummary): self.assignment_date = None self.assignment_title = None self.assignment_title_html = None - self.requires_full_access = None + self.contains_gated_content = False + self.complete = None + self.past_due = None @property def date(self): diff --git a/lms/djangoapps/courseware/tests/test_date_summary.py b/lms/djangoapps/courseware/tests/test_date_summary.py index 56b1556aa5..7c07fd9716 100644 --- a/lms/djangoapps/courseware/tests/test_date_summary.py +++ b/lms/djangoapps/courseware/tests/test_date_summary.py @@ -158,7 +158,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): assignment_title_html = [''] with self.store.bulk_operations(course.id): section = ItemFactory.create(category='chapter', parent_location=course.location) - subsection_1 = ItemFactory.create( + ItemFactory.create( category='sequential', display_name='Released', parent_location=section.location, @@ -166,7 +166,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): due=now + timedelta(days=6), graded=True, ) - subsection_2 = ItemFactory.create( + ItemFactory.create( category='sequential', display_name='Not released', parent_location=section.location, @@ -174,7 +174,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): due=now + timedelta(days=7), graded=True, ) - subsection_3 = ItemFactory.create( + ItemFactory.create( category='sequential', display_name='Third nearest assignment', parent_location=section.location, @@ -182,7 +182,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): due=now + timedelta(days=8), graded=True, ) - subsection_4 = ItemFactory.create( + ItemFactory.create( category='sequential', display_name='Past due date', parent_location=section.location, @@ -190,7 +190,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): due=now - timedelta(days=7), graded=True, ) - subsection_5 = ItemFactory.create( + ItemFactory.create( category='sequential', display_name='Not returned since we do not get non-graded subsections', parent_location=section.location, @@ -198,7 +198,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): due=now - timedelta(days=7), graded=False, ) - subsection_6 = ItemFactory.create( + ItemFactory.create( category='sequential', display_name='No start date', parent_location=section.location, @@ -206,7 +206,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): due=now + timedelta(days=9), graded=True, ) - subsection_7 = ItemFactory.create( + ItemFactory.create( category='sequential', # Setting display name to None should set the assignment title to 'Assignment' display_name=None, @@ -222,85 +222,66 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course.id): self.store.delete_item(dummy_subsection.location, user.id) - with patch('lms.djangoapps.courseware.courses.get_dates_for_course') as mock_get_dates: - mock_get_dates.return_value = { - (subsection_1.location, 'due'): subsection_1.due, - (subsection_1.location, 'start'): subsection_1.start, - (subsection_2.location, 'due'): subsection_2.due, - (subsection_2.location, 'start'): subsection_2.start, - (subsection_3.location, 'due'): subsection_3.due, - (subsection_3.location, 'start'): subsection_3.start, - (subsection_4.location, 'due'): subsection_4.due, - (subsection_4.location, 'start'): subsection_4.start, - (subsection_5.location, 'due'): subsection_5.due, - (subsection_5.location, 'start'): subsection_5.start, - (subsection_6.location, 'due'): subsection_6.due, - (subsection_7.location, 'due'): subsection_7.due, - (subsection_7.location, 'start'): subsection_7.start, - # Adding this in for the case where we return a block that - # doesn't actually exist in the modulestore. Should just be ignored. - (dummy_subsection.location, 'due'): dummy_subsection.due, - } - # Standard widget case where we restrict the number of assignments. - expected_blocks = ( - TodaysDate, CourseAssignmentDate, CourseAssignmentDate, CourseEndDate, VerificationDeadlineDate - ) - blocks = get_course_date_blocks(course, user, request, num_assignments=2) - self.assertEqual(len(blocks), len(expected_blocks)) - self.assertEqual(set(type(b) for b in blocks), set(expected_blocks)) - assignment_blocks = filter(lambda b: isinstance(b, CourseAssignmentDate), blocks) - for assignment in assignment_blocks: - assignment_title = str(assignment.title_html) or str(assignment.title) - self.assertNotEqual(assignment_title, 'Third nearest assignment') - self.assertNotEqual(assignment_title, 'Past due date') - self.assertNotEqual(assignment_title, 'Not returned since we do not get non-graded subsections') - # checking if it is _in_ the title instead of being the title since released assignments - # are actually links. Unreleased assignments are just the string of the title. - if 'Released' in assignment_title: - for html_tag in assignment_title_html: - self.assertIn(html_tag, assignment_title) - elif assignment_title == 'Not released': - for html_tag in assignment_title_html: - self.assertNotIn(html_tag, assignment_title) + # Standard widget case where we restrict the number of assignments. + expected_blocks = ( + TodaysDate, CourseAssignmentDate, CourseAssignmentDate, CourseEndDate, VerificationDeadlineDate + ) + blocks = get_course_date_blocks(course, user, request, num_assignments=2) + self.assertEqual(len(blocks), len(expected_blocks)) + self.assertEqual(set(type(b) for b in blocks), set(expected_blocks)) + assignment_blocks = filter(lambda b: isinstance(b, CourseAssignmentDate), blocks) + for assignment in assignment_blocks: + assignment_title = str(assignment.title_html) or str(assignment.title) + self.assertNotEqual(assignment_title, 'Third nearest assignment') + self.assertNotEqual(assignment_title, 'Past due date') + self.assertNotEqual(assignment_title, 'Not returned since we do not get non-graded subsections') + # checking if it is _in_ the title instead of being the title since released assignments + # are actually links. Unreleased assignments are just the string of the title. + if 'Released' in assignment_title: + for html_tag in assignment_title_html: + self.assertIn(html_tag, assignment_title) + elif assignment_title == 'Not released': + for html_tag in assignment_title_html: + self.assertNotIn(html_tag, assignment_title) - # No restrictions on number of assignments to return - expected_blocks = ( - CourseStartDate, TodaysDate, CourseAssignmentDate, CourseAssignmentDate, CourseAssignmentDate, - CourseAssignmentDate, CourseAssignmentDate, CourseAssignmentDate, CourseEndDate, - VerificationDeadlineDate - ) - blocks = get_course_date_blocks(course, user, request, include_past_dates=True) - self.assertEqual(len(blocks), len(expected_blocks)) - self.assertEqual(set(type(b) for b in blocks), set(expected_blocks)) - assignment_blocks = filter(lambda b: isinstance(b, CourseAssignmentDate), blocks) - for assignment in assignment_blocks: - assignment_title = str(assignment.title_html) or str(assignment.title) - self.assertNotEqual(assignment_title, 'Not returned since we do not get non-graded subsections') - # checking if it is _in_ the title instead of being the title since released assignments - # are actually links. Unreleased assignments are just the string of the title. - if 'Released' in assignment_title: - for html_tag in assignment_title_html: - self.assertIn(html_tag, assignment_title) - elif assignment_title == 'Not released': - for html_tag in assignment_title_html: - self.assertNotIn(html_tag, assignment_title) - elif assignment_title == 'Third nearest assignment': - # It's still not released - for html_tag in assignment_title_html: - self.assertNotIn(html_tag, assignment_title) - elif 'Past due date' in assignment_title: - self.assertGreater(now, assignment.date) - for html_tag in assignment_title_html: - self.assertIn(html_tag, assignment_title) - elif 'No start date' == assignment_title: - # Can't determine if it is released so it does not get a link - for html_tag in assignment_title_html: - self.assertNotIn(html_tag, assignment_title) - # This is the item with no display name where we set one ourselves. - elif 'Assignment' in assignment_title: - # Can't determine if it is released so it does not get a link - for html_tag in assignment_title_html: - self.assertIn(html_tag, assignment_title) + # No restrictions on number of assignments to return + expected_blocks = ( + CourseStartDate, TodaysDate, CourseAssignmentDate, CourseAssignmentDate, CourseAssignmentDate, + CourseAssignmentDate, CourseAssignmentDate, CourseAssignmentDate, CourseEndDate, + VerificationDeadlineDate + ) + blocks = get_course_date_blocks(course, user, request, include_past_dates=True) + self.assertEqual(len(blocks), len(expected_blocks)) + self.assertEqual(set(type(b) for b in blocks), set(expected_blocks)) + assignment_blocks = filter(lambda b: isinstance(b, CourseAssignmentDate), blocks) + for assignment in assignment_blocks: + assignment_title = str(assignment.title_html) or str(assignment.title) + self.assertNotEqual(assignment_title, 'Not returned since we do not get non-graded subsections') + # checking if it is _in_ the title instead of being the title since released assignments + # are actually links. Unreleased assignments are just the string of the title. + if 'Released' in assignment_title: + for html_tag in assignment_title_html: + self.assertIn(html_tag, assignment_title) + elif assignment_title == 'Not released': + for html_tag in assignment_title_html: + self.assertNotIn(html_tag, assignment_title) + elif assignment_title == 'Third nearest assignment': + # It's still not released + for html_tag in assignment_title_html: + self.assertNotIn(html_tag, assignment_title) + elif 'Past due date' in assignment_title: + self.assertGreater(now, assignment.date) + for html_tag in assignment_title_html: + self.assertIn(html_tag, assignment_title) + elif 'No start date' == assignment_title: + # Can't determine if it is released so it does not get a link + for html_tag in assignment_title_html: + self.assertNotIn(html_tag, assignment_title) + # This is the item with no display name where we set one ourselves. + elif 'Assignment' in assignment_title: + # Can't determine if it is released so it does not get a link + for html_tag in assignment_title_html: + self.assertIn(html_tag, assignment_title) @RELATIVE_DATES_FLAG.override(active=True) def test_enabled_block_types_with_expired_course(self): diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index ef67dee7af..5d6d906014 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -3187,55 +3187,47 @@ class DatesTabTestCase(ModuleStoreTestCase): 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 + due=now + timedelta(days=1), # Setting this to tomorrow so it'll show the 'Due Next' 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, '
') - # No pills for verified enrollments - self.assertNotContains(response, '
') + with patch('lms.djangoapps.courseware.views.views.get_enrollment') as mock_get_enrollment: + 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 today's date + self.assertContains(response, '
') + # Make sure pill exists for next due assignment + self.assertContains(response, '
') + # No pills for verified enrollments + self.assertNotContains(response, '
') - enrollment.delete() - subsection.due = now + timedelta(days=1) - enrollment = CourseEnrollmentFactory(course_id=self.course.id, user=self.user, mode=CourseMode.AUDIT) - mock_get_dates.return_value = { - (subsection.location, 'due'): subsection.due, - (subsection.location, 'start'): subsection.start, - } - mock_get_enrollment.return_value = { - 'mode': enrollment.mode - } + enrollment.delete() + enrollment = CourseEnrollmentFactory(course_id=self.course.id, user=self.user, mode=CourseMode.AUDIT) + mock_get_enrollment.return_value = { + 'mode': enrollment.mode + } - expected_calls = [ - call('course_id', text_type(self.course.id)), - call('user_id', self.user.id), - call('is_staff', self.user.is_staff), - ] + expected_calls = [ + call('course_id', text_type(self.course.id)), + call('user_id', self.user.id), + call('is_staff', self.user.is_staff), + ] - response = self._get_response(self.course) + response = self._get_response(self.course) - mock_set_custom_metric.assert_has_calls(expected_calls, any_order=True) - self.assertContains(response, subsection.display_name) - # Show the Verification Deadline for everyone - self.assertContains(response, 'Verification Deadline') - # Pill doesn't exist for assignment due tomorrow - self.assertNotContains(response, '
') - # Should have verified pills for audit enrollments - self.assertContains(response, '
') + mock_set_custom_metric.assert_has_calls(expected_calls, any_order=True) + self.assertContains(response, subsection.display_name) + # Show the Verification Deadline for everyone + self.assertContains(response, 'Verification Deadline') + # Pill doesn't exist for assignment due tomorrow + self.assertNotContains(response, '
') + # Should have verified pills for audit enrollments + self.assertContains(response, '
') class TestShowCoursewareMFE(TestCase): diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index b963ba4fed..bd914c3265 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -1071,20 +1071,20 @@ def dates(request, course_id): course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=False) course_date_blocks = get_course_date_blocks(course, request.user, request, include_access=True, include_past_dates=True) - enrollment = get_enrollment(request.user.username, course_id) + learner_is_verified = False + enrollment = get_enrollment(request.user.username, course_id) + if enrollment: + learner_is_verified = enrollment.get('mode') == 'verified' # User locale settings user_timezone_locale = user_timezone_locale_prefs(request) user_timezone = user_timezone_locale['user_timezone'] user_language = user_timezone_locale['user_language'] - if enrollment: - learner_is_verified = enrollment.get('mode') == 'verified' - context = { 'course': course, - 'course_date_blocks': [block for block in course_date_blocks if block.title != 'current_datetime'], + 'course_date_blocks': course_date_blocks, 'verified_upgrade_link': verified_upgrade_deadline_link(request.user, course=course), 'learner_is_verified': learner_is_verified, 'user_timezone': user_timezone, diff --git a/lms/static/sass/course/_dates.scss b/lms/static/sass/course/_dates.scss index eb53e22f89..da9ca399d0 100644 --- a/lms/static/sass/course/_dates.scss +++ b/lms/static/sass/course/_dates.scss @@ -59,15 +59,25 @@ width: 7px; height: 7px; position: absolute; - left: -4px; + left: -5px; background-color: #2d323e; border-radius: 50%; + border-style: solid; + border-width: thin; - &.active { - width: 14px; - height: 14px; - left: -7px; - background-color: #2d323e; + &.past-date { + background-color: #f5f5f5; + + &.past-due { + background-color: #d1d2d4; + } + } + + &.todays-date { + width: 13px; + height: 13px; + left: -8px; + background-color: #ffdb87; } } @@ -79,7 +89,7 @@ flex: 100%; line-height: 1.25; - &.active { + &.todays-date { top: -3px; } } @@ -99,6 +109,10 @@ font-weight: bold; margin-bottom: 8px; align-items: center; + + &.not-released { + color: #767676; + } } .timeline-title { @@ -114,6 +128,10 @@ color: #2d323e; text-decoration: underline; } + + &.not-released { + color: #d1d2d4; + } } .timeline-description { @@ -139,7 +157,29 @@ font-weight: bold; vertical-align: top; - &.due { + &.completed { + background-color: #f3f3f4; + color: #2d323e; + } + + &.due-next { + background-color: #686b73; + color: $white; + } + + &.not-released { + background-color: $white; + border-color: #d1d2d4; + border-style: solid; + border-width: thin; + color: #767676; + } + + &.past-due { + background-color: #d1d2d4; + } + + &.today { background-color: #ffdb87; color: #2d323e; } diff --git a/lms/templates/courseware/dates.html b/lms/templates/courseware/dates.html index ef37bb3fdd..8c7e9f5856 100644 --- a/lms/templates/courseware/dates.html +++ b/lms/templates/courseware/dates.html @@ -4,7 +4,7 @@ <%! from django.utils.translation import ugettext as _ -from lms.djangoapps.courseware.date_summary import CourseAssignmentDate, VerificationDeadlineDate, VerifiedUpgradeDeadlineDate +from lms.djangoapps.courseware.date_summary import CourseAssignmentDate, TodaysDate, VerificationDeadlineDate, VerifiedUpgradeDeadlineDate from openedx.core.djangolib.markup import HTML, Text %> @@ -26,7 +26,7 @@ from openedx.core.djangolib.markup import HTML, Text

${_("Important Dates")}

- <% has_locked_assignments = any(hasattr(block, 'requires_full_access') and block.requires_full_access for block in course_date_blocks if isinstance(block, CourseAssignmentDate)) %> + <% has_locked_assignments = any(hasattr(block, 'contains_gated_content') and block.contains_gated_content for block in course_date_blocks if isinstance(block, CourseAssignmentDate)) %> % if has_locked_assignments and verified_upgrade_link:
@@ -44,39 +44,58 @@ from openedx.core.djangolib.markup import HTML, Text
% endif + <% due_next_set = False %> % for block in course_date_blocks: - <% active = 'active' if block.date and (block.date.strftime(block.date_format) == block.current_time.strftime(block.date_format)) else '' %> - <% block_is_verified = (hasattr(block, 'requires_full_access') and block.requires_full_access) or isinstance(block, VerificationDeadlineDate) %> + <% block_is_verified = (hasattr(block, 'contains_gated_content') and block.contains_gated_content) or isinstance(block, VerificationDeadlineDate) %> <% learner_has_access = not block_is_verified or learner_is_verified %> <% access_class = '' if learner_has_access else 'no-access' %> <% is_assignment = isinstance(block, CourseAssignmentDate) %> + <% todays_date = 'todays-date' if isinstance(block, TodaysDate) else '' %> + <% past_date = 'past-date' if block.date and block.date < block.current_time else '' %> + <% past_due = 'past-due' if learner_is_verified and is_assignment and block.past_due else '' %> + <% due_in_future = True if learner_is_verified and is_assignment and block.date and block.date >= block.current_time else False %> + <% not_released = 'not-released' if learner_is_verified and is_assignment and not block.title_html else '' %> % if not (learner_is_verified and isinstance(block, VerifiedUpgradeDeadlineDate)): -
-
-
-
- % if block.date: -
- -
- % if active: -
${_('Due Today')}
- % endif - % if not learner_has_access: -
${_('Verified Only')}
- % endif +
+
+
+
+ % if block.date: +
+ +
+ % if todays_date: +
${_('Today')}
% endif -
-
- % if block.title_html and is_assignment and learner_has_access: - ${block.title_html} - % else: - ${block.title} + % if not learner_has_access: +
${_('Verified Only')}
+ % else: + % if is_assignment and block.complete: +
${_('Completed')}
+ % elif is_assignment and block.past_due: +
${_('Past Due')}
+ % elif is_assignment and due_in_future and not due_next_set: +
${_('Due Next')}
+ <% due_next_set = True %> + % endif + % if not_released: +
${_('Not yet released')}
+ % endif + %endif % endif
-
- ${block.description} -
+ % if not todays_date: +
+ % if block.title_html and is_assignment and learner_has_access: + ${block.title_html} + % else: + ${block.title} + % endif +
+
+ ${block.description} +
+ % endif
% endif diff --git a/openedx/features/calendar_sync/tests/test_ics.py b/openedx/features/calendar_sync/tests/test_ics.py index 8fa356fba2..a0bfe91dcd 100644 --- a/openedx/features/calendar_sync/tests/test_ics.py +++ b/openedx/features/calendar_sync/tests/test_ics.py @@ -31,9 +31,12 @@ class TestIcsGeneration(TestCase): self.request.site = SiteFactory() self.request.user = self.user - def make_assigment(self, block_key=None, title=None, url=None, date=None, requires_file_access=False): + def make_assigment( + self, block_key=None, title=None, url=None, date=None, contains_gated_content=False, complete=False, + past_due=False + ): """ Bundles given info into a namedtupled like get_course_assignments returns """ - return _Assignment(block_key, title, url, date, requires_file_access) + return _Assignment(block_key, title, url, date, contains_gated_content, complete, past_due) def expected_ics(self, *assignments): """ Returns hardcoded expected ics strings for given assignments """ diff --git a/openedx/features/content_type_gating/block_transformers.py b/openedx/features/content_type_gating/block_transformers.py index ffec502345..cfd4734b47 100644 --- a/openedx/features/content_type_gating/block_transformers.py +++ b/openedx/features/content_type_gating/block_transformers.py @@ -35,6 +35,18 @@ class ContentTypeGateTransformer(BlockStructureTransformer): """ block_structure.request_xblock_fields('group_access', 'graded', 'has_score', 'weight') + def _set_contains_gated_content_on_parents(self, block_structure, block_key): + """ + This will recursively set a field on all the parents of a block if one of the problems + inside of it is content gated. `contains_gated_content` can then be used to indicate something + in the blocks subtree is gated. + """ + for parent_block_key in block_structure.get_parents(block_key): + if block_structure.get_xblock_field(parent_block_key, 'contains_gated_content'): + continue + block_structure.override_xblock_field(parent_block_key, 'contains_gated_content', True) + self._set_contains_gated_content_on_parents(block_structure, parent_block_key) + def transform(self, usage_info, block_structure): if not ContentTypeGatingConfig.enabled_for_enrollment( user=usage_info.user, @@ -56,3 +68,4 @@ class ContentTypeGateTransformer(BlockStructureTransformer): [settings.CONTENT_TYPE_GATE_GROUP_IDS['full_access']] ) block_structure.override_xblock_field(block_key, 'group_access', current_access) + self._set_contains_gated_content_on_parents(block_structure, block_key) diff --git a/openedx/features/course_experience/utils.py b/openedx/features/course_experience/utils.py index d6b90880e4..5b679ebc4e 100644 --- a/openedx/features/course_experience/utils.py +++ b/openedx/features/course_experience/utils.py @@ -28,9 +28,13 @@ log = logging.getLogger(__name__) @request_cached() -def get_course_outline_block_tree(request, course_id, user=None): +def get_course_outline_block_tree(request, course_id, user=None, allow_start_dates_in_future=False): """ Returns the root block of the course outline, with children as blocks. + + allow_start_dates_in_future (bool): When True, will allow blocks to be + returned that can bypass the StartDateTransformer's filter to show + blocks with start dates in the future. """ assert user is None or user.is_authenticated @@ -208,6 +212,8 @@ def get_course_outline_block_tree(request, course_id, user=None): 'children', 'display_name', 'type', + 'start', + 'contains_gated_content', 'due', 'graded', 'has_score', @@ -216,7 +222,8 @@ def get_course_outline_block_tree(request, course_id, user=None): 'show_gated_sections', 'format' ], - block_types_filter=block_types_filter + block_types_filter=block_types_filter, + allow_start_dates_in_future=allow_start_dates_in_future, ) course_outline_root_block = all_blocks['blocks'].get(all_blocks['root'], None) diff --git a/openedx/features/course_experience/views/course_home_messages.py b/openedx/features/course_experience/views/course_home_messages.py index e60ab6e7d1..d1c32b761d 100644 --- a/openedx/features/course_experience/views/course_home_messages.py +++ b/openedx/features/course_experience/views/course_home_messages.py @@ -74,7 +74,7 @@ class CourseHomeMessageFragmentView(EdxFragmentView): _register_course_home_messages(request, course, user_access, course_start_data) # Register course date alerts - for course_date_block in get_course_date_blocks(course, request.user): + for course_date_block in get_course_date_blocks(course, request.user, request): course_date_block.register_alerts(request, course) # Register a course goal message, if appropriate diff --git a/scripts/thresholds.sh b/scripts/thresholds.sh index 0d7e7da870..8a81f0d22c 100755 --- a/scripts/thresholds.sh +++ b/scripts/thresholds.sh @@ -2,6 +2,6 @@ set -e export LOWER_PYLINT_THRESHOLD=1000 -export UPPER_PYLINT_THRESHOLD=3310 +export UPPER_PYLINT_THRESHOLD=3300 export ESLINT_THRESHOLD=5530 export STYLELINT_THRESHOLD=880