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:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
136
openedx/core/djangoapps/credit/partition_schemes.py
Normal file
136
openedx/core/djangoapps/credit/partition_schemes.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
181
openedx/core/djangoapps/credit/tests/test_partition.py
Normal file
181
openedx/core/djangoapps/credit/tests/test_partition.py
Normal 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)
|
||||
@@ -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):
|
||||
|
||||
270
openedx/core/djangoapps/credit/tests/test_verification_access.py
Normal file
270
openedx/core/djangoapps/credit/tests/test_verification_access.py
Normal 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)
|
||||
38
openedx/core/djangoapps/credit/utils.py
Normal file
38
openedx/core/djangoapps/credit/utils.py
Normal 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
|
||||
187
openedx/core/djangoapps/credit/verification_access.py
Normal file
187
openedx/core/djangoapps/credit/verification_access.py
Normal 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
|
||||
Reference in New Issue
Block a user