Initial version of new transformer.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -1233,7 +1233,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'],
|
||||
@@ -1245,6 +1245,40 @@ class EntranceExamTest(UniqueCourseTest):
|
||||
AutoAuthPage(self.browser, course_id=self.course_id).visit()
|
||||
|
||||
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.
|
||||
@@ -1252,12 +1286,13 @@ class EntranceExamTest(UniqueCourseTest):
|
||||
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 +1311,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'
|
||||
))
|
||||
|
||||
@@ -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('Student')
|
||||
self.assertEqual(course_home.outline.num_sections, 1)
|
||||
|
||||
courseware = CoursewarePage(self.browser, self.course_id)
|
||||
courseware.wait_for_page()
|
||||
StaffCoursewarePage(self.browser, self.course_id).set_staff_view_mode('Learner')
|
||||
self.assertEqual(courseware.num_sections, 1)
|
||||
courseware.visit()
|
||||
StaffCoursewarePage(self.browser, self.course_id).set_staff_view_mode('Student')
|
||||
self.assertIn(
|
||||
"To access course materials, you must score", courseware.entrance_exam_message_selector.text[0]
|
||||
)
|
||||
|
||||
@@ -51,8 +51,12 @@ def get_blocks(
|
||||
"""
|
||||
# create ordered list of transformers, adding BlocksAPITransformer at end.
|
||||
transformers = BlockStructureTransformers()
|
||||
can_view_special_exam = False
|
||||
if requested_fields is not None and 'special_exam' in requested_fields:
|
||||
can_view_special_exam = True
|
||||
if user is not None:
|
||||
transformers += COURSE_BLOCK_ACCESS_TRANSFORMERS + [MilestonesTransformer(), HiddenContentTransformer()]
|
||||
transformers += COURSE_BLOCK_ACCESS_TRANSFORMERS
|
||||
transformers += [MilestonesTransformer(can_view_special_exam), 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 MilestonesTransformer
|
||||
|
||||
|
||||
class SupportedFieldType(object):
|
||||
@@ -44,6 +45,8 @@ SUPPORTED_FIELDS = [
|
||||
# 'student_view_multi_device'
|
||||
SupportedFieldType(StudentViewTransformer.STUDENT_VIEW_MULTI_DEVICE, StudentViewTransformer),
|
||||
|
||||
SupportedFieldType('special_exam', MilestonesTransformer),
|
||||
|
||||
# set the block_field_name to None so the entire data for the transformer is serialized
|
||||
SupportedFieldType(None, BlockCountsTransformer, BlockCountsTransformer.BLOCK_COUNTS),
|
||||
|
||||
|
||||
@@ -2,16 +2,22 @@
|
||||
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 MilestonesTransformer(BlockStructureTransformer):
|
||||
"""
|
||||
Excludes all special exams (timed, proctored, practice proctored) from the student view.
|
||||
Excludes all blocks with unfulfilled milestones from the student view.
|
||||
@@ -23,6 +29,9 @@ class MilestonesTransformer(FilteringTransformerMixin, BlockStructureTransformer
|
||||
def name(cls):
|
||||
return "milestones"
|
||||
|
||||
def __init__(self, can_view_special_exams=True):
|
||||
self.can_view_special_exams = can_view_special_exams
|
||||
|
||||
@classmethod
|
||||
def collect(cls, block_structure):
|
||||
"""
|
||||
@@ -35,22 +44,79 @@ 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.
|
||||
"""
|
||||
|
||||
def add_special_exam_info(block_key):
|
||||
"""
|
||||
Adds special exam information to course blocks.
|
||||
"""
|
||||
if self.is_special_exam(block_key, block_structure):
|
||||
|
||||
#
|
||||
# call into edx_proctoring subsystem
|
||||
# to get relevant proctoring information regarding this
|
||||
# level of the courseware
|
||||
#
|
||||
# This will return None, if (user, course_id, content_id)
|
||||
# is not applicable
|
||||
#
|
||||
timed_exam_attempt_context = None
|
||||
try:
|
||||
timed_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 timed_exam_attempt_context:
|
||||
# yes, user has proctoring context about
|
||||
# this level of the courseware
|
||||
# so add to the accordion data context
|
||||
block_structure.set_transformer_block_field(
|
||||
block_key,
|
||||
self,
|
||||
'special_exam',
|
||||
timed_exam_attempt_context,
|
||||
)
|
||||
|
||||
root_key = block_structure.root_block_usage_key
|
||||
course_key = root_key.course_key
|
||||
user_can_skip = EntranceExamConfiguration.user_can_skip_entrance_exam(usage_info.user, course_key)
|
||||
exam_id = block_structure.get_xblock_field(root_key, 'entrance_exam_id')
|
||||
required_content = milestones_helpers.get_required_content(course_key, usage_info.user)
|
||||
if user_can_skip:
|
||||
required_content = [content for content in required_content if not content == exam_id]
|
||||
|
||||
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)
|
||||
if usage_info.has_staff_access:
|
||||
return False
|
||||
elif self.has_pending_milestones_for_user(block_key, usage_info):
|
||||
return True
|
||||
elif required_content:
|
||||
if block_key.block_type == 'chapter' and unicode(block_key) not in required_content:
|
||||
return True
|
||||
elif (settings.FEATURES.get('ENABLE_SPECIAL_EXAMS', False) and
|
||||
(self.is_special_exam(block_key, block_structure) and
|
||||
not self.can_view_special_exams)):
|
||||
return True
|
||||
return False
|
||||
|
||||
return [block_structure.create_removal_filter(user_gated_from_block)]
|
||||
for block_key in block_structure.topological_traversal():
|
||||
if user_gated_from_block(block_key):
|
||||
block_structure.remove_block(block_key, False)
|
||||
else:
|
||||
add_special_exam_info(block_key)
|
||||
|
||||
@staticmethod
|
||||
def is_special_exam(block_key, block_structure):
|
||||
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
@@ -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_can_view_special(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):
|
||||
|
||||
@@ -27,6 +27,7 @@ from django.utils.translation import ugettext as _
|
||||
</div>
|
||||
<ol class="outline-item focusable" role="group" tabindex="0">
|
||||
% for subsection in section.get('children') or []:
|
||||
${ subsection.get('special_exam', '') }
|
||||
<li
|
||||
class="subsection ${ 'current' if subsection['current'] else '' }"
|
||||
role="treeitem"
|
||||
|
||||
@@ -47,7 +47,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'],
|
||||
block_types_filter=['course', 'chapter', 'sequential']
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user