diff --git a/cms/djangoapps/contentstore/views/tests/test_tabs.py b/cms/djangoapps/contentstore/views/tests/test_tabs.py index 9f6f0a994c07128b90387ee463e91b432670defd..11678489d5ec5b09369b6c4c454037dad9f3702a 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 fa1b110987be442ad485f0959da69f500376d416..ffd98876c42868ac22e5b44b40f96a069dd8f5bd 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 a6f6ad06e4ae5cafc3b4769a3cbbd3ef348d55bc..b81b16ee6af7a60287c56259781ca8d02f36cffe 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 5746495de7eea273fcaf1e0812d42b8a4dcdcd6a..fab8e711bce19cf5aff80c865676a3d6f50afa84 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 0d075e53256d10825c174c6d408fa36ed70859bd..88bf5d47e29c02142f2ae94c56cf5c0aed31be66 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 aa8852500274861747a254ab12e01c642953e4fd..46c31bb737a070040807ad978c0788f105b029b2 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, '<div class="pill due">') + # No pills for verified enrollments + self.assertNotContains(response, '<div class="pill verified">') + + 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 + } + + response = self._get_response(self.course) + 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, '<div class="pill due">') + # Should have verified pills for audit enrollments + self.assertContains(response, '<div class="pill verified">') diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 8a6ed95bbfd72b60a7be64111cdd60938f282576..4cd4c466e7407a51ee33d2ffba799d3137a542d1 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -57,6 +57,7 @@ from lms.djangoapps.courseware.courses import ( can_self_enroll_in_course, course_open_for_self_enrollment, get_course, + get_course_date_blocks, get_course_overview_with_access, get_course_with_access, get_courses, @@ -66,6 +67,7 @@ from lms.djangoapps.courseware.courses import ( sort_by_announcement, sort_by_start_date ) +from lms.djangoapps.courseware.date_summary import verified_upgrade_deadline_link from lms.djangoapps.courseware.masquerade import setup_masquerade from lms.djangoapps.courseware.model_data import FieldDataCache from lms.djangoapps.courseware.models import BaseStudentModuleHistory, StudentModule @@ -94,7 +96,7 @@ from openedx.core.djangoapps.credit.api import ( is_credit_course, is_user_eligible_for_credit ) -from openedx.core.djangoapps.enrollments.api import add_enrollment +from openedx.core.djangoapps.enrollments.api import add_enrollment, get_enrollment from openedx.core.djangoapps.enrollments.permissions import ENROLL_IN_COURSE from openedx.core.djangoapps.models.course_details import CourseDetails from openedx.core.djangoapps.plugin_api.views import EdxFragmentView @@ -1027,6 +1029,33 @@ def program_marketing(request, program_uuid): return render_to_response('courseware/program_marketing.html', context) +@ensure_csrf_cookie +@ensure_valid_course_key +def dates(request, course_id): + """ + Display the course's dates.html, or 404 if there is no such course. + Assumes the course_id is in a valid format. + """ + + course_key = CourseKey.from_string(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 + 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'], + 'verified_upgrade_link': verified_upgrade_deadline_link(request.user, course=course), + 'learner_is_verified': learner_is_verified, + } + + return render_to_response('courseware/dates.html', context) + + @transaction.non_atomic_requests @login_required @cache_control(no_cache=True, no_store=True, must_revalidate=True) diff --git a/lms/static/sass/_build-course.scss b/lms/static/sass/_build-course.scss index 641198aa56760486c2508c8492988a356b872868..ce6c72b7188379f3f89799a7eb292aa2524df4af 100644 --- a/lms/static/sass/_build-course.scss +++ b/lms/static/sass/_build-course.scss @@ -50,6 +50,7 @@ @import "course/profile"; @import "course/tabs"; @import "course/student-notes"; +@import "course/dates"; @import "views/teams"; // course - instructor-only views diff --git a/lms/static/sass/course/_dates.scss b/lms/static/sass/course/_dates.scss new file mode 100644 index 0000000000000000000000000000000000000000..169d50072b60c4bd4091fe15b2b72ee98ae1720a --- /dev/null +++ b/lms/static/sass/course/_dates.scss @@ -0,0 +1,120 @@ +.date-wrapper { + @extend .content; + + font-family: $helvetica; + + .date-title { + color: #414141; + font-weight: 500; + border-bottom: 0; + } + + .timeline-item { + border-left: solid 1px #2d323e; + color: #2d323e; + position: relative; + padding-left: 18px; + margin-left: 6px; + padding-bottom: 32px; + + &:first-of-type { + margin-top: 24px; + } + + &:last-of-type { + border-left: solid 1px transparent; + } + } + + .date-circle { + width: 7px; + height: 7px; + position: absolute; + left: -4px; + background-color: #2d323e; + border-radius: 50%; + + &.active { + width: 14px; + height: 14px; + left: -7px; + background-color: #2d323e; + } + } + + .date-content { + display: flex; + flex-wrap: wrap; + position: relative; + top: -7px; + flex: 100%; + line-height: 1.25; + + &.active { + top: -3px; + } + } + + .timeline-date-content { + @include font-size(16); + + display: flex; + flex: 100%; + font-weight: bold; + margin-bottom: 8px; + align-items: center; + } + + .timeline-title { + @include font-size(14); + + display: flex; + flex: 100%; + margin-bottom: 4px; + font-weight: bold; + line-height: 1.25; + + a { + color: #2d323e; + text-decoration: underline; + } + } + + .timeline-description { + @include font-size(14); + + display: flex; + flex: 100%; + line-height: 1.25; + + a { + color: #2d323e; + text-decoration: underline; + } + } + + .pill { + @include font-size(12); + + padding: 2px 8px 2px 8px; + border-radius: 5px; + margin-left: 8px; + font-style: italic; + font-weight: bold; + vertical-align: top; + + &.due { + background-color: #ffdb87; + color: #2d323e; + } + + &.verified { + background-color: #2d323e; + color: $white; + } + } + + .verified-icon { + padding-right: 8px; + } +} diff --git a/lms/templates/courseware/dates.html b/lms/templates/courseware/dates.html new file mode 100644 index 0000000000000000000000000000000000000000..15f5f351465bd031f689b9b92e4a2ca8b388f7af --- /dev/null +++ b/lms/templates/courseware/dates.html @@ -0,0 +1,73 @@ +<%page expression_filter="h"/> +<%inherit file="/main.html" /> +<%namespace name='static' file='/static_content.html'/> +<%! +from django.utils.translation import ugettext as _ +from lms.djangoapps.courseware.date_summary import CourseAssignmentDate, VerificationDeadlineDate, VerifiedUpgradeDeadlineDate +from openedx.core.djangolib.markup import HTML, Text +%> + +<%block name="bodyclass">view-in-course view-progress</%block> + +<%block name="headextra"> +<%static:css group='style-course-vendor'/> +<%static:css group='style-course'/> +</%block> + +<%block name="pagetitle">${_("{course.display_number_with_default} Course Info").format(course=course)}</%block> + +<%include file="/courseware/course_navigation.html" args="active_page='dates'" /> + +<main id="main" aria-label="Content" tabindex="-1"> + <div class="container"> + <div class="date-wrapper"> + <section class="course-info" id="course-info-dates"> + <h2 class="hd hd-2 date-title"> + ${_("Important Dates")} + </h2> + % 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) %> + <% is_assignment = isinstance(block, CourseAssignmentDate) %> + % if not (learner_is_verified and isinstance(block, VerifiedUpgradeDeadlineDate)): + <div class="timeline-item ${active}"> + <div class="date-circle ${active}"></div> + <div class="date-content ${active}"> + <div class="timeline-date-content"> + % if block.date: + <div class="timeline-date"> + ${block.date.strftime(block.date_format)} + </div> + % if active: + <div class="pill due">${_('Due Today')}</div> + % endif + % if block_is_verified and not learner_is_verified: + <div class="pill verified"><span class="fa fa-lock verified-icon" aria-hidden="true"></span>${_('Verified Only')}</div> + % endif + % endif + </div> + <div class="timeline-title "> + % if block.title_html and is_assignment: + ${block.title_html} + % else: + ${block.title} + % endif + </div> + <div class="timeline-description"> + ${block.description} + % if block_is_verified and verified_upgrade_link and not learner_is_verified: + ${Text(_('{a_start}Upgrade{a_end}{space}to a Verified Certificate for full access.')).format( + a_start=HTML('<a href={link}>').format(link=verified_upgrade_link), + a_end=HTML('</a>'), + space=HTML(' '), + )} + % endif + </div> + </div> + </div> + % endif + % endfor + </section> + </div> + </div> +</main> diff --git a/lms/urls.py b/lms/urls.py index 32aa6b67089fddd4d5065a53db8baeb75ee0fc85..b0ff293088b28364f075724df9a67c71b820e75d 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -451,6 +451,15 @@ urlpatterns += [ name='progress', ), + # dates page + url( + r'^courses/{}/dates'.format( + settings.COURSE_ID_PATTERN, + ), + courseware_views.dates, + name='dates', + ), + # Takes optional student_id for instructor use--shows profile as that student sees it. url( r'^courses/{}/progress/(?P<student_id>[^/]*)/$'.format( diff --git a/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py b/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py index 05bb64cb2243ab876af15f0fb6e46061c2f3f27e..f5f8b95a0eff1999318cb36682d04a499791cfe1 100644 --- a/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py +++ b/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py @@ -65,7 +65,7 @@ class CourseOverviewTestCase(CatalogIntegrationMixin, ModuleStoreTestCase, Cache None: None, } - COURSE_OVERVIEW_TABS = {'courseware', 'info', 'textbooks', 'discussion', 'wiki', 'progress'} + COURSE_OVERVIEW_TABS = {'courseware', 'info', 'textbooks', 'discussion', 'wiki', 'progress', 'dates'} ENABLED_SIGNALS = ['course_deleted', 'course_published'] diff --git a/setup.py b/setup.py index 3a07a12e21cc07bc3573fba1a1d44ebb826b6f96..46ea428665bb1c2327c4d1025638595f8dc385e1 100644 --- a/setup.py +++ b/setup.py @@ -21,6 +21,7 @@ setup( "ccx = lms.djangoapps.ccx.plugins:CcxCourseTab", "courseware = lms.djangoapps.courseware.tabs:CoursewareTab", "course_info = lms.djangoapps.courseware.tabs:CourseInfoTab", + "dates = lms.djangoapps.courseware.tabs:DatesTab", "discussion = lms.djangoapps.discussion.plugins:DiscussionTab", "edxnotes = lms.djangoapps.edxnotes.plugins:EdxNotesTab", "external_discussion = lms.djangoapps.courseware.tabs:ExternalDiscussionCourseTab",