Skip to content
Snippets Groups Projects
Commit 3c5caaf7 authored by Gabe Mulley's avatar Gabe Mulley
Browse files

REVE-103: Fix masquerade for feature based enrollments

parent e95be9c4
No related merge requests found
......@@ -22,6 +22,7 @@ show_preview_menu = course and staff_access and supports_preview_menu
course_partitions = get_all_partitions_for_course(course)
masquerade_user_name = masquerade.user_name if masquerade else None
masquerade_group_id = masquerade.group_id if masquerade else None
masquerade_user_partition_id = masquerade.user_partition_id if masquerade else None
staff_selected = selected(not masquerade or masquerade.role != "student")
specific_student_selected = selected(not staff_selected and masquerade.user_name)
student_selected = selected(not staff_selected and not specific_student_selected and not masquerade_group_id)
......@@ -39,7 +40,7 @@ show_preview_menu = course and staff_access and supports_preview_menu
% if course_partitions:
% for course_partition in course_partitions:
% for group in sorted(course_partition.groups, key=lambda group: group.name):
<option value="group.id" data-group-id="${group.id}" data-partition-id="${course_partition.id}" ${selected(masquerade_group_id == group.id)}>
<option value="group.id" data-group-id="${group.id}" data-partition-id="${course_partition.id}" ${selected(masquerade_user_partition_id == course_partition.id and masquerade_group_id == group.id)}>
${_("Learner in {content_group}").format(content_group=group.name)}
</option>
% endfor
......
......@@ -10,6 +10,7 @@ from django.db import models
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
from django.utils import timezone
from lms.djangoapps.courseware.masquerade import get_course_masquerade, is_masquerading_as_specific_student
from experiments.models import ExperimentData
from student.models import CourseEnrollment
......@@ -92,15 +93,18 @@ class ContentTypeGatingConfig(StackedConfigurationModel):
# TODO: clean up as part of REV-100
experiment_data_holdback_key = EXPERIMENT_DATA_HOLDBACK_KEY.format(user)
is_in_holdback = False
try:
holdback_value = ExperimentData.objects.get(
user=user,
experiment_id=EXPERIMENT_ID,
key=experiment_data_holdback_key,
).value
is_in_holdback = holdback_value == 'True'
except ExperimentData.DoesNotExist:
pass
no_masquerade = get_course_masquerade(user, course_key) is None
student_masquerade = is_masquerading_as_specific_student(user, course_key)
if user and (no_masquerade or student_masquerade):
try:
holdback_value = ExperimentData.objects.get(
user=user,
experiment_id=EXPERIMENT_ID,
key=experiment_data_holdback_key,
).value
is_in_holdback = holdback_value == 'True'
except ExperimentData.DoesNotExist:
pass
if is_in_holdback:
return False
current_config = cls.current(course_key=enrollment.course_id)
......
......@@ -11,6 +11,7 @@ from course_modes.models import CourseMode
import crum
from django.apps import apps
from django.conf import settings
from django.template.loader import render_to_string
from django.utils.translation import ugettext_lazy as _
......@@ -21,7 +22,7 @@ from lms.djangoapps.courseware.masquerade import (
is_masquerading_as_specific_student,
get_masquerading_user_group,
)
from xmodule.partitions.partitions import Group, UserPartition, UserPartitionError
from xmodule.partitions.partitions import Group, UserPartition, UserPartitionError, ENROLLMENT_TRACK_PARTITION_ID
from openedx.core.lib.mobile_utils import is_request_from_mobile_app
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
from student.roles import CourseBetaTesterRole
......@@ -140,8 +141,18 @@ class ContentTypeGatingPartitionScheme(object):
# same logic as normal to return that student's group. If the current
# user is masquerading as a generic student in a specific group, then
# return that group.
if get_course_masquerade(user, course_key) and not is_masquerading_as_specific_student(user, course_key):
return get_masquerading_user_group(course_key, user, user_partition)
course_masquerade = get_course_masquerade(user, course_key)
if course_masquerade and not is_masquerading_as_specific_student(user, course_key):
masquerade_group = get_masquerading_user_group(course_key, user, user_partition)
if masquerade_group is not None:
return masquerade_group
else:
audit_mode_id = settings.COURSE_ENROLLMENT_MODES.get(CourseMode.AUDIT, {}).get('id')
if course_masquerade.user_partition_id == ENROLLMENT_TRACK_PARTITION_ID:
if course_masquerade.group_id != audit_mode_id:
return cls.FULL_ACCESS
else:
return cls.LIMITED_ACCESS
# For now, treat everyone as a Full-access user, until we have the rest of the
# feature gating logic in place.
......
"""
Test audit user's access to various content based on content-gating features.
"""
from datetime import datetime
import json
from datetime import datetime, timedelta
import ddt
from django.conf import settings
from django.test.client import RequestFactory
from django.test.utils import override_settings
from django.urls import reverse
from django.utils import timezone
from mock import patch
from course_modes.tests.factories import CourseModeFactory
from experiments.models import ExperimentKeyValue
from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID
from lms.djangoapps.courseware.module_render import load_single_xblock
from lms.djangoapps.courseware.tests.factories import (
InstructorFactory,
......@@ -23,16 +26,14 @@ from lms.djangoapps.courseware.tests.factories import (
)
from openedx.core.djangoapps.user_api.tests.factories import UserCourseTagFactory
from openedx.core.djangoapps.util.testing import TestConditionalContent
from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
from openedx.core.lib.url_utils import quote_slashes
from openedx.features.content_type_gating.partitions import CONTENT_GATING_PARTITION_ID
from openedx.features.content_type_gating.partitions import CONTENT_GATING_PARTITION_ID, CONTENT_TYPE_GATE_GROUP_IDS
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
from openedx.features.course_duration_limits.config import (
EXPERIMENT_DATA_HOLDBACK_KEY,
EXPERIMENT_ID,
)
from student.models import CourseEnrollment
from student.roles import CourseBetaTesterRole, CourseInstructorRole, CourseStaffRole
from student.roles import CourseInstructorRole
from student.tests.factories import (
CourseEnrollmentFactory,
UserFactory,
......@@ -201,7 +202,7 @@ class TestProblemTypeAccess(SharedModuleStoreTestCase):
# enroll all users into the all track types course
self.users = {}
for mode_type in self.MODE_TYPES:
self.users[mode_type] = UserFactory.create()
self.users[mode_type] = UserFactory.create(username=mode_type)
CourseEnrollmentFactory.create(
user=self.users[mode_type],
course_id=self.courses['all_track_types']['course'].id,
......@@ -245,7 +246,8 @@ class TestProblemTypeAccess(SharedModuleStoreTestCase):
....
}
"""
course = CourseFactory.create(run=run, display_name=display_name)
start_date = timezone.now() - timedelta(weeks=1)
course = CourseFactory.create(run=run, display_name=display_name, start=start_date)
for mode in modes:
CourseModeFactory.create(course_id=course.id, mode_slug=mode)
......@@ -465,6 +467,64 @@ class TestProblemTypeAccess(SharedModuleStoreTestCase):
request_factory=self.factory,
)
@ddt.data(
({'user_partition_id': CONTENT_GATING_PARTITION_ID,
'group_id': CONTENT_TYPE_GATE_GROUP_IDS['limited_access']}, True),
({'user_partition_id': CONTENT_GATING_PARTITION_ID,
'group_id': CONTENT_TYPE_GATE_GROUP_IDS['full_access']}, False),
({'user_partition_id': ENROLLMENT_TRACK_PARTITION_ID,
'group_id': settings.COURSE_ENROLLMENT_MODES['audit']['id']}, True),
({'user_partition_id': ENROLLMENT_TRACK_PARTITION_ID,
'group_id': settings.COURSE_ENROLLMENT_MODES['verified']['id']}, False),
({'role': 'staff'}, False),
({'role': 'student'}, True),
({'username': 'audit'}, True),
({'username': 'verified'}, False),
)
@ddt.unpack
def test_masquerade(self, masquerade_config, is_gated):
instructor = UserFactory.create()
CourseEnrollmentFactory.create(
user=instructor,
course_id=self.course.id,
mode='audit'
)
CourseInstructorRole(self.course.id).add_users(instructor)
self.client.login(username=instructor.username, password=TEST_PASSWORD)
self.update_masquerade(**masquerade_config)
block = self.blocks_dict['problem']
block_view_url = reverse('render_xblock', kwargs={'usage_key_string': unicode(block.scope_ids.usage_id)})
response = self.client.get(block_view_url)
if is_gated:
self.assertEquals(response.status_code, 404)
else:
self.assertEquals(response.status_code, 200)
def update_masquerade(self, role='student', group_id=None, username=None, user_partition_id=None):
"""
Toggle masquerade state.
"""
masquerade_url = reverse(
'masquerade_update',
kwargs={
'course_key_string': unicode(self.course.id),
}
)
response = self.client.post(
masquerade_url,
json.dumps({
'role': role,
'group_id': group_id,
'user_name': username,
'user_partition_id': user_partition_id,
}),
'application/json'
)
self.assertEqual(response.status_code, 200)
return response
@override_settings(FIELD_OVERRIDE_PROVIDERS=(
'openedx.features.content_type_gating.field_override.ContentTypeGatingFieldOverride',
......
......@@ -70,9 +70,12 @@ class TestContentTypeGatingConfig(CacheIsolationTestCase):
user = self.user
course_key = self.course_overview.id
query_count = 5
if not pass_enrollment and already_enrolled:
if already_enrolled and pass_enrollment:
query_count = 4
elif not pass_enrollment and already_enrolled:
query_count = 6
else:
query_count = 5
with self.assertNumQueries(query_count):
enabled = ContentTypeGatingConfig.enabled_for_enrollment(
......
......@@ -9,11 +9,14 @@ from django.apps import apps
from django.utils import timezone
from django.utils.translation import ugettext as _
from course_modes.models import CourseMode
from util.date_utils import DEFAULT_SHORT_DATE_FORMAT, strftime_localized
from course_modes.models import CourseMode
from lms.djangoapps.courseware.access_response import AccessError
from lms.djangoapps.courseware.access_utils import ACCESS_GRANTED
from lms.djangoapps.courseware.date_summary import verified_upgrade_deadline_link
from lms.djangoapps.courseware.masquerade import get_course_masquerade, is_masquerading_as_student
from openedx.core.djangoapps.catalog.utils import get_course_run_details
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
......@@ -93,6 +96,10 @@ def check_course_expired(user, course):
"""
Check if the course expired for the user.
"""
# masquerading course staff should always have access
if get_course_masquerade(user, course.id):
return ACCESS_GRANTED
if not CourseDurationLimitConfig.enabled_for_enrollment(user=user, course_key=course.id):
return ACCESS_GRANTED
......@@ -107,15 +114,29 @@ def register_course_expired_message(request, course):
"""
Add a banner notifying the user of the user course expiration date if it exists.
"""
if CourseDurationLimitConfig.enabled_for_enrollment(user=request.user, course_key=course.id):
expiration_date = get_user_course_expiration_date(request.user, course)
if expiration_date:
upgrade_message = _('Your access to this course expires on {expiration_date}. \
<a href="{upgrade_link}">Upgrade now</a> for unlimited access.')
PageLevelMessages.register_info_message(
request,
HTML(upgrade_message).format(
expiration_date=expiration_date.strftime('%b %-d'),
upgrade_link=verified_upgrade_deadline_link(user=request.user, course=course)
)
if not CourseDurationLimitConfig.enabled_for_enrollment(user=request.user, course_key=course.id):
return
expiration_date = get_user_course_expiration_date(request.user, course)
if not expiration_date:
return
if is_masquerading_as_student(request.user, course.id) and timezone.now() > expiration_date:
upgrade_message = _('This learner would not have access to this course. '
'Their access expired on {expiration_date}.')
PageLevelMessages.register_warning_message(
request,
HTML(upgrade_message).format(
expiration_date=expiration_date.strftime('%b %-d')
)
)
else:
upgrade_message = _('Your access to this course expires on {expiration_date}. \
<a href="{upgrade_link}">Upgrade now</a> for unlimited access.')
PageLevelMessages.register_info_message(
request,
HTML(upgrade_message).format(
expiration_date=expiration_date.strftime('%b %-d'),
upgrade_link=verified_upgrade_deadline_link(user=request.user, course=course)
)
)
......@@ -5,14 +5,21 @@ Course Duration Limit Configuration Models
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
from django.utils import timezone
from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID
from course_modes.models import CourseMode
from lms.djangoapps.courseware.masquerade import get_masquerade_role, get_course_masquerade, \
is_masquerading_as_specific_student
from experiments.models import ExperimentData
from openedx.core.djangoapps.config_model_utils.models import StackedConfigurationModel
from openedx.features.content_type_gating.partitions import CONTENT_GATING_PARTITION_ID, CONTENT_TYPE_GATE_GROUP_IDS
from openedx.features.course_duration_limits.config import (
CONTENT_TYPE_GATING_FLAG,
EXPERIMENT_ID,
......@@ -79,13 +86,25 @@ class CourseDurationLimitConfig(StackedConfigurationModel):
# if the user is has a role of staff, instructor or beta tester their access should not expire
if user is None and enrollment is not None:
user = enrollment.user
if user:
staff_role = CourseStaffRole(course_key).has_user(user)
instructor_role = CourseInstructorRole(course_key).has_user(user)
beta_tester_role = CourseBetaTesterRole(course_key).has_user(user)
if staff_role or instructor_role or beta_tester_role:
return False
if user:
course_masquerade = get_course_masquerade(user, course_key)
if course_masquerade:
verified_mode_id = settings.COURSE_ENROLLMENT_MODES.get(CourseMode.VERIFIED, {}).get('id')
is_verified = (course_masquerade.user_partition_id == ENROLLMENT_TRACK_PARTITION_ID
and course_masquerade.group_id == verified_mode_id)
is_full_access = (course_masquerade.user_partition_id == CONTENT_GATING_PARTITION_ID
and course_masquerade.group_id == CONTENT_TYPE_GATE_GROUP_IDS['full_access'])
is_staff = get_masquerade_role(user, course_key) == 'staff'
if is_verified or is_full_access or is_staff:
return False
else:
staff_role = CourseStaffRole(course_key).has_user(user)
instructor_role = CourseInstructorRole(course_key).has_user(user)
beta_tester_role = CourseBetaTesterRole(course_key).has_user(user)
if staff_role or instructor_role or beta_tester_role:
return False
# enrollment might be None if the user isn't enrolled. In that case,
# return enablement as if the user enrolled today
......@@ -95,15 +114,18 @@ class CourseDurationLimitConfig(StackedConfigurationModel):
# TODO: clean up as part of REV-100
experiment_data_holdback_key = EXPERIMENT_DATA_HOLDBACK_KEY.format(user)
is_in_holdback = False
try:
holdback_value = ExperimentData.objects.get(
user=user,
experiment_id=EXPERIMENT_ID,
key=experiment_data_holdback_key,
).value
is_in_holdback = holdback_value == 'True'
except ExperimentData.DoesNotExist:
pass
no_masquerade = get_course_masquerade(user, course_key) is None
student_masquerade = is_masquerading_as_specific_student(user, course_key)
if user and (no_masquerade or student_masquerade):
try:
holdback_value = ExperimentData.objects.get(
user=user,
experiment_id=EXPERIMENT_ID,
key=experiment_data_holdback_key,
).value
is_in_holdback = holdback_value == 'True'
except ExperimentData.DoesNotExist:
pass
if is_in_holdback:
return False
current_config = cls.current(course_key=enrollment.course_id)
......
"""
Contains tests to verify correctness of course expiration functionality
"""
import json
from datetime import timedelta
from django.conf import settings
from django.urls import reverse
from django.utils.timezone import now
import ddt
import mock
from course_modes.models import CourseMode
from experiments.models import ExperimentData
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.features.content_type_gating.partitions import CONTENT_GATING_PARTITION_ID, CONTENT_TYPE_GATE_GROUP_IDS
from openedx.features.course_duration_limits.access import get_user_course_expiration_date, MIN_DURATION, MAX_DURATION
from openedx.features.course_duration_limits.config import EXPERIMENT_ID, EXPERIMENT_DATA_HOLDBACK_KEY
from openedx.features.course_experience.tests.views.helpers import add_course_mode
from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from student.roles import CourseInstructorRole
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
......@@ -101,3 +111,158 @@ class CourseExpirationTestCase(ModuleStoreTestCase):
result = get_user_course_expiration_date(self.user, future_course)
content_availability_date = start_date
self.assertEqual(result, content_availability_date + access_duration)
@mock.patch("openedx.features.course_duration_limits.access.get_course_run_details")
@ddt.data(
({'user_partition_id': CONTENT_GATING_PARTITION_ID,
'group_id': CONTENT_TYPE_GATE_GROUP_IDS['limited_access']}, True),
({'user_partition_id': CONTENT_GATING_PARTITION_ID,
'group_id': CONTENT_TYPE_GATE_GROUP_IDS['full_access']}, False),
({'user_partition_id': ENROLLMENT_TRACK_PARTITION_ID,
'group_id': settings.COURSE_ENROLLMENT_MODES['audit']['id']}, True),
({'user_partition_id': ENROLLMENT_TRACK_PARTITION_ID,
'group_id': settings.COURSE_ENROLLMENT_MODES['verified']['id']}, False),
({'role': 'staff'}, False),
({'role': 'student'}, True),
({'username': 'audit'}, True),
({'username': 'verified'}, False),
)
@ddt.unpack
def test_masquerade(self, masquerade_config, show_expiration_banner, mock_get_course_run_details):
mock_get_course_run_details.return_value = {'weeks_to_complete': 12}
audit_student = UserFactory(username='audit')
CourseEnrollmentFactory.create(
user=audit_student,
course_id=self.course.id,
mode='audit'
)
verified_student = UserFactory(username='verified')
CourseEnrollmentFactory.create(
user=verified_student,
course_id=self.course.id,
mode='verified'
)
CourseDurationLimitConfig.objects.create(
enabled=True,
course=CourseOverview.get_from_id(self.course.id),
enabled_as_of=self.course.start,
)
instructor = UserFactory.create(username='instructor')
CourseEnrollmentFactory.create(
user=instructor,
course_id=self.course.id,
mode='audit'
)
CourseInstructorRole(self.course.id).add_users(instructor)
self.client.login(username=instructor.username, password='test')
self.update_masquerade(**masquerade_config)
course_home_url = reverse('openedx.course_experience.course_home', args=[unicode(self.course.id)])
response = self.client.get(course_home_url, follow=True)
self.assertEqual(response.status_code, 200)
self.assertItemsEqual(response.redirect_chain, [])
banner_text = 'Your access to this course expires on'
if show_expiration_banner:
self.assertIn(banner_text, response.content)
else:
self.assertNotIn(banner_text, response.content)
def update_masquerade(self, role='student', group_id=None, username=None, user_partition_id=None):
"""
Toggle masquerade state.
"""
masquerade_url = reverse(
'masquerade_update',
kwargs={
'course_key_string': unicode(self.course.id),
}
)
response = self.client.post(
masquerade_url,
json.dumps({
'role': role,
'group_id': group_id,
'user_name': username,
'user_partition_id': user_partition_id,
}),
'application/json'
)
self.assertEqual(response.status_code, 200)
return response
@mock.patch("openedx.features.course_duration_limits.access.get_course_run_details")
def test_masquerade_in_holdback(self, mock_get_course_run_details):
mock_get_course_run_details.return_value = {'weeks_to_complete': 12}
audit_student = UserFactory(username='audit')
CourseEnrollmentFactory.create(
user=audit_student,
course_id=self.course.id,
mode='audit'
)
ExperimentData.objects.create(
user=audit_student,
experiment_id=EXPERIMENT_ID,
key=EXPERIMENT_DATA_HOLDBACK_KEY.format(audit_student),
value='True'
)
CourseDurationLimitConfig.objects.create(
enabled=True,
course=CourseOverview.get_from_id(self.course.id),
enabled_as_of=self.course.start,
)
instructor = UserFactory.create(username='instructor')
CourseEnrollmentFactory.create(
user=instructor,
course_id=self.course.id,
mode='audit'
)
CourseInstructorRole(self.course.id).add_users(instructor)
self.client.login(username=instructor.username, password='test')
self.update_masquerade(username='audit')
course_home_url = reverse('openedx.course_experience.course_home', args=[unicode(self.course.id)])
response = self.client.get(course_home_url, follow=True)
self.assertEqual(response.status_code, 200)
self.assertItemsEqual(response.redirect_chain, [])
banner_text = 'Your access to this course expires on'
self.assertNotIn(banner_text, response.content)
@mock.patch("openedx.features.course_duration_limits.access.get_course_run_details")
def test_masquerade_expired(self, mock_get_course_run_details):
mock_get_course_run_details.return_value = {'weeks_to_complete': 1}
audit_student = UserFactory(username='audit')
enrollment = CourseEnrollmentFactory.create(
user=audit_student,
course_id=self.course.id,
mode='audit',
)
enrollment.created = self.course.start
enrollment.save()
CourseDurationLimitConfig.objects.create(
enabled=True,
course=CourseOverview.get_from_id(self.course.id),
enabled_as_of=self.course.start,
)
instructor = UserFactory.create(username='instructor')
CourseEnrollmentFactory.create(
user=instructor,
course_id=self.course.id,
mode='audit'
)
CourseInstructorRole(self.course.id).add_users(instructor)
self.client.login(username=instructor.username, password='test')
self.update_masquerade(username='audit')
course_home_url = reverse('openedx.course_experience.course_home', args=[unicode(self.course.id)])
response = self.client.get(course_home_url, follow=True)
self.assertEqual(response.status_code, 200)
self.assertItemsEqual(response.redirect_chain, [])
banner_text = 'This learner would not have access to this course. Their access expired on'
self.assertIn(banner_text, response.content)
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment