diff --git a/lms/djangoapps/course_blocks/transformers/start_date.py b/lms/djangoapps/course_blocks/transformers/start_date.py new file mode 100644 index 0000000000..2838931943 --- /dev/null +++ b/lms/djangoapps/course_blocks/transformers/start_date.py @@ -0,0 +1,101 @@ +""" +Start Date Transformer implementation. +""" +from openedx.core.lib.block_cache.transformer import BlockStructureTransformer +from lms.djangoapps.courseware.access_utils import check_start_date +from xmodule.course_metadata_utils import DEFAULT_START_DATE + +from .utils import get_field_on_block + + +class StartDateTransformer(BlockStructureTransformer): + """ + A transformer that enforces the 'start' and 'days_early_for_beta' + fields on blocks by removing blocks from the block structure for + which the user does not have access. The 'start' field on a + block is percolated down to its descendants, so that all blocks + enforce the 'start' field from their ancestors. The assumed + 'start' value for a block is then the maximum of its parent and its + own. + + For a block with multiple parents, the assumed parent start date + value is a computed minimum of the start dates of all its parents. + So as long as one parent chain allows access, the block has access. + + Staff users are exempted from visibility rules. + """ + VERSION = 1 + MERGED_START_DATE = 'merged_start_date' + + @classmethod + def name(cls): + """ + Unique identifier for the transformer's class; + same identifier used in setup.py. + """ + return "start_date" + + @classmethod + def get_merged_start_date(cls, block_structure, block_key): + """ + Returns the merged value for the start date for the block with + the given block_key in the given block_structure. + """ + return block_structure.get_transformer_block_field( + block_key, cls, cls.MERGED_START_DATE, False + ) + + @classmethod + def collect(cls, block_structure): + """ + Collects any information that's necessary to execute this + transformer's transform method. + """ + block_structure.request_xblock_fields('days_early_for_beta') + + for block_key in block_structure.topological_traversal(): + + # compute merged value of start date from all parents + parents = block_structure.get_parents(block_key) + min_all_parents_start_date = min( + cls.get_merged_start_date(block_structure, parent_key) + for parent_key in parents + ) if parents else None + + # set the merged value for this block + block_start = get_field_on_block(block_structure.get_xblock(block_key), 'start') + if min_all_parents_start_date is None: + # no parents so just use value on block or default + merged_start_value = block_start or DEFAULT_START_DATE + + elif not block_start: + # no value on this block so take value from parents + merged_start_value = min_all_parents_start_date + + else: + # max of merged-start-from-all-parents and this block + merged_start_value = max(min_all_parents_start_date, block_start) + + block_structure.set_transformer_block_field( + block_key, + cls, + cls.MERGED_START_DATE, + merged_start_value + ) + + def transform(self, usage_info, block_structure): + """ + Mutates block_structure based on the given usage_info. + """ + # Users with staff access bypass the Start Date check. + if usage_info.has_staff_access: + return + + block_structure.remove_block_if( + lambda block_key: not check_start_date( + usage_info.user, + block_structure.get_xblock_field(block_key, 'days_early_for_beta'), + self.get_merged_start_date(block_structure, block_key), + usage_info.course_key, + ) + ) diff --git a/lms/djangoapps/course_blocks/transformers/tests/test_start_date.py b/lms/djangoapps/course_blocks/transformers/tests/test_start_date.py new file mode 100644 index 0000000000..88d1d744d9 --- /dev/null +++ b/lms/djangoapps/course_blocks/transformers/tests/test_start_date.py @@ -0,0 +1,118 @@ +""" +Tests for StartDateTransformer. +""" +import ddt +from datetime import timedelta +from django.utils.timezone import now +from mock import patch + +from courseware.tests.factories import BetaTesterFactory +from ..start_date import StartDateTransformer, DEFAULT_START_DATE +from .test_helpers import BlockParentsMapTestCase, update_block + + +@ddt.ddt +class StartDateTransformerTestCase(BlockParentsMapTestCase): + """ + StartDateTransformer Test + """ + STUDENT = 1 + BETA_USER = 2 + + class StartDateType(object): + """ + Use constant enum types for deterministic ddt test method names (rather than dynamically generated timestamps) + """ + released = 1, + future = 2, + default = 3 + + TODAY = now() + LAST_MONTH = TODAY - timedelta(days=30) + NEXT_MONTH = TODAY + timedelta(days=30) + + @classmethod + def start(cls, enum_value): + """ + Returns a start date for the given enum value + """ + if enum_value == cls.released: + return cls.LAST_MONTH + elif enum_value == cls.future: + return cls.NEXT_MONTH + else: + return DEFAULT_START_DATE + + def setUp(self, **kwargs): + super(StartDateTransformerTestCase, self).setUp(**kwargs) + self.beta_user = BetaTesterFactory(course_key=self.course.id, username='beta_tester', password=self.password) + course = self.get_block(0) + course.days_early_for_beta = 33 + update_block(course) + + # Following test cases are based on BlockParentsMapTestCase.parents_map: + # 0 + # / \ + # 1 2 + # / \ / \ + # 3 4 / 5 + # \ / + # 6 + @patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False}) + @ddt.data( + (STUDENT, {}, {}, {}), + (STUDENT, {0: StartDateType.default}, {}, {}), + (STUDENT, {0: StartDateType.future}, {}, {}), + (STUDENT, {0: StartDateType.released}, {0, 1, 2, 3, 4, 5, 6}, {}), + + # has_access checks on block directly and doesn't follow negative access set on parent/ancestor (i.e., 0) + (STUDENT, {1: StartDateType.released}, {}, {1, 3, 4, 6}), + (STUDENT, {2: StartDateType.released}, {}, {2, 5, 6}), + (STUDENT, {1: StartDateType.released, 2: StartDateType.released}, {}, {1, 2, 3, 4, 5, 6}), + + # DAG conflicts: has_access relies on field inheritance so it takes only the value from the first parent-chain + (STUDENT, {0: StartDateType.released, 4: StartDateType.future}, {0, 1, 2, 3, 5, 6}, {6}), + ( + STUDENT, + {0: StartDateType.released, 2: StartDateType.released, 4: StartDateType.future}, + {0, 1, 2, 3, 5, 6}, + {6}, + ), + (STUDENT, {0: StartDateType.released, 2: StartDateType.future, 4: StartDateType.released}, {0, 1, 3, 4, 6}, {}), + + # beta user cases + (BETA_USER, {}, {}, {}), + (BETA_USER, {0: StartDateType.default}, {}, {}), + (BETA_USER, {0: StartDateType.future}, {0, 1, 2, 3, 4, 5, 6}, {}), + (BETA_USER, {0: StartDateType.released}, {0, 1, 2, 3, 4, 5, 6}, {}), + + ( + BETA_USER, + {0: StartDateType.released, 2: StartDateType.default, 5: StartDateType.future}, + {0, 1, 3, 4, 6}, + {5}, + ), + (BETA_USER, {1: StartDateType.released, 2: StartDateType.default}, {}, {1, 3, 4, 6}), + (BETA_USER, {0: StartDateType.released, 4: StartDateType.future}, {0, 1, 2, 3, 4, 5, 6}, {}), + (BETA_USER, {0: StartDateType.released, 4: StartDateType.default}, {0, 1, 2, 3, 5, 6}, {6}), + ) + @ddt.unpack + # pylint: disable=invalid-name + def test_block_start_date( + self, + user_type, + start_date_type_values, + expected_student_visible_blocks, + blocks_with_differing_student_access + ): + for idx, start_date_type in start_date_type_values.iteritems(): + block = self.get_block(idx) + block.start = self.StartDateType.start(start_date_type) + update_block(block) + + self.assert_transform_results( + self.beta_user if user_type == self.BETA_USER else self.student, + expected_student_visible_blocks, + blocks_with_differing_student_access, + [StartDateTransformer()], + )