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:
Calen Pennington
2018-10-15 13:20:38 -04:00
committed by Bessie Steinberg
parent 2ff3a38d91
commit 83d676cbfa
13 changed files with 242 additions and 38 deletions

View File

@@ -114,6 +114,7 @@ from lms.envs.common import (
FILE_UPLOAD_STORAGE_PREFIX,
COURSE_ENROLLMENT_MODES,
CONTENT_TYPE_GATE_GROUP_IDS,
HELP_TOKENS_BOOKS,

View File

@@ -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.

View File

@@ -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,

View File

@@ -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)]

View File

@@ -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

View File

@@ -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)

View File

@@ -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):
"""

View File

@@ -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

View File

@@ -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>

View 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)

View 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

View File

@@ -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

View File

@@ -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"