Merge pull request #22990 from edx/jlajoie/AA-6

AA-6: Adds in Dates tab for Course Overview
This commit is contained in:
Jeff LaJoie
2020-02-25 11:13:05 -05:00
committed by GitHub
13 changed files with 383 additions and 6 deletions

View File

@@ -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

View File

@@ -413,6 +413,7 @@ class CourseTabList(List):
discussion_tab,
CourseTab.load('wiki'),
CourseTab.load('progress'),
CourseTab.load('dates'),
])
@staticmethod

View File

@@ -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.

View File

@@ -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):

View File

@@ -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

View File

@@ -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">')

View File

@@ -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)

View File

@@ -50,6 +50,7 @@
@import "course/profile";
@import "course/tabs";
@import "course/student-notes";
@import "course/dates";
@import "views/teams";
// course - instructor-only views

View File

@@ -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;
}
}

View File

@@ -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('&nbsp'),
)}
% endif
</div>
</div>
</div>
% endif
% endfor
</section>
</div>
</div>
</main>

View File

@@ -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(

View File

@@ -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']

View File

@@ -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",