447 lines
17 KiB
Python
447 lines
17 KiB
Python
"""
|
|
Helpers for courseware tests.
|
|
"""
|
|
|
|
|
|
import ast
|
|
import json
|
|
from collections import OrderedDict
|
|
from datetime import timedelta
|
|
|
|
from django.contrib import messages
|
|
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
|
|
from django.test import TestCase
|
|
from django.test.client import Client, RequestFactory
|
|
from django.urls import reverse
|
|
from django.utils.timezone import now
|
|
from xblock.field_data import DictFieldData
|
|
|
|
from common.djangoapps.edxmako.shortcuts import render_to_string
|
|
from lms.djangoapps.courseware.access import has_access
|
|
from lms.djangoapps.courseware.utils import verified_upgrade_deadline_link
|
|
from lms.djangoapps.courseware.masquerade import MasqueradeView
|
|
from lms.djangoapps.courseware.masquerade import setup_masquerade
|
|
from lms.djangoapps.lms_xblock.field_data import LmsFieldData
|
|
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
|
from openedx.core.lib.url_utils import quote_slashes
|
|
from common.djangoapps.student.models import CourseEnrollment, Registration
|
|
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
|
|
from common.djangoapps.util.date_utils import strftime_localized_html
|
|
from xmodule.modulestore.django import modulestore
|
|
from xmodule.modulestore.tests.django_utils import TEST_DATA_MONGO_MODULESTORE, ModuleStoreTestCase
|
|
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
|
from xmodule.tests import get_test_descriptor_system, get_test_system
|
|
|
|
|
|
class BaseTestXmodule(ModuleStoreTestCase):
|
|
"""Base class for testing Xmodules with mongo store.
|
|
|
|
This class prepares course and users for tests:
|
|
1. create test course;
|
|
2. create, enroll and login users for this course;
|
|
|
|
Any xmodule should overwrite only next parameters for test:
|
|
1. CATEGORY
|
|
2. DATA or METADATA
|
|
3. MODEL_DATA
|
|
4. COURSE_DATA and USER_COUNT if needed
|
|
|
|
This class should not contain any tests, because CATEGORY
|
|
should be defined in child class.
|
|
"""
|
|
MODULESTORE = TEST_DATA_MONGO_MODULESTORE
|
|
|
|
USER_COUNT = 2
|
|
COURSE_DATA = {}
|
|
|
|
# Data from YAML common/lib/xmodule/xmodule/templates/NAME/default.yaml
|
|
CATEGORY = "vertical"
|
|
DATA = ''
|
|
# METADATA must be overwritten for every instance that uses it. Otherwise,
|
|
# if we'll change it in the tests, it will be changed for all other instances
|
|
# of parent class.
|
|
METADATA = {}
|
|
MODEL_DATA = {'data': '<some_module></some_module>'}
|
|
|
|
def new_module_runtime(self):
|
|
"""
|
|
Generate a new ModuleSystem that is minimally set up for testing
|
|
"""
|
|
return get_test_system(course_id=self.course.id)
|
|
|
|
def new_descriptor_runtime(self):
|
|
runtime = get_test_descriptor_system()
|
|
runtime.get_block = modulestore().get_item
|
|
return runtime
|
|
|
|
def initialize_module(self, **kwargs): # lint-amnesty, pylint: disable=missing-function-docstring
|
|
kwargs.update({
|
|
'parent_location': self.section.location,
|
|
'category': self.CATEGORY
|
|
})
|
|
|
|
self.item_descriptor = ItemFactory.create(**kwargs)
|
|
|
|
self.runtime = self.new_descriptor_runtime()
|
|
|
|
field_data = {}
|
|
field_data.update(self.MODEL_DATA)
|
|
student_data = DictFieldData(field_data)
|
|
self.item_descriptor._field_data = LmsFieldData(self.item_descriptor._field_data, student_data) # lint-amnesty, pylint: disable=protected-access
|
|
|
|
self.item_descriptor.xmodule_runtime = self.new_module_runtime()
|
|
|
|
self.item_url = str(self.item_descriptor.location)
|
|
|
|
def setup_course(self): # lint-amnesty, pylint: disable=missing-function-docstring
|
|
self.course = CourseFactory.create(data=self.COURSE_DATA)
|
|
|
|
# Turn off cache.
|
|
modulestore().request_cache = None
|
|
modulestore().metadata_inheritance_cache_subsystem = None
|
|
|
|
chapter = ItemFactory.create(
|
|
parent_location=self.course.location,
|
|
category="sequential",
|
|
)
|
|
self.section = ItemFactory.create(
|
|
parent_location=chapter.location,
|
|
category="sequential"
|
|
)
|
|
|
|
# username = robot{0}, password = 'test'
|
|
self.users = [
|
|
UserFactory.create()
|
|
for dummy0 in range(self.USER_COUNT)
|
|
]
|
|
|
|
for user in self.users:
|
|
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
|
|
|
|
# login all users for acces to Xmodule
|
|
self.clients = {user.username: Client() for user in self.users}
|
|
self.login_statuses = [
|
|
self.clients[user.username].login(
|
|
username=user.username, password='test')
|
|
for user in self.users
|
|
]
|
|
|
|
assert all(self.login_statuses)
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.setup_course()
|
|
self.initialize_module(metadata=self.METADATA, data=self.DATA)
|
|
|
|
def get_url(self, dispatch):
|
|
"""Return item url with dispatch."""
|
|
return reverse(
|
|
'xblock_handler',
|
|
args=(str(self.course.id), quote_slashes(self.item_url), 'xmodule_handler', dispatch)
|
|
)
|
|
|
|
|
|
class XModuleRenderingTestBase(BaseTestXmodule): # lint-amnesty, pylint: disable=missing-class-docstring
|
|
|
|
def new_module_runtime(self):
|
|
"""
|
|
Create a runtime that actually does html rendering
|
|
"""
|
|
runtime = super().new_module_runtime()
|
|
runtime.render_template = render_to_string
|
|
return runtime
|
|
|
|
|
|
class LoginEnrollmentTestCase(TestCase):
|
|
"""
|
|
Provides support for user creation,
|
|
activation, login, and course enrollment.
|
|
"""
|
|
user = None
|
|
|
|
def setup_user(self):
|
|
"""
|
|
Create a user account, activate, and log in.
|
|
"""
|
|
self.email = 'foo@test.com' # lint-amnesty, pylint: disable=attribute-defined-outside-init
|
|
self.password = 'bar' # lint-amnesty, pylint: disable=attribute-defined-outside-init
|
|
self.username = 'test' # lint-amnesty, pylint: disable=attribute-defined-outside-init
|
|
self.user = self.create_account(
|
|
self.username,
|
|
self.email,
|
|
self.password,
|
|
)
|
|
# activate_user re-fetches and returns the activated user record
|
|
self.user = self.activate_user(self.email)
|
|
self.login(self.email, self.password)
|
|
|
|
def assert_request_status_code(self, status_code, url, method="GET", **kwargs):
|
|
"""
|
|
Make a request to the specified URL and verify that it returns the
|
|
expected status code.
|
|
"""
|
|
make_request = getattr(self.client, method.lower())
|
|
response = make_request(url, **kwargs)
|
|
assert response.status_code == status_code, f'{method} request to {url} returned status code {response.status_code}, expected status code {status_code}' # pylint: disable=line-too-long
|
|
return response
|
|
|
|
def assert_account_activated(self, url, method="GET", **kwargs): # lint-amnesty, pylint: disable=missing-function-docstring
|
|
make_request = getattr(self.client, method.lower())
|
|
response = make_request(url, **kwargs)
|
|
message_list = list(messages.get_messages(response.wsgi_request))
|
|
assert len(message_list) == 1
|
|
assert 'success' in message_list[0].tags
|
|
assert 'You have activated your account.' in message_list[0].message
|
|
|
|
# ============ User creation and login ==============
|
|
|
|
def login(self, email, password):
|
|
"""
|
|
Login, check that the corresponding view's response has a 200 status code.
|
|
"""
|
|
resp = self.client.post(reverse('user_api_login_session'),
|
|
{'email': email, 'password': password})
|
|
assert resp.status_code == 200
|
|
|
|
def logout(self):
|
|
"""
|
|
Logout; check that the HTTP response code indicates redirection
|
|
as expected.
|
|
"""
|
|
self.assert_request_status_code(200, reverse('logout'))
|
|
|
|
def create_account(self, username, email, password):
|
|
"""
|
|
Create the account and check that it worked.
|
|
"""
|
|
url = reverse('user_api_registration')
|
|
request_data = {
|
|
'username': username,
|
|
'email': email,
|
|
'password': password,
|
|
'name': 'username',
|
|
'terms_of_service': 'true',
|
|
'honor_code': 'true',
|
|
}
|
|
self.assert_request_status_code(200, url, method="POST", data=request_data)
|
|
# Check both that the user is created, and inactive
|
|
user = User.objects.get(email=email)
|
|
assert not user.is_active
|
|
return user
|
|
|
|
def activate_user(self, email):
|
|
"""
|
|
Look up the activation key for the user, then hit the activate view.
|
|
No error checking.
|
|
"""
|
|
activation_key = Registration.objects.get(user__email=email).activation_key
|
|
# and now we try to activate
|
|
url = reverse('activate', kwargs={'key': activation_key})
|
|
self.assert_account_activated(url)
|
|
# Now make sure that the user is now actually activated
|
|
user = User.objects.get(email=email)
|
|
assert user.is_active
|
|
# And return the user we fetched.
|
|
return user
|
|
|
|
def enroll(self, course, verify=False):
|
|
"""
|
|
Try to enroll and return boolean indicating result.
|
|
`course` is an instance of CourseBlock.
|
|
`verify` is an optional boolean parameter specifying whether we
|
|
want to verify that the student was successfully enrolled
|
|
in the course.
|
|
"""
|
|
resp = self.client.post(reverse('change_enrollment'), {
|
|
'enrollment_action': 'enroll',
|
|
'course_id': str(course.id),
|
|
'check_access': True,
|
|
})
|
|
result = resp.status_code == 200
|
|
if verify:
|
|
assert result
|
|
return result
|
|
|
|
def unenroll(self, course):
|
|
"""
|
|
Unenroll the currently logged-in user, and check that it worked.
|
|
`course` is an instance of CourseBlock.
|
|
"""
|
|
url = reverse('change_enrollment')
|
|
request_data = {
|
|
'enrollment_action': 'unenroll',
|
|
'course_id': str(course.id),
|
|
}
|
|
self.assert_request_status_code(200, url, method="POST", data=request_data)
|
|
|
|
|
|
class CourseAccessTestMixin(TestCase):
|
|
"""
|
|
Utility mixin for asserting access (or lack thereof) to courses.
|
|
If relevant, also checks access for courses' corresponding CourseOverviews.
|
|
"""
|
|
|
|
def assertCanAccessCourse(self, user, action, course):
|
|
"""
|
|
Assert that a user has access to the given action for a given course.
|
|
|
|
Test with both the given course and with a CourseOverview of the given
|
|
course.
|
|
|
|
Arguments:
|
|
user (User): a user.
|
|
action (str): type of access to test.
|
|
course (CourseBlock): a course.
|
|
"""
|
|
assert has_access(user, action, course)
|
|
assert has_access(user, action, CourseOverview.get_from_id(course.id))
|
|
|
|
def assertCannotAccessCourse(self, user, action, course):
|
|
"""
|
|
Assert that a user lacks access to the given action the given course.
|
|
|
|
Test with both the given course and with a CourseOverview of the given
|
|
course.
|
|
|
|
Arguments:
|
|
user (User): a user.
|
|
action (str): type of access to test.
|
|
course (CourseBlock): a course.
|
|
|
|
Note:
|
|
It may seem redundant to have one method for testing access
|
|
and another method for testing lack thereof (why not just combine
|
|
them into one method with a boolean flag?), but it makes reading
|
|
stack traces of failed tests easier to understand at a glance.
|
|
"""
|
|
assert not has_access(user, action, course)
|
|
assert not has_access(user, action, CourseOverview.get_from_id(course.id))
|
|
|
|
|
|
class MasqueradeMixin:
|
|
"""
|
|
Adds masquerade utilities for your TestCase.
|
|
|
|
Your test case class must have self.client. And can optionally have self.course if you don't want
|
|
to pass in the course parameter below.
|
|
"""
|
|
|
|
def update_masquerade(self, course=None, role='student', group_id=None, username=None, user_partition_id=None):
|
|
"""
|
|
Installs a masquerade for the specified user and course, to enable
|
|
the user to masquerade as belonging to the specific partition/group
|
|
combination.
|
|
|
|
Arguments:
|
|
course (object): a course or None for self.course
|
|
user_partition_id (int): the integer partition id, referring to partitions already
|
|
configured in the course.
|
|
group_id (int); the integer group id, within the specified partition.
|
|
username (str): user to masquerade as
|
|
role (str): role to masquerade as
|
|
|
|
Returns: the response object for the AJAX call to update the user's masquerade.
|
|
"""
|
|
course = course or self.course
|
|
masquerade_url = reverse(
|
|
'masquerade_update',
|
|
kwargs={
|
|
'course_key_string': str(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'
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()['success'], response.json().get('error')
|
|
return response
|
|
|
|
|
|
def masquerade_as_group_member(user, course, partition_id, group_id):
|
|
"""
|
|
Installs a masquerade for the specified user and course, to enable
|
|
the user to masquerade as belonging to the specific partition/group
|
|
combination.
|
|
|
|
Arguments:
|
|
user (User): a user.
|
|
course (CourseBlock): a course.
|
|
partition_id (int): the integer partition id, referring to partitions already
|
|
configured in the course.
|
|
group_id (int); the integer group id, within the specified partition.
|
|
|
|
Returns: the status code for the AJAX response to update the user's masquerade for
|
|
the specified course.
|
|
"""
|
|
request = _create_mock_json_request(
|
|
user,
|
|
data={"role": "student", "user_partition_id": partition_id, "group_id": group_id}
|
|
)
|
|
response = MasqueradeView.as_view()(request, str(course.id))
|
|
setup_masquerade(request, course.id, True)
|
|
return response.status_code
|
|
|
|
|
|
def _create_mock_json_request(user, data, method='POST'):
|
|
"""
|
|
Returns a mock JSON request for the specified user.
|
|
"""
|
|
factory = RequestFactory()
|
|
request = factory.generic(method, '/', content_type='application/json', data=json.dumps(data))
|
|
request.user = user
|
|
request.session = {}
|
|
return request
|
|
|
|
|
|
def get_expiration_banner_text(user, course, language='en'): # lint-amnesty, pylint: disable=unused-argument
|
|
"""
|
|
Get text for banner that messages user course expiration date
|
|
for different tests that depend on it.
|
|
"""
|
|
upgrade_link = verified_upgrade_deadline_link(user=user, course=course)
|
|
enrollment = CourseEnrollment.get_enrollment(user, course.id)
|
|
expiration_date = enrollment.created + timedelta(weeks=4)
|
|
upgrade_deadline = enrollment.upgrade_deadline
|
|
if upgrade_deadline is None or now() < upgrade_deadline:
|
|
upgrade_deadline = enrollment.course_upgrade_deadline
|
|
|
|
formatted_expiration_date = strftime_localized_html(expiration_date, 'SHORT_DATE')
|
|
if upgrade_deadline:
|
|
formatted_upgrade_deadline = strftime_localized_html(upgrade_deadline, 'SHORT_DATE')
|
|
|
|
bannerText = '<strong>Audit Access Expires {expiration_date}</strong><br>\
|
|
You lose all access to this course, including your progress, on {expiration_date}.\
|
|
<br>Upgrade by {upgrade_deadline} to get unlimited access to the course as long as it exists\
|
|
on the site. <a id="FBE_banner" href="{upgrade_link}">Upgrade now<span class="sr-only"> to retain access past\
|
|
{expiration_date}</span></a>'.format(
|
|
expiration_date=formatted_expiration_date,
|
|
upgrade_link=upgrade_link,
|
|
upgrade_deadline=formatted_upgrade_deadline
|
|
)
|
|
else:
|
|
bannerText = '<strong>Audit Access Expires {expiration_date}</strong><br>\
|
|
You lose all access to this course, including your progress, on {expiration_date}.\
|
|
'.format(
|
|
expiration_date=formatted_expiration_date
|
|
)
|
|
return bannerText
|
|
|
|
|
|
def get_context_dict_from_string(data):
|
|
"""
|
|
Retrieve dictionary from string.
|
|
"""
|
|
# Replace tuple and un-necessary info from inside string and get the dictionary.
|
|
cleaned_data = ast.literal_eval(data.split('((\'video.html\',')[1].replace("),\n {})", '').strip())
|
|
cleaned_data['metadata'] = OrderedDict(
|
|
sorted(json.loads(cleaned_data['metadata']).items(), key=lambda t: t[0])
|
|
)
|
|
return cleaned_data
|