Commit c4f0a89d by Diana Huang Committed by GitHub

Merge pull request #14828 from edx/diana/new-transformer

Add existing sidebar information to the course outline page
parents c58b3a14 196d379c
......@@ -207,7 +207,7 @@ def remove_course_milestones(course_key, user, relationship):
milestones_api.remove_user_milestone({'id': user.id}, milestone)
def get_required_content(course, user):
def get_required_content(course_key, user):
"""
Queries milestones subsystem to see if the specified course is gated on one or more milestones,
and if those milestones can be fulfilled via completion of a particular course content module
......@@ -217,7 +217,7 @@ def get_required_content(course, user):
# Get all of the outstanding milestones for this course, for this user
try:
milestone_paths = get_course_milestones_fulfillment_paths(
unicode(course.id),
unicode(course_key),
serialize_user(user)
)
except InvalidMilestoneRelationshipTypeException:
......
......@@ -56,7 +56,7 @@ class CourseOutlinePage(PageObject):
SECTION_SELECTOR = '.outline-item.section:nth-of-type({0})'
SECTION_TITLES_SELECTOR = '.section-name span'
SUBSECTION_SELECTOR = SECTION_SELECTOR + ' .subsection:nth-of-type({1}) .outline-item'
SUBSECTION_TITLES_SELECTOR = SECTION_SELECTOR + ' .subsection a span:first-child'
SUBSECTION_TITLES_SELECTOR = SECTION_SELECTOR + ' .subsection .subsection-title'
OUTLINE_RESUME_COURSE_SELECTOR = '.outline-item .resume-right'
def __init__(self, browser, parent_page):
......
......@@ -30,6 +30,7 @@ class CoursewarePage(CoursePage):
def is_browser_on_page(self):
return self.q(css='.course-content').present
# TODO: TNL-6546: Remove and find callers
@property
def chapter_count_in_navigation(self):
"""
......
......@@ -25,7 +25,6 @@ from common.test.acceptance.pages.common.logout import LogoutPage
from common.test.acceptance.pages.lms import BASE_URL
from common.test.acceptance.pages.lms.account_settings import AccountSettingsPage
from common.test.acceptance.pages.lms.auto_auth import AutoAuthPage
from common.test.acceptance.pages.lms.bookmarks import BookmarksPage
from common.test.acceptance.pages.lms.create_mode import ModeCreationPage
from common.test.acceptance.pages.lms.course_home import CourseHomePage
from common.test.acceptance.pages.lms.course_info import CourseInfoPage
......@@ -635,6 +634,7 @@ class CourseWikiTest(UniqueCourseTest):
children_page.a11y_audit.check_for_accessibility_errors()
@attr(shard=1)
class HighLevelTabTest(UniqueCourseTest):
"""
Tests that verify each of the high-level tabs available within a course.
......@@ -688,7 +688,6 @@ class HighLevelTabTest(UniqueCourseTest):
# Auto-auth register for the course
AutoAuthPage(self.browser, course_id=self.course_id).visit()
@attr(shard=1)
def test_course_info(self):
"""
Navigate to the course info page.
......@@ -706,7 +705,6 @@ class HighLevelTabTest(UniqueCourseTest):
self.assertEqual(len(handout_links), 1)
self.assertIn('demoPDF.pdf', handout_links[0])
@attr(shard=1)
def test_progress(self):
"""
Navigate to the progress page.
......@@ -724,7 +722,6 @@ class HighLevelTabTest(UniqueCourseTest):
actual_scores = self.progress_page.scores(CHAPTER, SECTION)
self.assertEqual(actual_scores, EXPECTED_SCORES)
@attr(shard=1)
def test_static_tab(self):
"""
Navigate to a static tab (course content)
......@@ -734,7 +731,6 @@ class HighLevelTabTest(UniqueCourseTest):
self.tab_nav.go_to_tab('Test Static Tab')
self.assertTrue(self.tab_nav.is_on_tab('Test Static Tab'))
@attr(shard=1)
def test_static_tab_with_mathjax(self):
"""
Navigate to a static tab (course content)
......@@ -747,7 +743,6 @@ class HighLevelTabTest(UniqueCourseTest):
# Verify that Mathjax has rendered
self.tab_nav.mathjax_has_rendered()
@attr(shard=1)
def test_wiki_tab_first_time(self):
"""
Navigate to the course wiki tab. When the wiki is accessed for
......@@ -769,7 +764,6 @@ class HighLevelTabTest(UniqueCourseTest):
self.assertEqual(expected_article_name, course_wiki.article_name)
# TODO: TNL-6546: This whole function will be able to go away, replaced by test_course_home below.
@attr(shard=1)
def test_courseware_nav(self):
"""
Navigate to a particular unit in the course.
......@@ -805,13 +799,9 @@ class HighLevelTabTest(UniqueCourseTest):
self.courseware_page.nav.go_to_section('Test Section 2', 'Test Subsection 3')
self.assertTrue(self.courseware_page.nav.is_on_section('Test Section 2', 'Test Subsection 3'))
@attr(shard=1)
def test_course_home(self):
def test_course_home_tab(self):
"""
Navigate to the course home page using the tab.
Includes smoke test of course outline, courseware page, and breadcrumbs.
"""
# TODO: TNL-6546: Use tab navigation and remove course_home_page.visit().
#self.course_info_page.visit()
......@@ -825,56 +815,6 @@ class HighLevelTabTest(UniqueCourseTest):
# Check that the tab lands on the course home page.
self.assertTrue(self.course_home_page.is_browser_on_page())
# Check that the course navigation appears correctly
EXPECTED_SECTIONS = {
'Test Section': ['Test Subsection'],
'Test Section 2': ['Test Subsection 2', 'Test Subsection 3']
}
actual_sections = self.course_home_page.outline.sections
for section, subsections in EXPECTED_SECTIONS.iteritems():
self.assertIn(section, actual_sections)
self.assertEqual(actual_sections[section], EXPECTED_SECTIONS[section])
# Navigate to a particular section
self.course_home_page.outline.go_to_section('Test Section', 'Test Subsection')
# Check the sequence items on the courseware page
EXPECTED_ITEMS = ['Test Problem 1', 'Test Problem 2', 'Test HTML']
actual_items = self.courseware_page.nav.sequence_items
self.assertEqual(len(actual_items), len(EXPECTED_ITEMS))
for expected in EXPECTED_ITEMS:
self.assertIn(expected, actual_items)
# Use outline breadcrumb to get back to course home page.
self.courseware_page.nav.go_to_outline()
# Navigate to a particular section other than the default landing section.
self.course_home_page.outline.go_to_section('Test Section 2', 'Test Subsection 3')
self.assertTrue(self.courseware_page.nav.is_on_section('Test Section 2', 'Test Subsection 3'))
# Verify that we can navigate to the bookmarks page
self.course_home_page.visit()
self.course_home_page.click_bookmarks_button()
bookmarks_page = BookmarksPage(self.browser, self.course_id)
self.assertTrue(bookmarks_page.is_browser_on_page())
# Test "Resume Course" button from header
self.course_home_page.visit()
self.course_home_page.resume_course_from_header()
self.assertTrue(self.courseware_page.nav.is_on_section('Test Section 2', 'Test Subsection 3'))
# Test "Resume Course" button from within outline
self.course_home_page.visit()
self.course_home_page.outline.resume_course_from_outline()
self.assertTrue(self.courseware_page.nav.is_on_section('Test Section 2', 'Test Subsection 3'))
@attr('a11y')
def test_course_home_a11y(self):
self.course_home_page.visit()
self.course_home_page.a11y_audit.check_for_accessibility_errors()
@attr(shard=1)
class PDFTextBooksTabTest(UniqueCourseTest):
......@@ -1233,7 +1173,7 @@ class EntranceExamTest(UniqueCourseTest):
self.course_info['run'], self.course_info['display_name']
).install()
self.courseware_page = CoursewarePage(self.browser, self.course_id)
self.course_home_page = CourseHomePage(self.browser, self.course_id)
self.settings_page = SettingsPage(
self.browser,
self.course_info['org'],
......@@ -1246,18 +1186,53 @@ class EntranceExamTest(UniqueCourseTest):
def test_entrance_exam_section(self):
"""
Scenario: Any course that is enabled for an entrance exam, should have
entrance exam section in the course outline.
Given that I visit the course outline
And entrance exams are not yet enabled
Then I should not see an "Entrance Exam" section
When I log in as staff
And enable entrance exams
And I visit the course outline again as student
Then there should be an "Entrance Exam" chapter.'
"""
# visit the course outline and make sure there is no "Entrance Exam" section.
self.course_home_page.visit()
self.assertFalse('Entrance Exam' in self.course_home_page.outline.sections.keys())
# Logout and login as a staff.
LogoutPage(self.browser).visit()
AutoAuthPage(self.browser, course_id=self.course_id, staff=True).visit()
# visit course settings page and set/enabled entrance exam for that course.
self.settings_page.visit()
self.settings_page.entrance_exam_field.click()
self.settings_page.save_changes()
# Logout and login as a student.
LogoutPage(self.browser).visit()
AutoAuthPage(self.browser, course_id=self.course_id, staff=False).visit()
# visit the course outline and make sure there is an "Entrance Exam" section.
self.course_home_page.visit()
self.assertTrue('Entrance Exam' in self.course_home_page.outline.sections.keys())
# TODO: TNL-6546: Remove test
def test_entrance_exam_section_2(self):
"""
Scenario: Any course that is enabled for an entrance exam, should have entrance exam chapter at course
page.
Given that I am on the course page
When I view the course that has an entrance exam
Then there should be an "Entrance Exam" chapter.'
"""
courseware_page = CoursewarePage(self.browser, self.course_id)
entrance_exam_link_selector = '.accordion .course-navigation .chapter .group-heading'
# visit course page and make sure there is not entrance exam chapter.
self.courseware_page.visit()
self.courseware_page.wait_for_page()
courseware_page.visit()
courseware_page.wait_for_page()
self.assertFalse(element_has_text(
page=self.courseware_page,
page=courseware_page,
css_selector=entrance_exam_link_selector,
text='Entrance Exam'
))
......@@ -1276,10 +1251,10 @@ class EntranceExamTest(UniqueCourseTest):
AutoAuthPage(self.browser, course_id=self.course_id, staff=False).visit()
# visit course info page and make sure there is an "Entrance Exam" section.
self.courseware_page.visit()
self.courseware_page.wait_for_page()
courseware_page.visit()
courseware_page.wait_for_page()
self.assertTrue(element_has_text(
page=self.courseware_page,
page=courseware_page,
css_selector=entrance_exam_link_selector,
text='Entrance Exam'
))
......
# -*- coding: utf-8 -*-
"""
End-to-end tests for the LMS that utilize the course home page and course outline.
"""
from contextlib import contextmanager
from nose.plugins.attrib import attr
from ..helpers import auto_auth, load_data_str, UniqueCourseTest
from ...fixtures.course import CourseFixture, XBlockFixtureDesc
from ...pages.lms.bookmarks import BookmarksPage
from ...pages.lms.course_home import CourseHomePage
from ...pages.lms.courseware import CoursewarePage
class CourseHomeBaseTest(UniqueCourseTest):
"""
Provides base setup for course home tests.
"""
USERNAME = "STUDENT_TESTER"
EMAIL = "student101@example.com"
def setUp(self):
"""
Initialize pages and install a course fixture.
"""
super(CourseHomeBaseTest, self).setUp()
self.course_home_page = CourseHomePage(self.browser, self.course_id)
self.courseware_page = CoursewarePage(self.browser, self.course_id)
# Install a course with sections and problems
course_fix = CourseFixture(
self.course_info['org'],
self.course_info['number'],
self.course_info['run'],
self.course_info['display_name']
)
course_fix.add_children(
XBlockFixtureDesc('static_tab', 'Test Static Tab', data=r"static tab data with mathjax \(E=mc^2\)"),
XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
XBlockFixtureDesc('problem', 'Test Problem 1', data=load_data_str('multiple_choice.xml')),
XBlockFixtureDesc('problem', 'Test Problem 2', data=load_data_str('formula_problem.xml')),
XBlockFixtureDesc('html', 'Test HTML'),
)
),
XBlockFixtureDesc('chapter', 'Test Section 2').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection 2'),
XBlockFixtureDesc('sequential', 'Test Subsection 3').add_children(
XBlockFixtureDesc('problem', 'Test Problem A', data=load_data_str('multiple_choice.xml'))
),
)
).install()
# Auto-auth register for the course.
auto_auth(self.browser, self.USERNAME, self.EMAIL, False, self.course_id)
class CourseHomeTest(CourseHomeBaseTest):
"""
Tests the course home page with course outline.
"""
def test_course_home(self):
"""
Smoke test of course outline, breadcrumbs to and from cours outline, and bookmarks.
"""
self.course_home_page.visit()
# TODO: TNL-6546: Remove unified_course_view.
self.course_home_page.unified_course_view = True
self.courseware_page.nav.unified_course_view = True
# Check that the tab lands on the course home page.
self.assertTrue(self.course_home_page.is_browser_on_page())
# Check that the course navigation appears correctly
EXPECTED_SECTIONS = {
'Test Section': ['Test Subsection'],
'Test Section 2': ['Test Subsection 2', 'Test Subsection 3']
}
actual_sections = self.course_home_page.outline.sections
for section, subsections in EXPECTED_SECTIONS.iteritems():
self.assertIn(section, actual_sections)
self.assertEqual(actual_sections[section], EXPECTED_SECTIONS[section])
# Navigate to a particular section
self.course_home_page.outline.go_to_section('Test Section', 'Test Subsection')
# Check the sequence items on the courseware page
EXPECTED_ITEMS = ['Test Problem 1', 'Test Problem 2', 'Test HTML']
actual_items = self.courseware_page.nav.sequence_items
self.assertEqual(len(actual_items), len(EXPECTED_ITEMS))
for expected in EXPECTED_ITEMS:
self.assertIn(expected, actual_items)
# Use outline breadcrumb to get back to course home page.
self.courseware_page.nav.go_to_outline()
# Navigate to a particular section other than the default landing section.
self.course_home_page.outline.go_to_section('Test Section 2', 'Test Subsection 3')
self.assertTrue(self.courseware_page.nav.is_on_section('Test Section 2', 'Test Subsection 3'))
# Verify that we can navigate to the bookmarks page
self.course_home_page.visit()
self.course_home_page.click_bookmarks_button()
bookmarks_page = BookmarksPage(self.browser, self.course_id)
self.assertTrue(bookmarks_page.is_browser_on_page())
# Test "Resume Course" button from header
self.course_home_page.visit()
self.course_home_page.resume_course_from_header()
self.assertTrue(self.courseware_page.nav.is_on_section('Test Section 2', 'Test Subsection 3'))
# Test "Resume Course" button from within outline
self.course_home_page.visit()
self.course_home_page.outline.resume_course_from_outline()
self.assertTrue(self.courseware_page.nav.is_on_section('Test Section 2', 'Test Subsection 3'))
@attr('a11y')
class CourseHomeA11yTest(CourseHomeBaseTest):
"""
Tests the accessibility of the course home page with course outline.
"""
def setUp(self):
super(CourseHomeA11yTest, self).setUp()
def test_course_home_a11y(self):
"""
Test the accessibility of the course home page with course outline.
"""
course_home_page = CourseHomePage(self.browser, self.course_id)
course_home_page.visit()
course_home_page.a11y_audit.check_for_accessibility_errors()
......@@ -129,7 +129,7 @@ class CoursewareTest(UniqueCourseTest):
@ddt.ddt
class ProctoredExamTest(UniqueCourseTest):
"""
Test courseware.
Tests for proctored exams.
"""
USERNAME = "STUDENT_TESTER"
EMAIL = "student101@example.com"
......
......@@ -6,6 +6,7 @@ from textwrap import dedent
from common.test.acceptance.tests.helpers import UniqueCourseTest
from common.test.acceptance.pages.studio.auto_auth import AutoAuthPage
from common.test.acceptance.pages.lms.course_home import CourseHomePage
from common.test.acceptance.pages.lms.courseware import CoursewarePage
from common.test.acceptance.pages.lms.problem import ProblemPage
from common.test.acceptance.fixtures.course import CourseFixture, XBlockFixtureDesc
......@@ -92,6 +93,8 @@ class EntranceExamPassTest(EntranceExamTest):
When I pass entrance exam
Then I can see complete TOC of course
And I can see message indicating my pass status
When I switch to course home page
Then I see 2 sections
"""
self.courseware_page.visit()
problem_page = ProblemPage(self.browser)
......@@ -102,4 +105,29 @@ class EntranceExamPassTest(EntranceExamTest):
problem_page.click_submit()
self.courseware_page.wait_for_page()
self.assertTrue(self.courseware_page.has_passed_message())
self.assertEqual(self.courseware_page.chapter_count_in_navigation, 2)
course_home_page = CourseHomePage(self.browser, self.course_id)
course_home_page.visit()
self.assertEqual(course_home_page.outline.num_sections, 2)
# TODO: TNL-6546: Delete test using outline on courseware
def test_course_is_unblocked_as_soon_as_student_passes_entrance_exam_2(self):
"""
Scenario: Ensure that entrance exam status message is updated and courseware is unblocked as soon as
student passes entrance exam.
Given I have a course with entrance exam as pre-requisite
When I pass entrance exam
Then I can see complete TOC of course
And I can see message indicating my pass status
"""
self.courseware_page.visit()
problem_page = ProblemPage(self.browser)
self.assertEqual(problem_page.wait_for_page().problem_name,
'HEIGHT OF EIFFEL TOWER')
self.assertTrue(self.courseware_page.has_entrance_exam_message())
self.assertFalse(self.courseware_page.has_passed_message())
problem_page.click_choice('choice_1')
problem_page.click_submit()
self.courseware_page.wait_for_page()
self.assertTrue(self.courseware_page.has_passed_message())
self.assertEqual(self.courseware_page.num_sections, 2)
......@@ -14,6 +14,7 @@ from common.test.acceptance.pages.studio.import_export import (
ImportCoursePage)
from common.test.acceptance.pages.studio.library import LibraryEditPage
from common.test.acceptance.pages.studio.overview import CourseOutlinePage
from common.test.acceptance.pages.lms.course_home import CourseHomePage
from common.test.acceptance.pages.lms.courseware import CoursewarePage
from common.test.acceptance.pages.lms.staff_view import StaffCoursewarePage
......@@ -282,9 +283,13 @@ class TestEntranceExamCourseImport(ImportTestMixin, StudioCourseTest):
When I visit the import page
And I upload a course that has an entrance exam section named 'Entrance Exam'
And I visit the course outline page again
The section named 'Entrance Exam' should now be available.
And when I switch the view mode to student view and Visit CourseWare
Then I see one section in the sidebar that is 'Entrance Exam'
The section named 'Entrance Exam' should now be available
When I visit the LMS Course Home page
Then I should see a section named 'Section' or 'Entrance Exam'
When I switch the view mode to student view
Then I should only see a section named 'Entrance Exam'
When I visit the courseware page
Then a message regarding the 'Entrance Exam'
"""
self.landing_page.visit()
# Should not exist yet.
......@@ -300,10 +305,16 @@ class TestEntranceExamCourseImport(ImportTestMixin, StudioCourseTest):
self.landing_page.section("Section")
self.landing_page.view_live()
course_home = CourseHomePage(self.browser, self.course_id)
course_home.visit()
self.assertEqual(course_home.outline.num_sections, 2)
course_home.preview.set_staff_view_mode('Learner')
self.assertEqual(course_home.outline.num_sections, 1)
courseware = CoursewarePage(self.browser, self.course_id)
courseware.wait_for_page()
courseware.visit()
StaffCoursewarePage(self.browser, self.course_id).set_staff_view_mode('Learner')
self.assertEqual(courseware.num_sections, 1)
self.assertIn(
"To access course materials, you must score", courseware.entrance_exam_message_selector.text[0]
)
......
......@@ -7,7 +7,7 @@ from lms.djangoapps.course_blocks.transformers.hidden_content import HiddenConte
from openedx.core.djangoapps.content.block_structure.transformers import BlockStructureTransformers
from .transformers.blocks_api import BlocksAPITransformer
from .transformers.milestones import MilestonesTransformer
from .transformers.milestones import MilestonesAndSpecialExamsTransformer
from .serializers import BlockSerializer, BlockDictSerializer
......@@ -51,8 +51,12 @@ def get_blocks(
"""
# create ordered list of transformers, adding BlocksAPITransformer at end.
transformers = BlockStructureTransformers()
include_special_exams = False
if requested_fields is not None and 'special_exam_info' in requested_fields:
include_special_exams = True
if user is not None:
transformers += COURSE_BLOCK_ACCESS_TRANSFORMERS + [MilestonesTransformer(), HiddenContentTransformer()]
transformers += COURSE_BLOCK_ACCESS_TRANSFORMERS
transformers += [MilestonesAndSpecialExamsTransformer(include_special_exams), HiddenContentTransformer()]
transformers += [
BlocksAPITransformer(
block_counts,
......
......@@ -146,7 +146,7 @@ class TestGetBlocksQueryCounts(SharedModuleStoreTestCase):
self._get_blocks(
course,
expected_mongo_queries=0,
expected_sql_queries=5 if with_storage_backing else 4,
expected_sql_queries=6 if with_storage_backing else 5,
)
@ddt.data(
......@@ -164,5 +164,5 @@ class TestGetBlocksQueryCounts(SharedModuleStoreTestCase):
self._get_blocks(
course,
expected_mongo_queries,
expected_sql_queries=13 if with_storage_backing else 5,
expected_sql_queries=14 if with_storage_backing else 6,
)
......@@ -6,6 +6,7 @@ from lms.djangoapps.course_blocks.transformers.visibility import VisibilityTrans
from .student_view import StudentViewTransformer
from .block_counts import BlockCountsTransformer
from .navigation import BlockNavigationTransformer
from .milestones import MilestonesAndSpecialExamsTransformer
class SupportedFieldType(object):
......@@ -44,6 +45,8 @@ SUPPORTED_FIELDS = [
# 'student_view_multi_device'
SupportedFieldType(StudentViewTransformer.STUDENT_VIEW_MULTI_DEVICE, StudentViewTransformer),
SupportedFieldType('special_exam_info', MilestonesAndSpecialExamsTransformer),
# set the block_field_name to None so the entire data for the transformer is serialized
SupportedFieldType(None, BlockCountsTransformer, BlockCountsTransformer.BLOCK_COUNTS),
......
......@@ -2,19 +2,31 @@
Milestones Transformer
"""
import logging
from django.conf import settings
from openedx.core.djangoapps.content.block_structure.transformer import (
BlockStructureTransformer,
FilteringTransformerMixin,
)
from edx_proctoring.exceptions import ProctoredExamNotFoundException
from edx_proctoring.api import get_attempt_status_summary
from student.models import EntranceExamConfiguration
from util import milestones_helpers
log = logging.getLogger(__name__)
class MilestonesTransformer(FilteringTransformerMixin, BlockStructureTransformer):
class MilestonesAndSpecialExamsTransformer(BlockStructureTransformer):
"""
Excludes all special exams (timed, proctored, practice proctored) from the student view.
Excludes all blocks with unfulfilled milestones from the student view.
A transformer that handles both milestones and special (timed) exams.
It excludes all blocks with unfulfilled milestones from the student view. An entrance exam is considered a
milestone, and is not considered a "special exam".
It also includes or excludes all special (timed) exams (timed, proctored, practice proctored) in/from the
student view, based on the value of `include_special_exams`.
"""
WRITE_VERSION = 1
READ_VERSION = 1
......@@ -23,6 +35,9 @@ class MilestonesTransformer(FilteringTransformerMixin, BlockStructureTransformer
def name(cls):
return "milestones"
def __init__(self, include_special_exams=True):
self.include_special_exams = include_special_exams
@classmethod
def collect(cls, block_structure):
"""
......@@ -35,28 +50,42 @@ class MilestonesTransformer(FilteringTransformerMixin, BlockStructureTransformer
block_structure.request_xblock_fields('is_proctored_enabled')
block_structure.request_xblock_fields('is_practice_exam')
block_structure.request_xblock_fields('is_timed_exam')
block_structure.request_xblock_fields('entrance_exam_id')
def transform_block_filters(self, usage_info, block_structure):
if usage_info.has_staff_access:
return [block_structure.create_universal_filter()]
def transform(self, usage_info, block_structure):
"""
Modify block structure according to the behavior of milestones and special exams.
"""
required_content = self.get_required_content(usage_info, block_structure)
def user_gated_from_block(block_key):
"""
Checks whether the user is gated from accessing this block, first via special exams,
then via a general milestones check.
"""
return (
settings.FEATURES.get('ENABLE_SPECIAL_EXAMS', False) and
self.is_special_exam(block_key, block_structure)
) or self.has_pending_milestones_for_user(block_key, usage_info)
return [block_structure.create_removal_filter(user_gated_from_block)]
if usage_info.has_staff_access:
return False
elif self.has_pending_milestones_for_user(block_key, usage_info):
return True
elif self.gated_by_required_content(block_key, block_structure, required_content):
return True
elif (settings.FEATURES.get('ENABLE_SPECIAL_EXAMS', False) and
(self.is_special_exam(block_key, block_structure) and
not self.include_special_exams)):
return True
return False
for block_key in block_structure.topological_traversal():
if user_gated_from_block(block_key):
block_structure.remove_block(block_key, False)
elif self.is_special_exam(block_key, block_structure):
self.add_special_exam_info(block_key, block_structure, usage_info)
@staticmethod
def is_special_exam(block_key, block_structure):
"""
Test whether the block is a special exam. These exams are always excluded
from the student view.
Test whether the block is a special exam.
"""
return (
block_structure.get_xblock_field(block_key, 'is_proctored_enabled') or
......@@ -76,3 +105,66 @@ class MilestonesTransformer(FilteringTransformerMixin, BlockStructureTransformer
'requires',
usage_info.user.id
))
# TODO: As part of a cleanup effort, this transformer should be split into
# MilestonesTransformer and SpecialExamsTransformer, which are completely independent.
def add_special_exam_info(self, block_key, block_structure, usage_info):
"""
For special exams, add the special exam information to the course blocks.
"""
special_exam_attempt_context = None
try:
# Calls into edx_proctoring subsystem to get relevant special exam information.
# This will return None, if (user, course_id, content_id) is not applicable.
special_exam_attempt_context = get_attempt_status_summary(
usage_info.user.id,
unicode(block_key.course_key),
unicode(block_key)
)
except ProctoredExamNotFoundException as ex:
log.exception(ex)
if special_exam_attempt_context:
# This user has special exam context for this block so add it.
block_structure.set_transformer_block_field(
block_key,
self,
'special_exam_info',
special_exam_attempt_context,
)
@staticmethod
def get_required_content(usage_info, block_structure):
"""
Get the required content for the course.
This takes into account if the user can skip the entrance exam.
"""
course_key = block_structure.root_block_usage_key.course_key
user_can_skip_entrance_exam = EntranceExamConfiguration.user_can_skip_entrance_exam(usage_info.user, course_key)
required_content = milestones_helpers.get_required_content(course_key, usage_info.user)
if not required_content:
return required_content
if user_can_skip_entrance_exam:
# remove the entrance exam from required content
entrance_exam_id = block_structure.get_xblock_field(block_structure.root_block_usage_key, 'entrance_exam_id')
required_content = [content for content in required_content if not content == entrance_exam_id]
return required_content
@staticmethod
def gated_by_required_content(block_key, block_structure, required_content):
"""
Returns True if the current block associated with the block_key should be gated by the given required_content.
Returns False otherwise.
"""
if not required_content:
return False
if block_key.block_type == 'chapter' and unicode(block_key) not in required_content:
return True
return False
......@@ -9,9 +9,10 @@ from gating import api as lms_gating_api
from lms.djangoapps.course_blocks.transformers.tests.helpers import CourseStructureTestCase
from milestones.tests.utils import MilestonesTestCaseMixin
from openedx.core.lib.gating import api as gating_api
from openedx.core.djangoapps.content.block_structure.transformers import BlockStructureTransformers
from student.tests.factories import CourseEnrollmentFactory
from ..milestones import MilestonesTransformer
from ..milestones import MilestonesAndSpecialExamsTransformer
from ...api import get_course_blocks
......@@ -22,7 +23,7 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM
"""
Test behavior of ProctoredExamTransformer
"""
TRANSFORMER_CLASS_TO_TEST = MilestonesTransformer
TRANSFORMER_CLASS_TO_TEST = MilestonesAndSpecialExamsTransformer
def setUp(self):
"""
......@@ -38,6 +39,8 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM
# Enroll user in course.
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id, is_active=True)
self.transformers = BlockStructureTransformers([self.TRANSFORMER_CLASS_TO_TEST(False)])
def setup_gated_section(self, gated_block, gating_block):
"""
Test helper to create a gating requirement.
......@@ -157,7 +160,7 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM
self.course.enable_subsection_gating = True
self.setup_gated_section(self.blocks[gated_block_ref], self.blocks[gating_block_ref])
with self.assertNumQueries(6):
with self.assertNumQueries(8):
self.get_blocks_and_check_against_expected(self.user, expected_blocks_before_completion)
# clear the request cache to simulate a new request
......@@ -171,7 +174,7 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM
self.user,
)
with self.assertNumQueries(6):
with self.assertNumQueries(8):
self.get_blocks_and_check_against_expected(self.user, self.ALL_BLOCKS_EXCEPT_SPECIAL)
def test_staff_access(self):
......@@ -183,6 +186,30 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM
self.setup_gated_section(self.blocks['H'], self.blocks['A'])
self.get_blocks_and_check_against_expected(self.staff, expected_blocks)
def test_special_exams(self):
"""
When the block structure transformers are set to allow users to view special exams,
ensure that we can see the special exams and not any of the otherwise gated blocks.
"""
self.transformers = BlockStructureTransformers([self.TRANSFORMER_CLASS_TO_TEST(True)])
self.course.enable_subsection_gating = True
self.setup_gated_section(self.blocks['H'], self.blocks['A'])
expected_blocks = (
'course', 'A', 'B', 'C', 'ProctoredExam', 'D', 'E', 'PracticeExam', 'F', 'G', 'TimedExam', 'J', 'K'
)
self.get_blocks_and_check_against_expected(self.user, expected_blocks)
# clear the request cache to simulate a new request
self.clear_caches()
# this call triggers reevaluation of prerequisites fulfilled by the gating block.
with patch('gating.api._get_subsection_percentage', Mock(return_value=100)):
lms_gating_api.evaluate_prerequisite(
self.course,
Mock(location=self.blocks['A'].location),
self.user,
)
self.get_blocks_and_check_against_expected(self.user, self.ALL_BLOCKS)
def get_blocks_and_check_against_expected(self, user, expected_blocks):
"""
Calls the course API as the specified user and checks the
......
......@@ -55,7 +55,7 @@ def get_entrance_exam_content(user, course):
"""
Get the entrance exam content information (ie, chapter module)
"""
required_content = get_required_content(course, user)
required_content = get_required_content(course.id, user)
exam_module = None
for content in required_content:
......
......@@ -162,7 +162,7 @@ def toc_for_course(user, request, course, active_chapter, active_section, field_
# Check for content which needs to be completed
# before the rest of the content is made available
required_content = milestones_helpers.get_required_content(course, user)
required_content = milestones_helpers.get_required_content(course.id, user)
# The user may not actually have to complete the entrance exam, if one is required
if user_can_skip_entrance_exam(user, course):
......
......@@ -35,21 +35,32 @@
list-style-type: none;
a.outline-item {
display: block;
display: flex;
justify-content: space-between;
padding: ($baseline / 2);
&:hover {
background-color: palette(primary, x-back);
text-decoration: none;
}
.subsection-text {
.details {
font-size: $body-font-size;
color: $lms-gray;
font-style: italic;
}
}
.subsection-actions {
.resume-right {
position: relative;
top: calc(50% - (#{$baseline} / 2));
}
}
}
&.current {
border: 1px solid $lms-active-color;
.resume-right {
@include float(right);
}
}
}
}
......
......@@ -38,14 +38,81 @@ from django.utils.translation import ugettext as _
href="${ subsection['lms_web_url'] }"
id="${ subsection['id'] }"
>
<span>${ subsection['display_name'] }</span>
<span class="sr-only">${ _("This is your last visited course section.") }</span>
% if subsection['current']:
<span class="resume-right">
<b>${ _("Resume Course") }</b>
<span class="icon fa fa-arrow-circle-right" aria-hidden="true"></span>
</span>
% endif
<div class="subsection-text">
## Subsection title
<span class="subsection-title">${ subsection['display_name'] }</span>
<div class="details">
## There are behavior differences between rendering of subsections which have
## exams (timed, graded, etc) and those that do not.
##
## Exam subsections expose exam status message field as well as a status icon
<%
if subsection.get('due') is None:
# examples: Homework, Lab, etc.
data_string = subsection.get('format')
else:
if 'special_exam_info' in subsection:
data_string = _('due {date}')
else:
data_string = _("{subsection_format} due {{date}}").format(subsection_format=subsection.get('format'))
%>
% if subsection.get('format') or 'special_exam_info' in subsection:
<span class="subtitle">
% if 'special_exam' in subsection:
## Display the exam status icon and status message
<span
class="menu-icon icon fa ${subsection['special_exam_info'].get('suggested_icon', 'fa-pencil-square-o')} ${subsection['special_exam_info'].get('status', 'eligible')}"
aria-hidden="true"
></span>
<span class="subtitle-name">
${subsection['special_exam_info'].get('short_description', '')}
</span>
## completed exam statuses should not show the due date
## since the exam has already been submitted by the user
% if not subsection['special_exam_info'].get('in_completed_state', False):
<span
class="localized-datetime subtitle-name"
data-datetime="${subsection.get('due')}"
data-string="${data_string}"
data-timezone="${user_timezone}"
data-language="${user_language}"
></span>
% endif
% else:
## non-graded section, we just show the exam format and the due date
## this is the standard case in edx-platform
<span
class="localized-datetime subtitle-name"
data-datetime="${subsection.get('due')}"
data-string="${data_string}"
data-timezone="${user_timezone}"
data-language="${user_language}"
></span>
% if 'graded' in subsection and subsection['graded']:
<span
class="menu-icon icon fa fa-pencil-square-o"
aria-hidden="true"
></span>
<span class="sr">${_("This content is graded")}</span>
% endif
% endif
</span>
% endif
</div> <!-- /details -->
</div> <!-- /subsection-text -->
<div class="subsection-actions">
## Resume button (if last visited section)
% if subsection['current']:
<span class="sr-only">${ _("This is your last visited course section.") }</span>
<span class="resume-right">
<b>${ _("Resume Course") }</b>
<span class="icon fa fa-arrow-circle-right" aria-hidden="true"></span>
</span>
%endif
</div>
</a>
</li>
% endfor
......@@ -54,3 +121,7 @@ from django.utils.translation import ugettext as _
% endfor
</ol>
</div>
<%static:require_module_async module_name="js/dateutil_factory" class_name="DateUtilFactory">
DateUtilFactory.transform('.localized-datetime');
</%static:require_module_async>
......@@ -47,6 +47,22 @@ class TestCourseOutlinePage(SharedModuleStoreTestCase):
ItemFactory.create(category='vertical', parent_location=section2.location)
course.last_accessed = None
cls.courses.append(course)
course = CourseFactory.create()
with cls.store.bulk_operations(course.id):
chapter = ItemFactory.create(category='chapter', parent_location=course.location)
section = ItemFactory.create(
category='sequential',
parent_location=chapter.location,
due=datetime.datetime.now(),
graded=True,
format='Homework',
)
ItemFactory.create(category='vertical', parent_location=section.location)
course.last_accessed = section.url_name
cls.courses.append(course)
@classmethod
def setUpTestData(cls):
"""Set up and enroll our fake user in the course."""
......@@ -70,14 +86,14 @@ class TestCourseOutlinePage(SharedModuleStoreTestCase):
self.assertEqual(response.status_code, 200)
response_content = response.content.decode("utf-8")
if course.last_accessed is not None:
self.assertIn('Resume Course', response_content)
else:
self.assertNotIn('Resume Course', response_content)
self.assertIn('Resume Course', response_content)
for chapter in course.children:
self.assertIn(chapter.display_name, response_content)
for section in chapter.children:
self.assertIn(section.display_name, response_content)
if section.graded:
self.assertIn(section.due, response_content)
self.assertIn(section.format, response_content)
for vertical in section.children:
self.assertNotIn(vertical.display_name, response_content)
......
......@@ -29,6 +29,7 @@ class CourseOutlineFragmentView(EdxFragmentView):
for i in range(len(children)):
child_id = block['children'][i]
child_detail = self.populate_children(all_blocks[child_id], all_blocks, course_position)
block['children'][i] = child_detail
block['children'][i]['current'] = course_position == child_detail['block_id']
......@@ -47,7 +48,7 @@ class CourseOutlineFragmentView(EdxFragmentView):
course_usage_key,
user=request.user,
nav_depth=3,
requested_fields=['children', 'display_name', 'type'],
requested_fields=['children', 'display_name', 'type', 'due', 'graded', 'special_exam_info', 'format'],
block_types_filter=['course', 'chapter', 'sequential']
)
......
......@@ -52,7 +52,7 @@ setup(
"visibility = lms.djangoapps.course_blocks.transformers.visibility:VisibilityTransformer",
"hidden_content = lms.djangoapps.course_blocks.transformers.hidden_content:HiddenContentTransformer",
"course_blocks_api = lms.djangoapps.course_api.blocks.transformers.blocks_api:BlocksAPITransformer",
"milestones = lms.djangoapps.course_api.blocks.transformers.milestones:MilestonesTransformer",
"milestones = lms.djangoapps.course_api.blocks.transformers.milestones:MilestonesAndSpecialExamsTransformer",
"grades = lms.djangoapps.grades.transformer:GradesTransformer",
],
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment