fix: hide sequences & sections when access is restricted in units through cohorts (#33191)
This does two major things: * Removes subsections from a student's course outline if the subsection's units are all restricted to a cohort that the student is not a part of (see CohortPartitionGroupsOutlineProcessor). * Removes sections from the course outline if the user is not allowed to see any of its child subsections.
This commit is contained in:
@@ -303,9 +303,20 @@ class OutlineTabView(RetrieveAPIView):
|
||||
)
|
||||
available_seq_ids = {str(usage_key) for usage_key in user_course_outline.sequences}
|
||||
|
||||
available_section_ids = {str(section.usage_key) for section in user_course_outline.sections}
|
||||
|
||||
# course_blocks is a reference to the root of the course,
|
||||
# so we go through the chapters (sections) and keep only those
|
||||
# which are part of the outline.
|
||||
course_blocks['children'] = [
|
||||
chapter_data
|
||||
for chapter_data in course_blocks.get('children', [])
|
||||
if chapter_data['id'] in available_section_ids
|
||||
]
|
||||
|
||||
# course_blocks is a reference to the root of the course, so we go
|
||||
# through the chapters (sections) to look for sequences to remove.
|
||||
for chapter_data in course_blocks.get('children', []):
|
||||
for chapter_data in course_blocks['children']:
|
||||
chapter_data['children'] = [
|
||||
seq_data
|
||||
for seq_data in chapter_data['children']
|
||||
|
||||
@@ -40,6 +40,7 @@ from ..models import (
|
||||
UserPartitionGroup
|
||||
)
|
||||
from .permissions import can_see_all_content
|
||||
from .processors.cohort_partition_groups import CohortPartitionGroupsOutlineProcessor
|
||||
from .processors.content_gating import ContentGatingOutlineProcessor
|
||||
from .processors.enrollment import EnrollmentOutlineProcessor
|
||||
from .processors.enrollment_track_partition_groups import EnrollmentTrackPartitionGroupsOutlineProcessor
|
||||
@@ -328,6 +329,7 @@ def _get_user_course_outline_and_processors(course_key: CourseKey, # lint-amnes
|
||||
('visibility', VisibilityOutlineProcessor),
|
||||
('enrollment', EnrollmentOutlineProcessor),
|
||||
('enrollment_track_partitions', EnrollmentTrackPartitionGroupsOutlineProcessor),
|
||||
('cohorts_partitions', CohortPartitionGroupsOutlineProcessor),
|
||||
]
|
||||
|
||||
# Run each OutlineProcessor in order to figure out what items we have to
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# lint-amnesty, pylint: disable=missing-module-docstring
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Union
|
||||
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from openedx.core import types
|
||||
from openedx.core.djangoapps.course_groups.cohorts import (
|
||||
get_cohort,
|
||||
get_cohorted_user_partition_id,
|
||||
get_group_info_for_cohort,
|
||||
)
|
||||
|
||||
from .base import OutlineProcessor
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CohortPartitionGroupsOutlineProcessor(OutlineProcessor):
|
||||
"""
|
||||
Processor for applying cohort user partition groups.
|
||||
|
||||
"""
|
||||
def __init__(self, course_key: CourseKey, user: types.User, at_time: datetime):
|
||||
super().__init__(course_key, user, at_time)
|
||||
self.user_cohort_group_id: Union[int, None] = None
|
||||
self.cohorted_partition_id: Union[int, None] = None
|
||||
|
||||
def load_data(self, full_course_outline) -> None:
|
||||
"""
|
||||
Load the cohorted partition id and the user's group id.
|
||||
"""
|
||||
|
||||
# It is possible that a cohort is not linked to any content group/partition.
|
||||
# This is why the cohorted_partition_id needs to be set independently
|
||||
# of a particular user's cohort.
|
||||
self.cohorted_partition_id = get_cohorted_user_partition_id(self.course_key)
|
||||
|
||||
if self.cohorted_partition_id:
|
||||
user_cohort = get_cohort(self.user, self.course_key)
|
||||
|
||||
if user_cohort:
|
||||
self.user_cohort_group_id, _ = get_group_info_for_cohort(user_cohort)
|
||||
|
||||
def _is_user_excluded_by_partition_group(self, user_partition_groups) -> bool:
|
||||
"""
|
||||
Is the user part of the group to which the block is restricting content?
|
||||
"""
|
||||
if not user_partition_groups:
|
||||
return False
|
||||
|
||||
groups = user_partition_groups.get(self.cohorted_partition_id)
|
||||
if not groups:
|
||||
return False
|
||||
|
||||
if self.user_cohort_group_id not in groups:
|
||||
# If the user's group (cohort) does not belong
|
||||
# to the partition of the block or the user's cohort
|
||||
# is not linked to a content group (user_cohort_group_id is None),
|
||||
# the block should be removed
|
||||
return True
|
||||
return False
|
||||
|
||||
def usage_keys_to_remove(self, full_course_outline):
|
||||
"""
|
||||
Content group exclusions remove the content entirely.
|
||||
|
||||
Remove sections and sequences inacessible by the user's
|
||||
cohort.
|
||||
"""
|
||||
if not self.cohorted_partition_id:
|
||||
return frozenset()
|
||||
|
||||
removed_usage_keys = set()
|
||||
for section in full_course_outline.sections:
|
||||
remove_all_children = False
|
||||
if self._is_user_excluded_by_partition_group(
|
||||
section.user_partition_groups
|
||||
):
|
||||
removed_usage_keys.add(section.usage_key)
|
||||
remove_all_children = True
|
||||
for seq in section.sequences:
|
||||
if remove_all_children or self._is_user_excluded_by_partition_group(
|
||||
seq.user_partition_groups
|
||||
):
|
||||
removed_usage_keys.add(seq.usage_key)
|
||||
return removed_usage_keys
|
||||
@@ -19,6 +19,8 @@ import ddt
|
||||
import pytest
|
||||
|
||||
from openedx.core.djangoapps.course_apps.toggles import EXAMS_IDA
|
||||
from openedx.core.djangoapps.course_groups.models import CourseCohortsSettings, CourseUserGroupPartitionGroup
|
||||
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
|
||||
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
|
||||
from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG
|
||||
from common.djangoapps.course_modes.models import CourseMode
|
||||
@@ -1717,6 +1719,190 @@ class EnrollmentTrackPartitionGroupsTestCase(OutlineProcessorTestCase): # lint-
|
||||
assert len(removed_usage_keys) == expected_values_dict[learner_to_verify.username]
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class CohortPartitionGroupsTestCase(OutlineProcessorTestCase):
|
||||
"""Tests for cohort partitions outline processor that affects outlines"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
super().setUpTestData()
|
||||
cls.visibility = VisibilityData(
|
||||
hide_from_toc=False,
|
||||
visible_to_staff_only=False
|
||||
)
|
||||
|
||||
def _create_and_enroll_learner(self, username, is_staff=False):
|
||||
"""
|
||||
Helper function to create the learner based on the username,
|
||||
then enroll the learner into the test course with VERIFIED
|
||||
mode.
|
||||
Returns the created learner
|
||||
"""
|
||||
learner = UserFactory.create(
|
||||
username=username, email='{}@example.com'.format(username), is_staff=is_staff
|
||||
)
|
||||
learner.courseenrollment_set.create(course_id=self.course_key, is_active=True, mode=CourseMode.VERIFIED)
|
||||
return learner
|
||||
|
||||
def _setup_course_outline_with_sections(
|
||||
self,
|
||||
course_sections,
|
||||
course_start_date=datetime(2021, 3, 26, tzinfo=timezone.utc)
|
||||
):
|
||||
"""
|
||||
Helper function to update the course outline under test with
|
||||
the course sections passed in.
|
||||
Returns the newly constructed course outline
|
||||
"""
|
||||
set_dates_for_course(
|
||||
self.course_key,
|
||||
[
|
||||
(
|
||||
self.course_key.make_usage_key('course', 'course'),
|
||||
{'start': course_start_date}
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
new_outline = CourseOutlineData(
|
||||
course_key=self.course_key,
|
||||
title="Cohort User Partition Test Course",
|
||||
published_at=course_start_date,
|
||||
published_version="8ebece4b69dd593d82fe2023",
|
||||
sections=course_sections,
|
||||
self_paced=False,
|
||||
days_early_for_beta=None,
|
||||
entrance_exam_id=None,
|
||||
course_visibility=CourseVisibility.PRIVATE,
|
||||
)
|
||||
|
||||
replace_course_outline(new_outline)
|
||||
|
||||
return new_outline
|
||||
|
||||
@ddt.data(
|
||||
(
|
||||
None,
|
||||
None,
|
||||
['student1', 'student2'],
|
||||
{'student1': 1, 'student2': 1}
|
||||
),
|
||||
(
|
||||
set([1001]),
|
||||
None,
|
||||
['student1', 'student2'],
|
||||
{'student1': 1, 'student2': 0}
|
||||
),
|
||||
(
|
||||
set([1002]),
|
||||
None,
|
||||
['student1', 'student2'],
|
||||
{'student1': 0, 'student2': 1}
|
||||
),
|
||||
(
|
||||
set([1001, 1002]),
|
||||
None,
|
||||
['student1', 'student2'],
|
||||
{'student1': 1, 'student2': 1}
|
||||
),
|
||||
(
|
||||
None,
|
||||
set([1001]),
|
||||
['student1', 'student2'],
|
||||
{'student1': 1, 'student2': 0}
|
||||
),
|
||||
(
|
||||
None,
|
||||
set([1002]),
|
||||
['student1', 'student2'],
|
||||
{'student1': 0, 'student2': 1}
|
||||
),
|
||||
(
|
||||
None,
|
||||
set([1001, 1002]),
|
||||
['student1', 'student2'],
|
||||
{'student1': 1, 'student2': 1}
|
||||
),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_cohort_partition_on_outline(
|
||||
self,
|
||||
section_visible_groups,
|
||||
sequence_visible_groups,
|
||||
learners,
|
||||
expected_values_dict
|
||||
):
|
||||
|
||||
section_user_partition_groups = None
|
||||
sequence_user_partition_groups = None
|
||||
if section_visible_groups:
|
||||
section_user_partition_groups = {
|
||||
1000: frozenset(section_visible_groups)
|
||||
}
|
||||
if sequence_visible_groups:
|
||||
sequence_user_partition_groups = {
|
||||
1000: frozenset(sequence_visible_groups)
|
||||
}
|
||||
|
||||
CourseCohortsSettings.objects.create(course_id=self.course_key, is_cohorted=True)
|
||||
|
||||
# Enroll students in the course
|
||||
learners_to_verify = []
|
||||
for username in learners:
|
||||
learners_to_verify.append(
|
||||
self._create_and_enroll_learner(username)
|
||||
)
|
||||
|
||||
# Create cohorts and corresponding GroupPartitions
|
||||
cohort_1 = CohortFactory(
|
||||
course_id=self.course_key,
|
||||
name='Test Cohort 1',
|
||||
users=[learners_to_verify[0]]
|
||||
)
|
||||
|
||||
CourseUserGroupPartitionGroup(
|
||||
course_user_group=cohort_1,
|
||||
partition_id=1000,
|
||||
group_id=1001
|
||||
).save()
|
||||
|
||||
cohort_2 = CohortFactory(
|
||||
course_id=self.course_key,
|
||||
name='Test Cohort 2',
|
||||
users=[learners_to_verify[1]]
|
||||
)
|
||||
|
||||
CourseUserGroupPartitionGroup(
|
||||
course_user_group=cohort_2,
|
||||
partition_id=1000,
|
||||
group_id=1002
|
||||
).save()
|
||||
|
||||
self._setup_course_outline_with_sections(
|
||||
[
|
||||
CourseSectionData(
|
||||
usage_key=self.course_key.make_usage_key('chapter', '0'),
|
||||
title="Section 0",
|
||||
user_partition_groups=section_user_partition_groups,
|
||||
sequences=[
|
||||
CourseLearningSequenceData(
|
||||
usage_key=self.course_key.make_usage_key('subsection', '0'),
|
||||
title='Subsection 0',
|
||||
visibility=self.visibility,
|
||||
user_partition_groups=sequence_user_partition_groups,
|
||||
),
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
check_date = datetime(2021, 3, 27, tzinfo=timezone.utc)
|
||||
|
||||
for learner_to_verify in learners_to_verify:
|
||||
learner_details = get_user_course_outline_details(self.course_key, learner_to_verify, check_date)
|
||||
assert len(learner_details.outline.accessible_sequences) == expected_values_dict[learner_to_verify.username]
|
||||
|
||||
|
||||
class ContentErrorTestCase(CacheIsolationTestCase):
|
||||
"""Test error collection and reporting."""
|
||||
|
||||
|
||||
@@ -250,10 +250,16 @@ class CourseOutlineData:
|
||||
"""
|
||||
keys_to_remove = set(usage_keys)
|
||||
|
||||
# If we remove a Section, we also remove all Sequences in that Section.
|
||||
for section in self.sections:
|
||||
section_sequences_keys = {seq.usage_key for seq in section.sequences}
|
||||
|
||||
# If we remove a Section, we also remove all Sequences in that Section.
|
||||
if section.usage_key in keys_to_remove:
|
||||
keys_to_remove |= {seq.usage_key for seq in section.sequences}
|
||||
keys_to_remove |= section_sequences_keys
|
||||
|
||||
# If a Section is empty or about to be, we remove it.
|
||||
elif section_sequences_keys.issubset(keys_to_remove):
|
||||
keys_to_remove.add(section.usage_key)
|
||||
|
||||
return attr.evolve(
|
||||
self,
|
||||
|
||||
@@ -619,3 +619,17 @@ def _get_cohort_settings_from_modulestore(course):
|
||||
'cohorted_discussions': list(course.cohorted_discussions),
|
||||
'always_cohort_inline_discussions': course.always_cohort_inline_discussions
|
||||
}
|
||||
|
||||
|
||||
def get_cohorted_user_partition_id(course_key):
|
||||
"""
|
||||
Returns the partition id to which cohorts are linked or None if there is no cohort linked
|
||||
to a content group.
|
||||
"""
|
||||
course_user_group_partition_group = CourseUserGroupPartitionGroup.objects.filter(
|
||||
course_user_group__course_id=course_key
|
||||
).first()
|
||||
|
||||
if course_user_group_partition_group:
|
||||
return course_user_group_partition_group.partition_id
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user