Add Content Type Gating Behind Waffle Flag
Content Type Gating: Xblocks that have a graded component cannot be
accessed by audit track users.
- Caveats:
- In studio, instructors can set certain xblocks to be available to
all users, but graded components will default to not being
available for audit users
- If a course does not have a verified mode option, all users will
have access to graded content.
The Waffle Flag: The waffle flag is of for now.
It's name is: ```content_type_gating.debug```
This Commit Does NOT Include: Displaying for a user WHY they do not have
access to a specific piece of content. That change will be part of
another PR.
This commit is contained in:
committed by
Bessie Steinberg
parent
2ff3a38d91
commit
83d676cbfa
@@ -114,6 +114,7 @@ from lms.envs.common import (
|
||||
FILE_UPLOAD_STORAGE_PREFIX,
|
||||
|
||||
COURSE_ENROLLMENT_MODES,
|
||||
CONTENT_TYPE_GATE_GROUP_IDS,
|
||||
|
||||
HELP_TOKENS_BOOKS,
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ class UserPartition(namedtuple("UserPartition", "id name description groups sche
|
||||
"""
|
||||
return None
|
||||
|
||||
def access_denied_fragment(self, block, user, course_key, user_group, allowed_groups):
|
||||
def access_denied_fragment(self, block, user, user_group, allowed_groups):
|
||||
"""
|
||||
Return an html fragment that should be displayed to the user when they are not allowed to access
|
||||
content managed by this partition, or None if there is no applicable message.
|
||||
|
||||
@@ -8,10 +8,7 @@ from django.utils.translation import ugettext_lazy as _
|
||||
import logging
|
||||
|
||||
from openedx.core.lib.cache_utils import request_cached
|
||||
from openedx.features.content_type_gating.partitions import (
|
||||
CONTENT_GATING_PARTITION_ID,
|
||||
create_content_gating_partition,
|
||||
)
|
||||
from openedx.features.content_type_gating.partitions import create_content_gating_partition
|
||||
from xmodule.partitions.partitions import (
|
||||
UserPartition,
|
||||
UserPartitionError,
|
||||
|
||||
@@ -6,6 +6,8 @@ from django.conf import settings
|
||||
|
||||
from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager
|
||||
from openedx.core.djangoapps.content.block_structure.transformers import BlockStructureTransformers
|
||||
from openedx.features.content_type_gating.block_transformers import ContentTypeGateTransformer
|
||||
from openedx.features.course_duration_limits.config import CONTENT_TYPE_GATING_FLAG
|
||||
|
||||
from .transformers import (
|
||||
library_content,
|
||||
@@ -39,12 +41,23 @@ def get_course_block_access_transformers(user):
|
||||
which the block structure is to be transformed.
|
||||
|
||||
"""
|
||||
course_block_access_transformers = [
|
||||
library_content.ContentLibraryTransformer(),
|
||||
start_date.StartDateTransformer(),
|
||||
user_partitions.UserPartitionTransformer(),
|
||||
visibility.VisibilityTransformer(),
|
||||
]
|
||||
if CONTENT_TYPE_GATING_FLAG.is_enabled():
|
||||
# [REV/Revisit] remove this duplicated code when flag is removed
|
||||
course_block_access_transformers = [
|
||||
library_content.ContentLibraryTransformer(),
|
||||
start_date.StartDateTransformer(),
|
||||
ContentTypeGateTransformer(),
|
||||
user_partitions.UserPartitionTransformer(),
|
||||
visibility.VisibilityTransformer(),
|
||||
]
|
||||
else:
|
||||
course_block_access_transformers = [
|
||||
library_content.ContentLibraryTransformer(),
|
||||
start_date.StartDateTransformer(),
|
||||
user_partitions.UserPartitionTransformer(),
|
||||
visibility.VisibilityTransformer(),
|
||||
]
|
||||
|
||||
if has_individual_student_override_provider():
|
||||
course_block_access_transformers += [load_override_data.OverrideDataTransformer(user)]
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ from courseware.access_utils import (
|
||||
in_preview_mode,
|
||||
check_course_open_for_learner,
|
||||
)
|
||||
from courseware.access_response import (
|
||||
NoAllowedPartitionGroupsError,
|
||||
IncorrectPartitionGroupError,
|
||||
)
|
||||
from courseware.masquerade import get_masquerade_role, is_masquerading_as_student
|
||||
from lms.djangoapps.ccx.custom_exception import CCXLocatorValidationException
|
||||
from lms.djangoapps.ccx.models import CustomCourseForEdX
|
||||
@@ -450,11 +454,6 @@ def _has_group_access(descriptor, user, course_key):
|
||||
# use merged_group_access which takes group access on the block's
|
||||
# parents / ancestors into account
|
||||
merged_access = descriptor.merged_group_access
|
||||
# check for False in merged_access, which indicates that at least one
|
||||
# partition's group list excludes all students.
|
||||
if False in merged_access.values():
|
||||
log.warning("Group access check excludes all students, access will be denied.", exc_info=True)
|
||||
return ACCESS_DENIED
|
||||
|
||||
# resolve the partition IDs in group_access to actual
|
||||
# partition objects, skipping those which contain empty group directives.
|
||||
@@ -465,6 +464,13 @@ def _has_group_access(descriptor, user, course_key):
|
||||
for partition_id, group_ids in merged_access.items():
|
||||
try:
|
||||
partition = descriptor._get_user_partition(partition_id) # pylint: disable=protected-access
|
||||
|
||||
# check for False in merged_access, which indicates that at least one
|
||||
# partition's group list excludes all students.
|
||||
if group_ids is False:
|
||||
log.warning("Group access check excludes all students, access will be denied.", exc_info=True)
|
||||
return NoAllowedPartitionGroupsError(partition)
|
||||
|
||||
if partition.active:
|
||||
if group_ids is not None:
|
||||
partitions.append(partition)
|
||||
@@ -491,19 +497,34 @@ def _has_group_access(descriptor, user, course_key):
|
||||
log.warning("Error looking up referenced user partition group, access will be denied.", exc_info=True)
|
||||
return ACCESS_DENIED
|
||||
|
||||
# look up the user's group for each partition
|
||||
user_groups = {}
|
||||
# finally: check that the user has a satisfactory group assignment
|
||||
# for each partition.
|
||||
|
||||
# missing_groups is the list of groups that the user is NOT in but would NEED to be in order to be granted access.
|
||||
# For each partition there are group(s) of users that are granted access to this content.
|
||||
# Below, we loop through each partition and check if the user belongs to one of the appropriate group(s). If they do
|
||||
# not that group is added to their list of missing_groups.
|
||||
# If missing_groups is empty, the user is granted access.
|
||||
# If missing_groups is NOT empty, we generate an error based on one of the particular groups they are missing.
|
||||
missing_groups = []
|
||||
for partition, groups in partition_groups:
|
||||
user_groups[partition.id] = partition.scheme.get_group_for_user(
|
||||
user_group = partition.scheme.get_group_for_user(
|
||||
course_key,
|
||||
user,
|
||||
partition,
|
||||
)
|
||||
if user_group not in groups:
|
||||
missing_groups.append((partition, user_group, groups))
|
||||
|
||||
# finally: check that the user has a satisfactory group assignment
|
||||
# for each partition.
|
||||
if not all(user_groups.get(partition.id) in groups for partition, groups in partition_groups):
|
||||
return ACCESS_DENIED
|
||||
if missing_groups:
|
||||
partition, user_group, allowed_groups = missing_groups[0]
|
||||
return IncorrectPartitionGroupError(
|
||||
partition=partition,
|
||||
user_group=user_group,
|
||||
allowed_groups=allowed_groups,
|
||||
user_message=partition.access_denied_message(descriptor, user, user_group, allowed_groups),
|
||||
user_fragment=partition.access_denied_fragment(descriptor, user, user_group, allowed_groups),
|
||||
)
|
||||
|
||||
# all checks passed.
|
||||
return ACCESS_GRANTED
|
||||
@@ -532,12 +553,14 @@ def _has_access_descriptor(user, action, descriptor, course_key=None):
|
||||
# access to this content, then deny access. The problem with calling _has_staff_access_to_descriptor
|
||||
# before this method is that _has_staff_access_to_descriptor short-circuits and returns True
|
||||
# for staff users in preview mode.
|
||||
if not _has_group_access(descriptor, user, course_key):
|
||||
return ACCESS_DENIED
|
||||
group_access_response = _has_group_access(descriptor, user, course_key)
|
||||
if not group_access_response:
|
||||
return group_access_response
|
||||
|
||||
# If the user has staff access, they can load the module and checks below are not needed.
|
||||
if _has_staff_access_to_descriptor(user, descriptor, course_key):
|
||||
return ACCESS_GRANTED
|
||||
staff_access_response = _has_staff_access_to_descriptor(user, descriptor, course_key)
|
||||
if staff_access_response:
|
||||
return staff_access_response
|
||||
|
||||
return (
|
||||
_visible_to_nonstaff_users(descriptor) and
|
||||
|
||||
@@ -165,3 +165,32 @@ class MobileAvailabilityError(AccessError):
|
||||
developer_message = "Course is not available on mobile for this user"
|
||||
user_message = _("You do not have access to this course on a mobile device")
|
||||
super(MobileAvailabilityError, self).__init__(error_code, developer_message, user_message)
|
||||
|
||||
|
||||
class IncorrectPartitionGroupError(AccessError):
|
||||
"""
|
||||
Access denied because the user is not in the correct user subset.
|
||||
"""
|
||||
def __init__(self, partition, user_group, allowed_groups, user_message=None, user_fragment=None):
|
||||
error_code = "incorrect_user_group"
|
||||
developer_message = "In partition {}, user was in group {}, but only {} are allowed access".format(
|
||||
partition.name,
|
||||
user_group.name if user_group is not None else user_group,
|
||||
", ".join(group.name for group in allowed_groups),
|
||||
)
|
||||
super(IncorrectPartitionGroupError, self).__init__(
|
||||
error_code=error_code,
|
||||
developer_message=developer_message,
|
||||
user_message=user_message,
|
||||
user_fragment=user_fragment
|
||||
)
|
||||
|
||||
|
||||
class NoAllowedPartitionGroupsError(AccessError):
|
||||
"""
|
||||
Access denied because the content is not allowed to any group in a partition.
|
||||
"""
|
||||
def __init__(self, partition, user_message=None, user_fragment=None):
|
||||
error_code = "no_allowed_user_groups"
|
||||
developer_message = "Group access for {} excludes all students".format(partition.name)
|
||||
super(NoAllowedPartitionGroupsError, self).__init__(error_code, developer_message, user_message)
|
||||
|
||||
@@ -38,7 +38,7 @@ from capa.tests.response_xml_factory import OptionResponseXMLFactory
|
||||
from course_modes.models import CourseMode
|
||||
from courseware import module_render as render
|
||||
from courseware.courses import get_course_info_section, get_course_with_access
|
||||
from lms.djangoapps.courseware.field_overrides import OverrideFieldData
|
||||
from courseware.access_response import AccessResponse
|
||||
from courseware.masquerade import CourseMasquerade
|
||||
from courseware.model_data import FieldDataCache
|
||||
from courseware.models import StudentModule
|
||||
@@ -47,6 +47,7 @@ from courseware.tests.factories import GlobalStaffFactory, StudentModuleFactory,
|
||||
from courseware.tests.test_submitting_problems import TestSubmittingProblems
|
||||
from courseware.tests.tests import LoginEnrollmentTestCase
|
||||
from lms.djangoapps.lms_xblock.field_data import LmsFieldData
|
||||
from lms.djangoapps.courseware.field_overrides import OverrideFieldData
|
||||
from openedx.core.djangoapps.credit.api import set_credit_requirement_status, set_credit_requirements
|
||||
from openedx.core.djangoapps.credit.models import CreditCourse
|
||||
from openedx.core.lib.courses import course_image_url
|
||||
@@ -2362,10 +2363,9 @@ class TestFilteredChildren(SharedModuleStoreTestCase):
|
||||
key = obj.scope_ids.usage_id
|
||||
elif isinstance(obj, UsageKey):
|
||||
key = obj
|
||||
|
||||
if key == self.parent.scope_ids.usage_id:
|
||||
return True
|
||||
return key in self.children_for_user[user]
|
||||
return AccessResponse(True)
|
||||
return AccessResponse(key in self.children_for_user[user])
|
||||
|
||||
def assertBoundChildren(self, block, user):
|
||||
"""
|
||||
|
||||
@@ -3081,7 +3081,7 @@ FIELD_OVERRIDE_PROVIDERS = ()
|
||||
|
||||
# Modulestore-level field override providers. These field override providers don't
|
||||
# require student context.
|
||||
MODULESTORE_FIELD_OVERRIDE_PROVIDERS = ()
|
||||
MODULESTORE_FIELD_OVERRIDE_PROVIDERS = ('openedx.features.content_type_gating.field_override.ContentTypeGatingFieldOverride',) # pylint: disable=line-too-long
|
||||
|
||||
# PROFILE IMAGE CONFIG
|
||||
# WARNING: Certain django storage backends do not support atomic
|
||||
@@ -3417,6 +3417,11 @@ COURSE_ENROLLMENT_MODES = {
|
||||
},
|
||||
}
|
||||
|
||||
CONTENT_TYPE_GATE_GROUP_IDS = {
|
||||
'limited_access': 1,
|
||||
'full_access': 2,
|
||||
}
|
||||
|
||||
############## Settings for the Discovery App ######################
|
||||
|
||||
COURSES_API_CACHE_TIMEOUT = 3600 # Value is in seconds
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
|
||||
<div class="vert-mod">
|
||||
% for idx, item in enumerate(items):
|
||||
<div class="vert vert-${idx}" data-id="${item['id']}">
|
||||
${HTML(item['content'])}
|
||||
</div>
|
||||
% if item['content']:
|
||||
<div class="vert vert-${idx}" data-id="${item['id']}">
|
||||
${HTML(item['content'])}
|
||||
</div>
|
||||
%endif
|
||||
% endfor
|
||||
</div>
|
||||
|
||||
49
openedx/features/content_type_gating/block_transformers.py
Normal file
49
openedx/features/content_type_gating/block_transformers.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Content Type Gate Transformer implementation.
|
||||
Limits access for certain users to certain types of content.
|
||||
"""
|
||||
from django.conf import settings
|
||||
|
||||
from openedx.core.djangoapps.content.block_structure.transformer import (
|
||||
BlockStructureTransformer,
|
||||
)
|
||||
from openedx.features.content_type_gating.partitions import CONTENT_GATING_PARTITION_ID
|
||||
|
||||
|
||||
class ContentTypeGateTransformer(BlockStructureTransformer):
|
||||
"""
|
||||
A transformer that adds a partition condition for all graded content
|
||||
so that the content is only visible to verified users.
|
||||
"""
|
||||
WRITE_VERSION = 1
|
||||
READ_VERSION = 1
|
||||
|
||||
@classmethod
|
||||
def name(cls):
|
||||
"""
|
||||
Unique identifier for the transformer's class;
|
||||
same identifier used in setup.py.
|
||||
"""
|
||||
return "content_type_gate"
|
||||
|
||||
@classmethod
|
||||
def collect(cls, block_structure):
|
||||
"""
|
||||
Collects any information that's necessary to execute this
|
||||
transformer's transform method.
|
||||
"""
|
||||
block_structure.request_xblock_fields('group_access', 'graded', 'has_score')
|
||||
|
||||
def transform(self, usage_info, block_structure):
|
||||
for block_key in block_structure.topological_traversal():
|
||||
graded = block_structure.get_xblock_field(block_key, 'graded')
|
||||
has_score = block_structure.get_xblock_field(block_key, 'has_score')
|
||||
if graded and has_score:
|
||||
current_access = block_structure.get_xblock_field(block_key, 'group_access')
|
||||
if current_access is None:
|
||||
current_access = {}
|
||||
current_access.setdefault(
|
||||
CONTENT_GATING_PARTITION_ID,
|
||||
[settings.CONTENT_TYPE_GATE_GROUP_IDS['full_access']]
|
||||
)
|
||||
block_structure.override_xblock_field(block_key, 'group_access', current_access)
|
||||
40
openedx/features/content_type_gating/field_override.py
Normal file
40
openedx/features/content_type_gating/field_override.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
FieldOverride that forces graded components to be only accessible to
|
||||
students in the Unlocked Group of the ContentTypeGating partition.
|
||||
"""
|
||||
from django.conf import settings
|
||||
|
||||
from lms.djangoapps.courseware.field_overrides import FieldOverrideProvider, disable_overrides
|
||||
from openedx.features.content_type_gating.partitions import CONTENT_GATING_PARTITION_ID
|
||||
|
||||
|
||||
class ContentTypeGatingFieldOverride(FieldOverrideProvider):
|
||||
"""
|
||||
A concrete implementation of
|
||||
:class:`~courseware.field_overrides.FieldOverrideProvider` which forces
|
||||
graded content to only be accessible to the Full Access group
|
||||
"""
|
||||
def get(self, block, name, default):
|
||||
if name != 'group_access':
|
||||
return default
|
||||
|
||||
if not (getattr(block, 'graded', False) and block.has_score):
|
||||
return default
|
||||
|
||||
# Read the group_access from the fallback field-data service
|
||||
with disable_overrides():
|
||||
original_group_access = block.group_access
|
||||
|
||||
if original_group_access is None:
|
||||
original_group_access = {}
|
||||
original_group_access.setdefault(
|
||||
CONTENT_GATING_PARTITION_ID,
|
||||
[settings.CONTENT_TYPE_GATE_GROUP_IDS['full_access']]
|
||||
)
|
||||
|
||||
return original_group_access
|
||||
|
||||
@classmethod
|
||||
def enabled_for(cls, course):
|
||||
"""This simple override provider is always enabled"""
|
||||
return True
|
||||
@@ -7,8 +7,11 @@ of audit learners.
|
||||
|
||||
import logging
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from django.apps import apps
|
||||
from lms.djangoapps.courseware.masquerade import (
|
||||
get_course_masquerade,
|
||||
is_masquerading_as_specific_student,
|
||||
@@ -20,13 +23,13 @@ from openedx.features.course_duration_limits.config import (
|
||||
CONTENT_TYPE_GATING_STUDIO_UI_FLAG,
|
||||
)
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
# Studio generates partition IDs starting at 100. There is already a manually generated
|
||||
# partition for Enrollment Track that uses ID 50, so we'll use 51.
|
||||
CONTENT_GATING_PARTITION_ID = 51
|
||||
|
||||
|
||||
CONTENT_TYPE_GATE_GROUP_IDS = {
|
||||
'limited_access': 1,
|
||||
'full_access': 2,
|
||||
@@ -102,7 +105,48 @@ class ContentTypeGatingPartitionScheme(object):
|
||||
|
||||
# For now, treat everyone as a Full-access user, until we have the rest of the
|
||||
# feature gating logic in place.
|
||||
return cls.FULL_ACCESS
|
||||
|
||||
if not CONTENT_TYPE_GATING_FLAG.is_enabled():
|
||||
return cls.FULL_ACCESS
|
||||
|
||||
# If CONTENT_TYPE_GATING is enabled use the following logic to determine whether a user should have FULL_ACCESS
|
||||
# or LIMITED_ACCESS
|
||||
|
||||
course_mode = apps.get_model('course_modes.CourseMode')
|
||||
modes = course_mode.modes_for_course(course_key, include_expired=True, only_selectable=False)
|
||||
modes_dict = {mode.slug: mode for mode in modes}
|
||||
|
||||
# If there is no verified mode, all users are granted FULL_ACCESS
|
||||
if not course_mode.has_verified_mode(modes_dict):
|
||||
return cls.FULL_ACCESS
|
||||
|
||||
course_enrollment = apps.get_model('student.CourseEnrollment')
|
||||
|
||||
mode_slug, is_active = course_enrollment.enrollment_mode_for_user(user, course_key)
|
||||
|
||||
if mode_slug and is_active:
|
||||
course_mode = course_mode.mode_for_course(
|
||||
course_key,
|
||||
mode_slug,
|
||||
modes=modes,
|
||||
)
|
||||
if course_mode is None:
|
||||
LOG.error(
|
||||
"User %s is in an unknown CourseMode '%s'"
|
||||
" for course %s. Granting full access to content for this user",
|
||||
user.username,
|
||||
mode_slug,
|
||||
course_key,
|
||||
)
|
||||
return cls.FULL_ACCESS
|
||||
|
||||
if mode_slug == CourseMode.AUDIT:
|
||||
return cls.LIMITED_ACCESS
|
||||
else:
|
||||
return cls.FULL_ACCESS
|
||||
else:
|
||||
# Unenrolled users don't get gated content
|
||||
return cls.LIMITED_ACCESS
|
||||
|
||||
@classmethod
|
||||
def create_user_partition(cls, id, name, description, groups=None, parameters=None, active=True): # pylint: disable=redefined-builtin, invalid-name, unused-argument
|
||||
|
||||
3
setup.py
3
setup.py
@@ -60,7 +60,8 @@ setup(
|
||||
"milestones = lms.djangoapps.course_api.blocks.transformers.milestones:MilestonesAndSpecialExamsTransformer",
|
||||
"grades = lms.djangoapps.grades.transformer:GradesTransformer",
|
||||
"completion = lms.djangoapps.course_api.blocks.transformers.block_completion:BlockCompletionTransformer",
|
||||
"load_override_data = lms.djangoapps.course_blocks.transformers.load_override_data:OverrideDataTransformer"
|
||||
"load_override_data = lms.djangoapps.course_blocks.transformers.load_override_data:OverrideDataTransformer",
|
||||
"content_type_gate = openedx.features.content_type_gating.block_transformers:ContentTypeGateTransformer",
|
||||
],
|
||||
"openedx.ace.policy": [
|
||||
"bulk_email_optout = lms.djangoapps.bulk_email.policies:CourseEmailOptout"
|
||||
|
||||
Reference in New Issue
Block a user