In-course reverification access control

* Automatically create user partitions on course publish for each ICRV checkpoint.
* Disable partitions for ICRV checkpoints that have been deleted.
* Skip partitions that have been disabled when checking access.
* Add verification access control UI to visibility settings.
* Add verification access control UI to sequential and vertical settings.
* Add partition scheme for verification partition groups.
* Cache information used by verification partition scheme and invalidate the cache on update.
* Add location parameter to UserPartition so the partition scheme can find the associated checkpoint.
* Refactor GroupConfiguration to allow multiple user partitions.
* Add special messaging to ICRV for students in the honor track.

Authors: Zubair Arbi, Awais Qureshi, Aamir Khan, Will Daly
This commit is contained in:
zubair-arbi
2015-07-31 19:23:46 +05:00
committed by Will Daly
parent 63a49d6d1b
commit ec28a75f14
43 changed files with 2174 additions and 240 deletions

View File

@@ -3,7 +3,6 @@ Provides a UserPartition driver for cohorts.
"""
import logging
from courseware import courses
from courseware.masquerade import ( # pylint: disable=import-error
get_course_masquerade,
get_masquerading_group_info,
@@ -100,13 +99,12 @@ class CohortPartitionScheme(object):
return None
def get_cohorted_user_partition(course_key):
def get_cohorted_user_partition(course):
"""
Returns the first user partition from the specified course which uses the CohortPartitionScheme,
or None if one is not found. Note that it is currently recommended that each course have only
one cohorted user partition.
"""
course = courses.get_course_by_id(course_key)
for user_partition in course.user_partitions:
if user_partition.scheme == CohortPartitionScheme:
return user_partition

View File

@@ -317,14 +317,14 @@ class TestGetCohortedUserPartition(ModuleStoreTestCase):
self.course.user_partitions.append(self.random_user_partition)
self.course.user_partitions.append(self.cohort_user_partition)
self.course.user_partitions.append(self.second_cohort_user_partition)
self.assertEqual(self.cohort_user_partition, get_cohorted_user_partition(self.course_key))
self.assertEqual(self.cohort_user_partition, get_cohorted_user_partition(self.course))
def test_no_cohort_user_partitions(self):
"""
Test get_cohorted_user_partition returns None when there are no cohorted user partitions.
"""
self.course.user_partitions.append(self.random_user_partition)
self.assertIsNone(get_cohorted_user_partition(self.course_key))
self.assertIsNone(get_cohorted_user_partition(self.course))
class TestMasqueradedGroup(StaffMasqueradeTestCase):

View File

@@ -0,0 +1,136 @@
"""
Partition scheme for in-course reverification.
This is responsible for placing users into one of two groups,
ALLOW or DENY, for a partition associated with a particular
in-course reverification checkpoint.
NOTE: This really should be defined in the verify_student app,
which owns the verification and reverification process.
It isn't defined there now because (a) we need access to this in both Studio
and the LMS, but verify_student is specific to the LMS, and
(b) in-course reverification checkpoints currently have messaging that's
specific to credit requirements.
"""
import logging
from django.core.cache import cache
from lms.djangoapps.verify_student.models import SkippedReverification, VerificationStatus
from student.models import CourseEnrollment
from xmodule.partitions.partitions import NoSuchUserPartitionGroupError
log = logging.getLogger(__name__)
class VerificationPartitionScheme(object):
"""
Assign users to groups for a particular verification checkpoint.
Users in the ALLOW group can see gated content;
users in the DENY group cannot.
"""
DENY = 0
ALLOW = 1
@classmethod
def get_group_for_user(cls, course_key, user, user_partition, **kwargs): # pylint: disable=unused-argument
"""
Return the user's group depending their enrollment and verification
status.
Args:
course_key (CourseKey): CourseKey
user (User): user object
user_partition (UserPartition): The user partition object.
Returns:
string of allowed access group
"""
checkpoint = user_partition.parameters['location']
# Retrieve all information we need to determine the user's group
# as a multi-get from the cache.
is_verified, has_skipped, has_completed = _get_user_statuses(user, course_key, checkpoint)
# Decide whether the user should have access to content gated by this checkpoint.
# Intuitively, we allow access if the user doesn't need to do anything at the checkpoint,
# either because the user is in a non-verified track or the user has already submitted.
#
# Note that we do NOT wait the user's reverification attempt to be approved,
# since this can take some time and the user might miss an assignment deadline.
partition_group = cls.DENY
if not is_verified or has_skipped or has_completed:
partition_group = cls.ALLOW
# Return matching user partition group if it exists
try:
return user_partition.get_group(partition_group)
except NoSuchUserPartitionGroupError:
log.error(
(
u"Could not find group with ID %s for verified partition "
"with ID %s in course %s. The user will not be assigned a group."
),
partition_group,
user_partition.id,
course_key
)
return None
def _get_user_statuses(user, course_key, checkpoint):
"""
Retrieve all the information we need to determine the user's group.
This will retrieve the information as a multi-get from the cache.
Args:
user (User): User object
course_key (CourseKey): Identifier for the course.
checkpoint (unicode): Location of the checkpoint in the course (serialized usage key)
Returns:
tuple of booleans of the form (is_verified, has_skipped, has_completed)
"""
enrollment_cache_key = CourseEnrollment.cache_key_name(user.id, unicode(course_key))
has_skipped_cache_key = SkippedReverification.cache_key_name(user.id, unicode(course_key))
verification_status_cache_key = VerificationStatus.cache_key_name(user.id, unicode(course_key))
# Try a multi-get from the cache
cache_values = cache.get_many([
enrollment_cache_key,
has_skipped_cache_key,
verification_status_cache_key
])
# Retrieve whether the user is enrolled in a verified mode.
is_verified = cache_values.get(enrollment_cache_key)
if is_verified is None:
is_verified = CourseEnrollment.is_enrolled_as_verified(user, course_key)
cache.set(enrollment_cache_key, is_verified)
# Retrieve whether the user has skipped any checkpoints in this course
has_skipped = cache_values.get(has_skipped_cache_key)
if has_skipped is None:
has_skipped = SkippedReverification.check_user_skipped_reverification_exists(user, course_key)
cache.set(has_skipped_cache_key, has_skipped)
# Retrieve the user's verification status for each checkpoint in the course.
verification_statuses = cache_values.get(verification_status_cache_key)
if verification_statuses is None:
verification_statuses = VerificationStatus.get_all_checkpoints(user.id, course_key)
cache.set(verification_status_cache_key, verification_statuses)
# Check whether the user has completed this checkpoint
# "Completion" here means *any* submission, regardless of its status
# since we want to show the user the content if they've submitted
# photos.
checkpoint = verification_statuses.get(checkpoint)
has_completed_check = bool(checkpoint)
return (is_verified, has_skipped, has_completed_check)

View File

@@ -8,14 +8,15 @@ from django.dispatch import receiver
from django.utils import timezone
from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.django import SignalHandler
from openedx.core.djangoapps.signals.signals import GRADES_UPDATED
from openedx.core.djangoapps.credit.verification_access import update_verification_partitions
from xmodule.modulestore.django import SignalHandler
log = logging.getLogger(__name__)
def on_course_publish(course_key): # pylint: disable=unused-argument
def on_course_publish(course_key):
"""
Will receive a delegated 'course_published' signal from cms/djangoapps/contentstore/signals.py
and kick off a celery task to update the credit course requirements.
@@ -33,6 +34,25 @@ def on_course_publish(course_key): # pylint: disable=unused-argument
log.info(u'Added task to update credit requirements for course "%s" to the task queue', course_key)
@receiver(SignalHandler.pre_publish)
def on_pre_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument
"""
Create user partitions for verification checkpoints.
This is a pre-publish step since we need to write to the course descriptor.
"""
from openedx.core.djangoapps.credit import api
if api.is_credit_course(course_key):
# For now, we are tagging content with in-course-reverification access groups
# only in credit courses on publish. In the long run, this is not where we want to put this.
# This really should be a transformation on the course structure performed as a pre-processing
# step by the LMS, and the transformation should be owned by the verify_student app.
# Since none of that infrastructure currently exists, we're doing it this way instead.
log.info(u"Starting to update in-course reverification access rules")
update_verification_partitions(course_key)
log.info(u"Finished updating in-course reverification access rules")
@receiver(GRADES_UPDATED)
def listen_for_grade_calculation(sender, username, grade_summary, course_key, deadline, **kwargs): # pylint: disable=unused-argument
"""Receive 'MIN_GRADE_REQUIREMENT_STATUS' signal and update minimum grade

View File

@@ -15,8 +15,9 @@ from opaque_keys.edx.keys import CourseKey
from .api import set_credit_requirements
from openedx.core.djangoapps.credit.exceptions import InvalidCreditRequirements
from openedx.core.djangoapps.credit.models import CreditCourse
from xmodule.modulestore.django import modulestore
from openedx.core.djangoapps.credit.utils import get_course_blocks
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
LOGGER = get_task_logger(__name__)
@@ -139,34 +140,13 @@ def _get_credit_course_requirement_xblocks(course_key): # pylint: disable=inval
return requirements
def _is_in_course_tree(block):
"""
Check that the XBlock is in the course tree.
It's possible that the XBlock is not in the course tree
if its parent has been deleted and is now an orphan.
"""
ancestor = block.get_parent()
while ancestor is not None and ancestor.location.category != "course":
ancestor = ancestor.get_parent()
return ancestor is not None
def _get_xblocks(course_key, category):
"""
Retrieve all XBlocks in the course for a particular category.
Returns only XBlocks that are published and haven't been deleted.
"""
xblocks = [
block for block in modulestore().get_items(
course_key,
qualifiers={"category": category},
revision=ModuleStoreEnum.RevisionOption.published_only,
)
if _is_in_course_tree(block)
]
xblocks = get_course_blocks(course_key, category)
# Secondary sort on credit requirement name
xblocks = sorted(xblocks, key=lambda block: block.get_credit_requirement_display_name())

View File

@@ -0,0 +1,181 @@
# -*- coding: utf-8 -*-
"""
Tests for In-Course Reverification Access Control Partition scheme
"""
import ddt
import unittest
from django.conf import settings
from lms.djangoapps.verify_student.models import (
VerificationCheckpoint,
VerificationStatus,
SkippedReverification,
)
from openedx.core.djangoapps.credit.partition_schemes import VerificationPartitionScheme
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from xmodule.partitions.partitions import UserPartition, Group
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class ReverificationPartitionTest(ModuleStoreTestCase):
"""Tests for the Reverification Partition Scheme. """
SUBMITTED = "submitted"
APPROVED = "approved"
DENIED = "denied"
def setUp(self):
super(ReverificationPartitionTest, self).setUp()
# creating course, checkpoint location and user partition mock object.
self.course = CourseFactory.create()
self.checkpoint_location = u'i4x://{org}/{course}/edx-reverification-block/first_uuid'.format(
org=self.course.id.org, course=self.course.id.course
)
scheme = UserPartition.get_scheme("verification")
self.user_partition = UserPartition(
id=0,
name=u"Verification Checkpoint",
description=u"Verification Checkpoint",
scheme=scheme,
parameters={"location": self.checkpoint_location},
groups=[
Group(scheme.ALLOW, "Allow access to content"),
Group(scheme.DENY, "Deny access to content"),
]
)
self.first_checkpoint = VerificationCheckpoint.objects.create(
course_id=self.course.id,
checkpoint_location=self.checkpoint_location
)
def create_user_and_enroll(self, enrollment_type):
"""Create and enroll users with provided enrollment type."""
user = UserFactory.create()
CourseEnrollment.objects.create(
user=user,
course_id=self.course.id,
mode=enrollment_type,
is_active=True
)
return user
def add_verification_status(self, user, status):
"""Adding the verification status for a user."""
VerificationStatus.add_status_from_checkpoints(
checkpoints=[self.first_checkpoint],
user=user,
status=status
)
@ddt.data(
("verified", SUBMITTED, VerificationPartitionScheme.ALLOW),
("verified", APPROVED, VerificationPartitionScheme.ALLOW),
("verified", DENIED, VerificationPartitionScheme.ALLOW),
("verified", None, VerificationPartitionScheme.DENY),
("honor", None, VerificationPartitionScheme.ALLOW),
)
@ddt.unpack
def test_get_group_for_user(self, enrollment_type, verification_status, expected_group):
# creating user and enroll them.
user = self.create_user_and_enroll(enrollment_type)
if verification_status:
self.add_verification_status(user, verification_status)
self._assert_group_assignment(user, expected_group)
def test_get_group_for_user_with_skipped(self):
# Check that a user is in verified allow group if that user has skipped
# any ICRV block.
user = self.create_user_and_enroll('verified')
SkippedReverification.add_skipped_reverification_attempt(
checkpoint=self.first_checkpoint,
user_id=user.id,
course_id=self.course.id
)
self._assert_group_assignment(user, VerificationPartitionScheme.ALLOW)
def test_cache_with_skipped_icrv(self):
# Check that a user is in verified allow group if that user has skipped
# any ICRV block.
user = self.create_user_and_enroll('verified')
SkippedReverification.add_skipped_reverification_attempt(
checkpoint=self.first_checkpoint,
user_id=user.id,
course_id=self.course.id
)
# this will warm the cache.
with self.assertNumQueries(3):
self._assert_group_assignment(user, VerificationPartitionScheme.ALLOW)
# no db queries this time.
with self.assertNumQueries(0):
self._assert_group_assignment(user, VerificationPartitionScheme.ALLOW)
def test_cache_with_submitted_status(self):
# Check that a user is in verified allow group if that user has approved status at
# any ICRV block.
user = self.create_user_and_enroll('verified')
self.add_verification_status(user, VerificationStatus.APPROVED_STATUS)
# this will warm the cache.
with self.assertNumQueries(4):
self._assert_group_assignment(user, VerificationPartitionScheme.ALLOW)
# no db queries this time.
with self.assertNumQueries(0):
self._assert_group_assignment(user, VerificationPartitionScheme.ALLOW)
def test_cache_with_denied_status(self):
# Check that a user is in verified allow group if that user has denied at
# any ICRV block.
user = self.create_user_and_enroll('verified')
self.add_verification_status(user, VerificationStatus.DENIED_STATUS)
# this will warm the cache.
with self.assertNumQueries(4):
self._assert_group_assignment(user, VerificationPartitionScheme.ALLOW)
# no db queries this time.
with self.assertNumQueries(0):
self._assert_group_assignment(user, VerificationPartitionScheme.ALLOW)
def test_cache_with_honor(self):
# Check that a user is in honor mode.
# any ICRV block.
user = self.create_user_and_enroll('honor')
# this will warm the cache.
with self.assertNumQueries(3):
self._assert_group_assignment(user, VerificationPartitionScheme.ALLOW)
# no db queries this time.
with self.assertNumQueries(0):
self._assert_group_assignment(user, VerificationPartitionScheme.ALLOW)
def test_cache_with_verified_deny_group(self):
# Check that a user is in verified mode. But not perform any action
user = self.create_user_and_enroll('verified')
# this will warm the cache.
with self.assertNumQueries(3):
self._assert_group_assignment(user, VerificationPartitionScheme.DENY)
# no db queries this time.
with self.assertNumQueries(0):
self._assert_group_assignment(user, VerificationPartitionScheme.DENY)
def _assert_group_assignment(self, user, expected_group_id):
"""Check that the user was assigned to a group. """
actual_group = VerificationPartitionScheme.get_group_for_user(self.course.id, user, self.user_partition)
self.assertEqual(actual_group.id, expected_group_id)

View File

@@ -208,7 +208,7 @@ class TestTaskExecution(ModuleStoreTestCase):
self.add_credit_course(self.course.id)
self.add_icrv_xblock()
with check_mongo_calls_range(max_finds=7):
with check_mongo_calls_range(max_finds=11):
on_course_publish(self.course.id)
def test_remove_icrv_requirement(self):

View File

@@ -0,0 +1,270 @@
"""
Tests for in-course reverification user partition creation.
This should really belong to the verify_student app,
but we can't move it there because it's in the LMS and we're
currently applying these rules on publish from Studio.
In the future, this functionality should be a course transformation
defined in the verify_student app, and these tests should be moved
into verify_student.
"""
from mock import patch
from django.conf import settings
from openedx.core.djangoapps.credit.models import CreditCourse
from openedx.core.djangoapps.credit.partition_schemes import VerificationPartitionScheme
from openedx.core.djangoapps.credit.verification_access import update_verification_partitions
from openedx.core.djangoapps.credit.signals import on_pre_publish
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import SignalHandler
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, TEST_DATA_SPLIT_MODULESTORE
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls_range
from xmodule.partitions.partitions import Group, UserPartition
class CreateVerificationPartitionTest(ModuleStoreTestCase):
"""
Tests for applying verification access rules.
"""
# Run the tests in split modulestore
# While verification access will work in old-Mongo, it's not something
# we're committed to supporting, since this feature is meant for use
# in new courses.
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
@patch.dict(settings.FEATURES, {"ENABLE_COURSEWARE_INDEX": False})
def setUp(self):
super(CreateVerificationPartitionTest, self).setUp()
# Disconnect the signal receiver -- we'll invoke the update code ourselves
SignalHandler.pre_publish.disconnect(receiver=on_pre_publish)
self.addCleanup(SignalHandler.pre_publish.connect, receiver=on_pre_publish)
# Create a dummy course with a single verification checkpoint
# Because we need to check "exam" content surrounding the ICRV checkpoint,
# we need to create a fairly large course structure, with multiple sections,
# subsections, verticals, units, and items.
self.course = CourseFactory()
self.sections = [
ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section A'),
ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section B'),
]
self.subsections = [
ItemFactory.create(parent=self.sections[0], category='sequential', display_name='Test Subsection A 1'),
ItemFactory.create(parent=self.sections[0], category='sequential', display_name='Test Subsection A 2'),
ItemFactory.create(parent=self.sections[1], category='sequential', display_name='Test Subsection B 1'),
ItemFactory.create(parent=self.sections[1], category='sequential', display_name='Test Subsection B 2'),
]
self.verticals = [
ItemFactory.create(parent=self.subsections[0], category='vertical', display_name='Test Unit A 1 a'),
ItemFactory.create(parent=self.subsections[0], category='vertical', display_name='Test Unit A 1 b'),
ItemFactory.create(parent=self.subsections[1], category='vertical', display_name='Test Unit A 2 a'),
ItemFactory.create(parent=self.subsections[1], category='vertical', display_name='Test Unit A 2 b'),
ItemFactory.create(parent=self.subsections[2], category='vertical', display_name='Test Unit B 1 a'),
ItemFactory.create(parent=self.subsections[2], category='vertical', display_name='Test Unit B 1 b'),
ItemFactory.create(parent=self.subsections[3], category='vertical', display_name='Test Unit B 2 a'),
ItemFactory.create(parent=self.subsections[3], category='vertical', display_name='Test Unit B 2 b'),
]
self.icrv = ItemFactory.create(parent=self.verticals[0], category='edx-reverification-block')
self.sibling_problem = ItemFactory.create(parent=self.verticals[0], category='problem')
def test_creates_user_partitions(self):
self._update_partitions()
# Check that a new user partition was created for the ICRV block
self.assertEqual(len(self.course.user_partitions), 1)
partition = self.course.user_partitions[0]
self.assertEqual(partition.scheme.name, "verification")
self.assertEqual(partition.parameters["location"], unicode(self.icrv.location))
# Check that the groups for the partition were created correctly
self.assertEqual(len(partition.groups), 2)
self.assertItemsEqual(
[g.id for g in partition.groups],
[
VerificationPartitionScheme.ALLOW,
VerificationPartitionScheme.DENY,
]
)
@patch.dict(settings.FEATURES, {"ENABLE_COURSEWARE_INDEX": False})
def test_removes_deleted_user_partitions(self):
self._update_partitions()
# Delete the reverification block, then update the partitions
self.store.delete_item(
self.icrv.location,
ModuleStoreEnum.UserID.test,
revision=ModuleStoreEnum.RevisionOption.published_only
)
self._update_partitions()
# Check that the user partition was marked as inactive
self.assertEqual(len(self.course.user_partitions), 1)
partition = self.course.user_partitions[0]
self.assertFalse(partition.active)
self.assertEqual(partition.scheme.name, "verification")
@patch.dict(settings.FEATURES, {"ENABLE_COURSEWARE_INDEX": False})
def test_preserves_partition_id_for_verified_partitions(self):
self._update_partitions()
partition_id = self.course.user_partitions[0].id
self._update_partitions()
new_partition_id = self.course.user_partitions[0].id
self.assertEqual(partition_id, new_partition_id)
@patch.dict(settings.FEATURES, {"ENABLE_COURSEWARE_INDEX": False})
def test_preserves_existing_user_partitions(self):
# Add other, non-verified partition to the course
self.course.user_partitions = [
UserPartition(
id=0,
name='Cohort user partition',
scheme=UserPartition.get_scheme('cohort'),
description='Cohorted user partition',
groups=[
Group(id=0, name="Group A"),
Group(id=1, name="Group B"),
],
),
UserPartition(
id=1,
name='Random user partition',
scheme=UserPartition.get_scheme('random'),
description='Random user partition',
groups=[
Group(id=0, name="Group A"),
Group(id=1, name="Group B"),
],
),
]
self.course = self.store.update_item(self.course, ModuleStoreEnum.UserID.test)
# Update the verification partitions.
# The existing partitions should still be available
self._update_partitions()
partition_ids = [p.id for p in self.course.user_partitions]
self.assertEqual(len(partition_ids), 3)
self.assertIn(0, partition_ids)
self.assertIn(1, partition_ids)
def test_multiple_reverification_blocks(self):
# Add an additional ICRV block in another section
other_icrv = ItemFactory.create(parent=self.verticals[3], category='edx-reverification-block')
self._update_partitions()
# Expect that both ICRV blocks have corresponding partitions
self.assertEqual(len(self.course.user_partitions), 2)
partition_locations = [p.parameters.get("location") for p in self.course.user_partitions]
self.assertIn(unicode(self.icrv.location), partition_locations)
self.assertIn(unicode(other_icrv.location), partition_locations)
# Delete the first ICRV block and update partitions
icrv_location = self.icrv.location
self.store.delete_item(
self.icrv.location,
ModuleStoreEnum.UserID.test,
revision=ModuleStoreEnum.RevisionOption.published_only
)
self._update_partitions()
# Expect that the correct partition is marked as inactive
self.assertEqual(len(self.course.user_partitions), 2)
partitions_by_loc = {
p.parameters["location"]: p
for p in self.course.user_partitions
}
self.assertFalse(partitions_by_loc[unicode(icrv_location)].active)
self.assertTrue(partitions_by_loc[unicode(other_icrv.location)].active)
def test_query_counts_with_no_reverification_blocks(self):
# Delete the ICRV block, so the number of ICRV blocks is zero
self.store.delete_item(
self.icrv.location,
ModuleStoreEnum.UserID.test,
revision=ModuleStoreEnum.RevisionOption.published_only
)
# 2 calls: get the course (definitions + structures)
# 2 calls: look up ICRV blocks in the course (definitions + structures)
with check_mongo_calls_range(max_finds=4, max_sends=2):
self._update_partitions(reload_items=False)
def test_query_counts_with_one_reverification_block(self):
# One ICRV block created in the setup method
# Additional call to load the ICRV block
with check_mongo_calls_range(max_finds=5, max_sends=3):
self._update_partitions(reload_items=False)
def test_query_counts_with_multiple_reverification_blocks(self):
# Total of two ICRV blocks (one created in setup method)
# Additional call to load each ICRV block
ItemFactory.create(parent=self.verticals[3], category='edx-reverification-block')
with check_mongo_calls_range(max_finds=6, max_sends=3):
self._update_partitions(reload_items=False)
def _update_partitions(self, reload_items=True):
"""Update user partitions in the course descriptor, then reload the content. """
update_verification_partitions(self.course.id) # pylint: disable=no-member
# Reload each component so we can see the changes
if reload_items:
self.course = self.store.get_course(self.course.id) # pylint: disable=no-member
self.sections = [self._reload_item(section.location) for section in self.sections]
self.subsections = [self._reload_item(subsection.location) for subsection in self.subsections]
self.verticals = [self._reload_item(vertical.location) for vertical in self.verticals]
self.icrv = self._reload_item(self.icrv.location)
self.sibling_problem = self._reload_item(self.sibling_problem.location)
def _reload_item(self, location):
"""Safely reload an item from the moduelstore. """
try:
return self.store.get_item(location)
except ItemNotFoundError:
return None
class WriteOnPublishTest(ModuleStoreTestCase):
"""
Verify that updates to the course descriptor's
user partitions are written automatically on publish.
"""
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
@patch.dict(settings.FEATURES, {"ENABLE_COURSEWARE_INDEX": False})
def setUp(self):
super(WriteOnPublishTest, self).setUp()
# Create a dummy course with an ICRV block
self.course = CourseFactory()
self.section = ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section')
self.subsection = ItemFactory.create(parent=self.section, category='sequential', display_name='Test Subsection')
self.vertical = ItemFactory.create(parent=self.subsection, category='vertical', display_name='Test Unit')
self.icrv = ItemFactory.create(parent=self.vertical, category='edx-reverification-block')
# Mark the course as credit
CreditCourse.objects.create(course_key=self.course.id, enabled=True) # pylint: disable=no-member
@patch.dict(settings.FEATURES, {"ENABLE_COURSEWARE_INDEX": False})
def test_can_write_on_publish_signal(self):
# Sanity check -- initially user partitions should be empty
self.assertEqual(self.course.user_partitions, [])
# Make and publish a change to a block, which should trigger the publish signal
with self.store.bulk_operations(self.course.id): # pylint: disable=no-member
self.icrv.display_name = "Updated display name"
self.store.update_item(self.icrv, ModuleStoreEnum.UserID.test)
self.store.publish(self.icrv.location, ModuleStoreEnum.UserID.test)
# Within the test, the course pre-publish signal should have fired synchronously
# Since the course is marked as credit, the in-course verification partitions
# should have been created.
# We need to verify that these changes were actually persisted to the modulestore.
retrieved_course = self.store.get_course(self.course.id) # pylint: disable=no-member
self.assertEqual(len(retrieved_course.user_partitions), 1)

View File

@@ -0,0 +1,38 @@
"""
Utilities for the credit app.
"""
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
def get_course_blocks(course_key, category):
"""
Retrieve all XBlocks in the course for a particular category.
Returns only XBlocks that are published and haven't been deleted.
"""
# Note: we need to check if found components have been orphaned
# due to a bug in split modulestore (PLAT-799). Once that bug
# is resolved, we can skip the `_is_in_course_tree()` check entirely.
return [
block for block in modulestore().get_items(
course_key,
qualifiers={"category": category},
revision=ModuleStoreEnum.RevisionOption.published_only,
)
if _is_in_course_tree(block)
]
def _is_in_course_tree(block):
"""
Check that the XBlock is in the course tree.
It's possible that the XBlock is not in the course tree
if its parent has been deleted and is now an orphan.
"""
ancestor = block.get_parent()
while ancestor is not None and ancestor.location.category != "course":
ancestor = ancestor.get_parent()
return ancestor is not None

View File

@@ -0,0 +1,187 @@
"""
Create in-course reverification access groups in a course.
We model the rules as a set of user partitions, one for each
verification checkpoint in a course.
For example, suppose that a course has two verification checkpoints,
one at midterm A and one at the midterm B.
Then the user partitions would look like this:
Midterm A: |-- ALLOW --|-- DENY --|
Midterm B: |-- ALLOW --|-- DENY --|
where the groups are defined as:
* ALLOW: The user has access to content gated by the checkpoint.
* DENY: The user does not have access to content gated by the checkpoint.
"""
import logging
from util.db import generate_int_id
from openedx.core.djangoapps.credit.utils import get_course_blocks
from xmodule.modulestore.django import modulestore
from xmodule.modulestore import ModuleStoreEnum
from xmodule.partitions.partitions import Group, UserPartition
log = logging.getLogger(__name__)
VERIFICATION_SCHEME_NAME = "verification"
VERIFICATION_BLOCK_CATEGORY = "edx-reverification-block"
def update_verification_partitions(course_key):
"""
Create a user partition for each verification checkpoint in the course.
This will modify the published version of the course descriptor.
It ensures that any in-course reverification XBlocks in the course
have an associated user partition. Other user partitions (e.g. cohorts)
will be preserved. Partitions associated with deleted reverification checkpoints
will be marked as inactive and will not be used to restrict access.
Arguments:
course_key (CourseKey): identifier for the course.
Returns:
None
"""
# Batch all the queries we're about to do and suppress
# the "publish" signal to avoid an infinite call loop.
with modulestore().bulk_operations(course_key, emit_signals=False):
# Retrieve all in-course reverification blocks in the course
icrv_blocks = get_course_blocks(course_key, VERIFICATION_BLOCK_CATEGORY)
# Update the verification definitions in the course descriptor
# This will also clean out old verification partitions if checkpoints
# have been deleted.
_set_verification_partitions(course_key, icrv_blocks)
def _unique_partition_id(course):
"""Return a unique user partition ID for the course. """
# Exclude all previously used IDs, even for partitions that have been disabled
# (e.g. if the course author deleted an in-course reverifification block but
# there are courseware components that reference the disabled partition).
used_ids = set(p.id for p in course.user_partitions)
return generate_int_id(used_ids=used_ids)
def _other_partitions(verified_partitions, exclude_partitions, course_key):
"""
Retrieve all partitions NOT associated with the current set of ICRV blocks.
Any partition associated with a deleted ICRV block will be marked as inactive
so its access rules will no longer be enforced.
Arguments:
all_partitions (list of UserPartition): All verified partitions defined in the course.
exclude_partitions (list of UserPartition): Partitions to exclude (e.g. the ICRV partitions already added)
course_key (CourseKey): Identifier for the course (used for logging).
Returns: list of `UserPartition`s
"""
results = []
partition_by_id = {
p.id: p for p in verified_partitions
}
other_partition_ids = set(p.id for p in verified_partitions) - set(p.id for p in exclude_partitions)
for pid in other_partition_ids:
partition = partition_by_id[pid]
results.append(
UserPartition(
id=partition.id,
name=partition.name,
description=partition.description,
scheme=partition.scheme,
parameters=partition.parameters,
groups=partition.groups,
active=False,
)
)
log.info(
(
"Disabled partition %s in course %s because the "
"associated in-course-reverification checkpoint does not exist."
),
partition.id, course_key
)
return results
def _set_verification_partitions(course_key, icrv_blocks):
"""
Create or update user partitions in the course.
Ensures that each ICRV block in the course has an associated user partition
with the groups ALLOW and DENY.
Arguments:
course_key (CourseKey): Identifier for the course.
icrv_blocks (list of XBlock): In-course reverification blocks, e.g. reverification checkpoints.
Returns:
list of UserPartition
"""
scheme = UserPartition.get_scheme(VERIFICATION_SCHEME_NAME)
if scheme is None:
log.error("Could not retrieve user partition scheme with ID %s", VERIFICATION_SCHEME_NAME)
return []
course = modulestore().get_course(course_key)
if course is None:
log.error("Could not find course %s", course_key)
return []
verified_partitions = course.get_user_partitions_for_scheme(scheme)
partition_id_for_location = {
p.parameters["location"]: p.id
for p in verified_partitions
if "location" in p.parameters
}
partitions = []
for block in icrv_blocks:
partition = UserPartition(
id=partition_id_for_location.get(
unicode(block.location),
_unique_partition_id(course)
),
name=block.related_assessment,
description=u"Verification checkpoint at {}".format(block.related_assessment),
scheme=scheme,
parameters={"location": unicode(block.location)},
groups=[
Group(scheme.ALLOW, "Completed verification at {}".format(block.related_assessment)),
Group(scheme.DENY, "Did not complete verification at {}".format(block.related_assessment)),
]
)
partitions.append(partition)
log.info(
(
"Configured partition %s for course %s using a verified partition scheme "
"for the in-course-reverification checkpoint at location %s"
),
partition.id,
course_key,
partition.parameters["location"]
)
# Preserve existing, non-verified partitions from the course
# Mark partitions for deleted in-course reverification as disabled.
partitions += _other_partitions(verified_partitions, partitions, course_key)
course.set_user_partitions_for_scheme(partitions, scheme)
modulestore().update_item(course, ModuleStoreEnum.UserID.system)
log.info("Saved updated partitions for the course %s", course_key)
return partitions