Merge pull request #21413 from open-craft/kshtij/problem-response-multi-root-and-fitler
[BB-873] Support for filters, and multiple roots in problem response reports
This commit is contained in:
@@ -1003,30 +1003,62 @@ def get_problem_responses(request, course_id):
|
|||||||
Initiate generation of a CSV file containing all student answers
|
Initiate generation of a CSV file containing all student answers
|
||||||
to a given problem.
|
to a given problem.
|
||||||
|
|
||||||
Responds with JSON
|
**Example requests**
|
||||||
{"status": "... status message ...", "task_id": created_task_UUID}
|
|
||||||
|
|
||||||
if initiation is successful (or generation task is already running).
|
POST /courses/{course_id}/instructor/api/get_problem_responses {
|
||||||
|
"problem_location": "{usage_key1},{usage_key2},{usage_key3}""
|
||||||
|
}
|
||||||
|
POST /courses/{course_id}/instructor/api/get_problem_responses {
|
||||||
|
"problem_location": "{usage_key}",
|
||||||
|
"problem_types_filter": "problem"
|
||||||
|
}
|
||||||
|
|
||||||
Responds with BadRequest if problem location is faulty.
|
**POST Parameters**
|
||||||
|
|
||||||
|
A POST request can include the following parameters:
|
||||||
|
|
||||||
|
* problem_location: A comma-separated list of usage keys for the blocks
|
||||||
|
to include in the report. If the location is a block that contains
|
||||||
|
other blocks, (such as the course, section, subsection, or unit blocks)
|
||||||
|
then all blocks under that block will be included in the report.
|
||||||
|
* problem_types_filter: Optional. A comma-separated list of block types
|
||||||
|
to include in the repot. If set, only blocks of the specified types will
|
||||||
|
be included in the report.
|
||||||
|
|
||||||
|
To get data on all the poll and survey blocks in a course, you could
|
||||||
|
POST the usage key of the course for `problem_location`, and
|
||||||
|
"poll, survey" as the value for `problem_types_filter`.
|
||||||
|
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
If initiation is successful (or generation task is already running):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "The problem responses report is being created. ...",
|
||||||
|
"task_id": "4e49522f-31d9-431a-9cff-dd2a2bf4c85a"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Responds with BadRequest if any of the provided problem locations are faulty.
|
||||||
"""
|
"""
|
||||||
course_key = CourseKey.from_string(course_id)
|
course_key = CourseKey.from_string(course_id)
|
||||||
problem_location = request.POST.get('problem_location', '')
|
# A comma-separated list of problem locations
|
||||||
|
# The name of the POST parameter is `problem_location` (not pluralised) in
|
||||||
|
# order to preserve backwards compatibility with existing third-party
|
||||||
|
# scripts.
|
||||||
|
problem_locations = request.POST.get('problem_location', '')
|
||||||
|
# A comma-separated list of block types
|
||||||
|
problem_types_filter = request.POST.get('problem_types_filter', '')
|
||||||
report_type = _('problem responses')
|
report_type = _('problem responses')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
problem_key = UsageKey.from_string(problem_location)
|
for problem_location in problem_locations.split(','):
|
||||||
# Are we dealing with an "old-style" problem location?
|
|
||||||
run = problem_key.run
|
|
||||||
if not run:
|
|
||||||
problem_key = UsageKey.from_string(problem_location).map_into_course(course_key)
|
problem_key = UsageKey.from_string(problem_location).map_into_course(course_key)
|
||||||
if problem_key.course_key != course_key:
|
|
||||||
raise InvalidKeyError(type(problem_key), problem_key)
|
|
||||||
except InvalidKeyError:
|
except InvalidKeyError:
|
||||||
return JsonResponseBadRequest(_("Could not find problem with this location."))
|
return JsonResponseBadRequest(_("Could not find problem with this location."))
|
||||||
|
|
||||||
task = task_api.submit_calculate_problem_responses_csv(
|
task = task_api.submit_calculate_problem_responses_csv(
|
||||||
request, course_key, problem_location
|
request, course_key, problem_locations, problem_types_filter,
|
||||||
)
|
)
|
||||||
success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type)
|
success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type)
|
||||||
|
|
||||||
|
|||||||
@@ -322,7 +322,9 @@ def submit_bulk_course_email(request, course_key, email_id):
|
|||||||
return submit_task(request, task_type, task_class, course_key, task_input, task_key)
|
return submit_task(request, task_type, task_class, course_key, task_input, task_key)
|
||||||
|
|
||||||
|
|
||||||
def submit_calculate_problem_responses_csv(request, course_key, problem_location):
|
def submit_calculate_problem_responses_csv(
|
||||||
|
request, course_key, problem_locations, problem_types_filter=None,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Submits a task to generate a CSV file containing all student
|
Submits a task to generate a CSV file containing all student
|
||||||
answers to a given problem.
|
answers to a given problem.
|
||||||
@@ -331,7 +333,11 @@ def submit_calculate_problem_responses_csv(request, course_key, problem_location
|
|||||||
"""
|
"""
|
||||||
task_type = 'problem_responses_csv'
|
task_type = 'problem_responses_csv'
|
||||||
task_class = calculate_problem_responses_csv
|
task_class = calculate_problem_responses_csv
|
||||||
task_input = {'problem_location': problem_location, 'user_id': request.user.pk}
|
task_input = {
|
||||||
|
'problem_locations': problem_locations,
|
||||||
|
'problem_types_filter': problem_types_filter,
|
||||||
|
'user_id': request.user.pk,
|
||||||
|
}
|
||||||
task_key = ""
|
task_key = ""
|
||||||
|
|
||||||
return submit_task(request, task_type, task_class, course_key, task_input, task_key)
|
return submit_task(request, task_type, task_class, course_key, task_input, task_key)
|
||||||
|
|||||||
@@ -159,7 +159,11 @@ def send_bulk_course_email(entry_id, _xmodule_instance_args):
|
|||||||
return run_main_task(entry_id, visit_fcn, action_name)
|
return run_main_task(entry_id, visit_fcn, action_name)
|
||||||
|
|
||||||
|
|
||||||
@task(base=BaseInstructorTask, routing_key=settings.GRADES_DOWNLOAD_ROUTING_KEY)
|
@task(
|
||||||
|
name='lms.djangoapps.instructor_task.tasks.calculate_problem_responses_csv.v2',
|
||||||
|
base=BaseInstructorTask,
|
||||||
|
routing_key=settings.GRADES_DOWNLOAD_ROUTING_KEY,
|
||||||
|
)
|
||||||
def calculate_problem_responses_csv(entry_id, xmodule_instance_args):
|
def calculate_problem_responses_csv(entry_id, xmodule_instance_args):
|
||||||
"""
|
"""
|
||||||
Compute student answers to a given problem and upload the CSV to
|
Compute student answers to a given problem and upload the CSV to
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ Functionality for generating grade reports.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
from collections import OrderedDict, defaultdict
|
from collections import OrderedDict, defaultdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
from time import time
|
from time import time
|
||||||
|
|
||||||
|
import re
|
||||||
import six
|
import six
|
||||||
|
from course_blocks.api import get_course_blocks
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from lazy import lazy
|
from lazy import lazy
|
||||||
@@ -18,33 +19,33 @@ from pytz import UTC
|
|||||||
from six import text_type
|
from six import text_type
|
||||||
from six.moves import zip, zip_longest
|
from six.moves import zip, zip_longest
|
||||||
|
|
||||||
from course_blocks.api import get_course_blocks
|
|
||||||
from course_modes.models import CourseMode
|
from course_modes.models import CourseMode
|
||||||
from lms.djangoapps.certificates.models import CertificateWhitelist, GeneratedCertificate, certificate_info_for_user
|
from lms.djangoapps.certificates.models import CertificateWhitelist, GeneratedCertificate, certificate_info_for_user
|
||||||
from lms.djangoapps.courseware.courses import get_course_by_id
|
from lms.djangoapps.courseware.courses import get_course_by_id
|
||||||
from lms.djangoapps.courseware.user_state_client import DjangoXBlockUserStateClient
|
from lms.djangoapps.courseware.user_state_client import DjangoXBlockUserStateClient
|
||||||
from lms.djangoapps.grades.api import CourseGradeFactory
|
from lms.djangoapps.grades.api import (
|
||||||
from lms.djangoapps.grades.api import context as grades_context
|
CourseGradeFactory,
|
||||||
from lms.djangoapps.grades.api import prefetch_course_and_subsection_grades
|
context as grades_context,
|
||||||
|
prefetch_course_and_subsection_grades,
|
||||||
|
)
|
||||||
from lms.djangoapps.instructor_analytics.basic import list_problem_responses
|
from lms.djangoapps.instructor_analytics.basic import list_problem_responses
|
||||||
from lms.djangoapps.instructor_analytics.csvs import format_dictlist
|
from lms.djangoapps.instructor_analytics.csvs import format_dictlist
|
||||||
from lms.djangoapps.instructor_task.config.waffle import (
|
from lms.djangoapps.instructor_task.config.waffle import (
|
||||||
course_grade_report_verified_only,
|
course_grade_report_verified_only,
|
||||||
optimize_get_learners_switch_enabled,
|
optimize_get_learners_switch_enabled,
|
||||||
problem_grade_report_verified_only
|
problem_grade_report_verified_only,
|
||||||
)
|
)
|
||||||
from lms.djangoapps.teams.models import CourseTeamMembership
|
from lms.djangoapps.teams.models import CourseTeamMembership
|
||||||
from lms.djangoapps.verify_student.services import IDVerificationService
|
from lms.djangoapps.verify_student.services import IDVerificationService
|
||||||
from openedx.core.lib.cache_utils import get_cache
|
|
||||||
from openedx.core.djangoapps.content.block_structure.api import get_course_in_cache
|
from openedx.core.djangoapps.content.block_structure.api import get_course_in_cache
|
||||||
from openedx.core.djangoapps.course_groups.cohorts import bulk_cache_cohorts, get_cohort, is_course_cohorted
|
from openedx.core.djangoapps.course_groups.cohorts import bulk_cache_cohorts, get_cohort, is_course_cohorted
|
||||||
from openedx.core.djangoapps.user_api.course_tag.api import BulkCourseTags
|
from openedx.core.djangoapps.user_api.course_tag.api import BulkCourseTags
|
||||||
|
from openedx.core.lib.cache_utils import get_cache
|
||||||
from student.models import CourseEnrollment
|
from student.models import CourseEnrollment
|
||||||
from student.roles import BulkRoleCache
|
from student.roles import BulkRoleCache
|
||||||
from xmodule.modulestore.django import modulestore
|
from xmodule.modulestore.django import modulestore
|
||||||
from xmodule.partitions.partitions_service import PartitionService
|
from xmodule.partitions.partitions_service import PartitionService
|
||||||
from xmodule.split_test_module import get_split_user_partitions
|
from xmodule.split_test_module import get_split_user_partitions
|
||||||
|
|
||||||
from .runner import TaskProgress
|
from .runner import TaskProgress
|
||||||
from .utils import upload_csv_to_report_store
|
from .utils import upload_csv_to_report_store
|
||||||
|
|
||||||
@@ -822,6 +823,23 @@ class ProblemResponses(object):
|
|||||||
Class to encapsulate functionality related to generating Problem Responses Reports.
|
Class to encapsulate functionality related to generating Problem Responses Reports.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_block_base_path(block):
|
||||||
|
"""
|
||||||
|
Return the display names of the blocks that lie above the supplied block in hierarchy.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
block: a single block
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[str]: a list of display names of blocks starting from the root block (Course)
|
||||||
|
"""
|
||||||
|
path = []
|
||||||
|
while block.parent:
|
||||||
|
block = block.get_parent()
|
||||||
|
path.append(block.display_name)
|
||||||
|
return list(reversed(path))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _build_problem_list(cls, course_blocks, root, path=None):
|
def _build_problem_list(cls, course_blocks, root, path=None):
|
||||||
"""
|
"""
|
||||||
@@ -836,19 +854,21 @@ class ProblemResponses(object):
|
|||||||
Tuple[str, List[str], UsageKey]: tuple of a block's display name, path, and
|
Tuple[str, List[str], UsageKey]: tuple of a block's display name, path, and
|
||||||
usage key
|
usage key
|
||||||
"""
|
"""
|
||||||
name = course_blocks.get_xblock_field(root, 'display_name') or root.category
|
name = course_blocks.get_xblock_field(root, 'display_name') or root.block_type
|
||||||
if path is None:
|
if path is None:
|
||||||
path = [name]
|
path = [name]
|
||||||
|
|
||||||
yield name, path, root
|
yield name, path, root
|
||||||
|
|
||||||
for block in course_blocks.get_children(root):
|
for block in course_blocks.get_children(root):
|
||||||
name = course_blocks.get_xblock_field(block, 'display_name') or block.category
|
name = course_blocks.get_xblock_field(block, 'display_name') or block.block_type
|
||||||
for result in cls._build_problem_list(course_blocks, block, path + [name]):
|
for result in cls._build_problem_list(course_blocks, block, path + [name]):
|
||||||
yield result
|
yield result
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _build_student_data(cls, user_id, course_key, usage_key_str):
|
def _build_student_data(
|
||||||
|
cls, user_id, course_key, usage_key_str_list, filter_types=None,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Generate a list of problem responses for all problem under the
|
Generate a list of problem responses for all problem under the
|
||||||
``problem_location`` root.
|
``problem_location`` root.
|
||||||
@@ -856,16 +876,20 @@ class ProblemResponses(object):
|
|||||||
user_id (int): The user id for the user generating the report
|
user_id (int): The user id for the user generating the report
|
||||||
course_key (CourseKey): The ``CourseKey`` for the course whose report
|
course_key (CourseKey): The ``CourseKey`` for the course whose report
|
||||||
is being generated
|
is being generated
|
||||||
usage_key_str (str): The generated report will include this
|
usage_key_str_list (List[str]): The generated report will include these
|
||||||
block and it child blocks.
|
blocks and their child blocks.
|
||||||
|
filter_types (List[str]): The report generator will only include data for
|
||||||
|
block types in this list.
|
||||||
Returns:
|
Returns:
|
||||||
Tuple[List[Dict], List[str]]: Returns a list of dictionaries
|
Tuple[List[Dict], List[str]]: Returns a list of dictionaries
|
||||||
containing the student data which will be included in the
|
containing the student data which will be included in the
|
||||||
final csv, and the features/keys to include in that CSV.
|
final csv, and the features/keys to include in that CSV.
|
||||||
"""
|
"""
|
||||||
usage_key = UsageKey.from_string(usage_key_str).map_into_course(course_key)
|
usage_keys = [
|
||||||
|
UsageKey.from_string(usage_key_str).map_into_course(course_key)
|
||||||
|
for usage_key_str in usage_key_str_list
|
||||||
|
]
|
||||||
user = get_user_model().objects.get(pk=user_id)
|
user = get_user_model().objects.get(pk=user_id)
|
||||||
course_blocks = get_course_blocks(user, usage_key)
|
|
||||||
|
|
||||||
student_data = []
|
student_data = []
|
||||||
max_count = settings.FEATURES.get('MAX_PROBLEM_RESPONSES_COUNT')
|
max_count = settings.FEATURES.get('MAX_PROBLEM_RESPONSES_COUNT')
|
||||||
@@ -876,53 +900,61 @@ class ProblemResponses(object):
|
|||||||
student_data_keys = set()
|
student_data_keys = set()
|
||||||
|
|
||||||
with store.bulk_operations(course_key):
|
with store.bulk_operations(course_key):
|
||||||
for title, path, block_key in cls._build_problem_list(course_blocks, usage_key):
|
for usage_key in usage_keys:
|
||||||
# Chapter and sequential blocks are filtered out since they include state
|
if max_count is not None and max_count <= 0:
|
||||||
# which isn't useful for this report.
|
break
|
||||||
if block_key.block_type in ('sequential', 'chapter'):
|
course_blocks = get_course_blocks(user, usage_key)
|
||||||
continue
|
base_path = cls._build_block_base_path(store.get_item(usage_key))
|
||||||
|
for title, path, block_key in cls._build_problem_list(course_blocks, usage_key):
|
||||||
|
# Chapter and sequential blocks are filtered out since they include state
|
||||||
|
# which isn't useful for this report.
|
||||||
|
if block_key.block_type in ('sequential', 'chapter'):
|
||||||
|
continue
|
||||||
|
|
||||||
block = store.get_item(block_key)
|
if filter_types is not None and block_key.block_type not in filter_types:
|
||||||
generated_report_data = defaultdict(list)
|
continue
|
||||||
|
|
||||||
# Blocks can implement the generate_report_data method to provide their own
|
block = store.get_item(block_key)
|
||||||
# human-readable formatting for user state.
|
generated_report_data = defaultdict(list)
|
||||||
if hasattr(block, 'generate_report_data'):
|
|
||||||
try:
|
|
||||||
user_state_iterator = user_state_client.iter_all_for_block(block_key)
|
|
||||||
for username, state in block.generate_report_data(user_state_iterator, max_count):
|
|
||||||
generated_report_data[username].append(state)
|
|
||||||
except NotImplementedError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
responses = []
|
# Blocks can implement the generate_report_data method to provide their own
|
||||||
|
# human-readable formatting for user state.
|
||||||
|
if hasattr(block, 'generate_report_data'):
|
||||||
|
try:
|
||||||
|
user_state_iterator = user_state_client.iter_all_for_block(block_key)
|
||||||
|
for username, state in block.generate_report_data(user_state_iterator, max_count):
|
||||||
|
generated_report_data[username].append(state)
|
||||||
|
except NotImplementedError:
|
||||||
|
pass
|
||||||
|
|
||||||
for response in list_problem_responses(course_key, block_key, max_count):
|
responses = []
|
||||||
response['title'] = title
|
|
||||||
# A human-readable location for the current block
|
|
||||||
response['location'] = ' > '.join(path)
|
|
||||||
# A machine-friendly location for the current block
|
|
||||||
response['block_key'] = str(block_key)
|
|
||||||
# A block that has a single state per user can contain multiple responses
|
|
||||||
# within the same state.
|
|
||||||
user_states = generated_report_data.get(response['username'], [])
|
|
||||||
if user_states:
|
|
||||||
# For each response in the block, copy over the basic data like the
|
|
||||||
# title, location, block_key and state, and add in the responses
|
|
||||||
for user_state in user_states:
|
|
||||||
user_response = response.copy()
|
|
||||||
user_response.update(user_state)
|
|
||||||
student_data_keys = student_data_keys.union(list(user_state.keys()))
|
|
||||||
responses.append(user_response)
|
|
||||||
else:
|
|
||||||
responses.append(response)
|
|
||||||
|
|
||||||
student_data += responses
|
for response in list_problem_responses(course_key, block_key, max_count):
|
||||||
|
response['title'] = title
|
||||||
|
# A human-readable location for the current block
|
||||||
|
response['location'] = ' > '.join(base_path + path)
|
||||||
|
# A machine-friendly location for the current block
|
||||||
|
response['block_key'] = str(block_key)
|
||||||
|
# A block that has a single state per user can contain multiple responses
|
||||||
|
# within the same state.
|
||||||
|
user_states = generated_report_data.get(response['username'])
|
||||||
|
if user_states:
|
||||||
|
# For each response in the block, copy over the basic data like the
|
||||||
|
# title, location, block_key and state, and add in the responses
|
||||||
|
for user_state in user_states:
|
||||||
|
user_response = response.copy()
|
||||||
|
user_response.update(user_state)
|
||||||
|
student_data_keys = student_data_keys.union(list(user_state.keys()))
|
||||||
|
responses.append(user_response)
|
||||||
|
else:
|
||||||
|
responses.append(response)
|
||||||
|
|
||||||
if max_count is not None:
|
student_data += responses
|
||||||
max_count -= len(responses)
|
|
||||||
if max_count <= 0:
|
if max_count is not None:
|
||||||
break
|
max_count -= len(responses)
|
||||||
|
if max_count <= 0:
|
||||||
|
break
|
||||||
|
|
||||||
# Keep the keys in a useful order, starting with username, title and location,
|
# Keep the keys in a useful order, starting with username, title and location,
|
||||||
# then the columns returned by the xblock report generator in sorted order and
|
# then the columns returned by the xblock report generator in sorted order and
|
||||||
@@ -947,13 +979,19 @@ class ProblemResponses(object):
|
|||||||
task_progress = TaskProgress(action_name, num_reports, start_time)
|
task_progress = TaskProgress(action_name, num_reports, start_time)
|
||||||
current_step = {'step': 'Calculating students answers to problem'}
|
current_step = {'step': 'Calculating students answers to problem'}
|
||||||
task_progress.update_task_state(extra_meta=current_step)
|
task_progress.update_task_state(extra_meta=current_step)
|
||||||
problem_location = task_input.get('problem_location')
|
problem_locations = task_input.get('problem_locations').split(',')
|
||||||
|
problem_types_filter = task_input.get('problem_types_filter')
|
||||||
|
|
||||||
|
filter_types = None
|
||||||
|
if problem_types_filter:
|
||||||
|
filter_types = problem_types_filter.split(',')
|
||||||
|
|
||||||
# Compute result table and format it
|
# Compute result table and format it
|
||||||
student_data, student_data_keys = cls._build_student_data(
|
student_data, student_data_keys = cls._build_student_data(
|
||||||
user_id=task_input.get('user_id'),
|
user_id=task_input.get('user_id'),
|
||||||
course_key=course_id,
|
course_key=course_id,
|
||||||
usage_key_str=problem_location
|
usage_key_str_list=problem_locations,
|
||||||
|
filter_types=filter_types,
|
||||||
)
|
)
|
||||||
|
|
||||||
for data in student_data:
|
for data in student_data:
|
||||||
@@ -971,8 +1009,7 @@ class ProblemResponses(object):
|
|||||||
task_progress.update_task_state(extra_meta=current_step)
|
task_progress.update_task_state(extra_meta=current_step)
|
||||||
|
|
||||||
# Perform the upload
|
# Perform the upload
|
||||||
problem_location = re.sub(r'[:/]', '_', problem_location)
|
csv_name = cls._generate_upload_file_name(problem_locations, filter_types)
|
||||||
csv_name = 'student_state_from_{}'.format(problem_location)
|
|
||||||
report_name, report_path = upload_csv_to_report_store(rows, csv_name, course_id, start_date)
|
report_name, report_path = upload_csv_to_report_store(rows, csv_name, course_id, start_date)
|
||||||
current_step = {
|
current_step = {
|
||||||
'step': 'CSV uploaded',
|
'step': 'CSV uploaded',
|
||||||
@@ -981,3 +1018,17 @@ class ProblemResponses(object):
|
|||||||
}
|
}
|
||||||
|
|
||||||
return task_progress.update_task_state(extra_meta=current_step)
|
return task_progress.update_task_state(extra_meta=current_step)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _generate_upload_file_name(problem_locations, filters):
|
||||||
|
"""Generate a concise file name based on the report generation parameters."""
|
||||||
|
multiple_problems = len(problem_locations) > 1
|
||||||
|
csv_name = 'student_state'
|
||||||
|
if multiple_problems:
|
||||||
|
csv_name += '_from_multiple_blocks'
|
||||||
|
else:
|
||||||
|
problem_location = re.sub(r'[:/]', '_', problem_locations[0])
|
||||||
|
csv_name += '_from_' + problem_location
|
||||||
|
if filters:
|
||||||
|
csv_name += '_for_' + ','.join(filters)
|
||||||
|
return csv_name
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa
|
|||||||
api_call = lambda: submit_calculate_problem_responses_csv(
|
api_call = lambda: submit_calculate_problem_responses_csv(
|
||||||
self.create_task_request(self.instructor),
|
self.create_task_request(self.instructor),
|
||||||
self.course.id,
|
self.course.id,
|
||||||
problem_location=''
|
problem_locations='',
|
||||||
)
|
)
|
||||||
self._test_resubmission(api_call)
|
self._test_resubmission(api_call)
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import openedx.core.djangoapps.user_api.course_tag.api as course_tag_api
|
|||||||
from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
|
from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
|
||||||
from course_modes.models import CourseMode
|
from course_modes.models import CourseMode
|
||||||
from course_modes.tests.factories import CourseModeFactory
|
from course_modes.tests.factories import CourseModeFactory
|
||||||
|
from lms.djangoapps.courseware.models import StudentModule
|
||||||
from lms.djangoapps.certificates.models import CertificateStatuses, GeneratedCertificate
|
from lms.djangoapps.certificates.models import CertificateStatuses, GeneratedCertificate
|
||||||
from lms.djangoapps.certificates.tests.factories import CertificateWhitelistFactory, GeneratedCertificateFactory
|
from lms.djangoapps.certificates.tests.factories import CertificateWhitelistFactory, GeneratedCertificateFactory
|
||||||
from lms.djangoapps.courseware.tests.factories import InstructorFactory
|
from lms.djangoapps.courseware.tests.factories import InstructorFactory
|
||||||
@@ -472,6 +473,7 @@ class TestTeamGradeReport(InstructorGradeReportTestCase):
|
|||||||
|
|
||||||
|
|
||||||
# pylint: disable=protected-access
|
# pylint: disable=protected-access
|
||||||
|
@ddt.ddt
|
||||||
class TestProblemResponsesReport(TestReportMixin, InstructorTaskModuleTestCase):
|
class TestProblemResponsesReport(TestReportMixin, InstructorTaskModuleTestCase):
|
||||||
"""
|
"""
|
||||||
Tests that generation of CSV files listing student answers to a
|
Tests that generation of CSV files listing student answers to a
|
||||||
@@ -512,7 +514,7 @@ class TestProblemResponsesReport(TestReportMixin, InstructorTaskModuleTestCase):
|
|||||||
student_data, _ = ProblemResponses._build_student_data(
|
student_data, _ = ProblemResponses._build_student_data(
|
||||||
user_id=self.instructor.id,
|
user_id=self.instructor.id,
|
||||||
course_key=self.course.id,
|
course_key=self.course.id,
|
||||||
usage_key_str=str(self.course.location),
|
usage_key_str_list=[str(self.course.location)],
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(len(student_data), 4)
|
self.assertEqual(len(student_data), 4)
|
||||||
@@ -532,12 +534,12 @@ class TestProblemResponsesReport(TestReportMixin, InstructorTaskModuleTestCase):
|
|||||||
student_data, _ = ProblemResponses._build_student_data(
|
student_data, _ = ProblemResponses._build_student_data(
|
||||||
user_id=self.instructor.id,
|
user_id=self.instructor.id,
|
||||||
course_key=self.course.id,
|
course_key=self.course.id,
|
||||||
usage_key_str=str(problem.location),
|
usage_key_str_list=[str(problem.location)],
|
||||||
)
|
)
|
||||||
self.assertEqual(len(student_data), 1)
|
self.assertEqual(len(student_data), 1)
|
||||||
self.assertDictContainsSubset({
|
self.assertDictContainsSubset({
|
||||||
'username': 'student',
|
'username': 'student',
|
||||||
'location': 'Problem1',
|
'location': 'test_course > Section > Subsection > Problem1',
|
||||||
'block_key': 'i4x://edx/1.23x/problem/Problem1',
|
'block_key': 'i4x://edx/1.23x/problem/Problem1',
|
||||||
'title': 'Problem1',
|
'title': 'Problem1',
|
||||||
}, student_data[0])
|
}, student_data[0])
|
||||||
@@ -561,7 +563,7 @@ class TestProblemResponsesReport(TestReportMixin, InstructorTaskModuleTestCase):
|
|||||||
student_data, _ = ProblemResponses._build_student_data(
|
student_data, _ = ProblemResponses._build_student_data(
|
||||||
user_id=self.instructor.id,
|
user_id=self.instructor.id,
|
||||||
course_key=self.course.id,
|
course_key=self.course.id,
|
||||||
usage_key_str=str(self.course.location),
|
usage_key_str_list=[str(self.course.location)],
|
||||||
)
|
)
|
||||||
self.assertEqual(len(student_data), 2)
|
self.assertEqual(len(student_data), 2)
|
||||||
self.assertDictContainsSubset({
|
self.assertDictContainsSubset({
|
||||||
@@ -592,7 +594,7 @@ class TestProblemResponsesReport(TestReportMixin, InstructorTaskModuleTestCase):
|
|||||||
student_data, _ = ProblemResponses._build_student_data(
|
student_data, _ = ProblemResponses._build_student_data(
|
||||||
user_id=self.instructor.id,
|
user_id=self.instructor.id,
|
||||||
course_key=self.course.id,
|
course_key=self.course.id,
|
||||||
usage_key_str=str(self.course.location),
|
usage_key_str_list=[str(self.course.location)],
|
||||||
)
|
)
|
||||||
self.assertEqual(len(student_data), 1)
|
self.assertEqual(len(student_data), 1)
|
||||||
self.assertDictContainsSubset({
|
self.assertDictContainsSubset({
|
||||||
@@ -607,6 +609,66 @@ class TestProblemResponsesReport(TestReportMixin, InstructorTaskModuleTestCase):
|
|||||||
}, student_data[0])
|
}, student_data[0])
|
||||||
self.assertIn('state', student_data[0])
|
self.assertIn('state', student_data[0])
|
||||||
|
|
||||||
|
def test_build_student_data_for_multiple_problems(self):
|
||||||
|
"""
|
||||||
|
Ensure that building student data works when supplied multiple usage keys.
|
||||||
|
"""
|
||||||
|
problem1 = self.define_option_problem(u'Problem1')
|
||||||
|
problem2 = self.define_option_problem(u'Problem2')
|
||||||
|
self.submit_student_answer(self.student.username, u'Problem1', ['Option 1'])
|
||||||
|
self.submit_student_answer(self.student.username, u'Problem2', ['Option 1'])
|
||||||
|
student_data, _ = ProblemResponses._build_student_data(
|
||||||
|
user_id=self.instructor.id,
|
||||||
|
course_key=self.course.id,
|
||||||
|
usage_key_str_list=[str(problem1.location), str(problem2.location)],
|
||||||
|
)
|
||||||
|
self.assertEqual(len(student_data), 2)
|
||||||
|
for idx in range(1, 3):
|
||||||
|
self.assertDictContainsSubset({
|
||||||
|
'username': 'student',
|
||||||
|
'location': u'test_course > Section > Subsection > Problem{}'.format(idx),
|
||||||
|
'block_key': 'i4x://edx/1.23x/problem/Problem{}'.format(idx),
|
||||||
|
'title': u'Problem{}'.format(idx),
|
||||||
|
'Answer ID': 'i4x-edx-1_23x-problem-Problem{}_2_1'.format(idx),
|
||||||
|
'Answer': u'Option 1',
|
||||||
|
'Correct Answer': u'Option 1',
|
||||||
|
'Question': u'The correct answer is Option 1',
|
||||||
|
}, student_data[idx - 1])
|
||||||
|
self.assertIn('state', student_data[idx - 1])
|
||||||
|
|
||||||
|
@ddt.data(
|
||||||
|
(['problem'], 5),
|
||||||
|
(['other'], 0),
|
||||||
|
(['problem', 'test-category'], 10),
|
||||||
|
(None, 10),
|
||||||
|
)
|
||||||
|
@ddt.unpack
|
||||||
|
def test_build_student_data_with_filter(self, filters, filtered_count):
|
||||||
|
"""
|
||||||
|
Ensure that building student data works when supplied multiple usage keys.
|
||||||
|
"""
|
||||||
|
for idx in range(1, 6):
|
||||||
|
self.define_option_problem(u'Problem{}'.format(idx))
|
||||||
|
item = ItemFactory.create(
|
||||||
|
parent_location=self.problem_section.location,
|
||||||
|
parent=self.problem_section,
|
||||||
|
category="test-category",
|
||||||
|
display_name=u"Item{}".format(idx),
|
||||||
|
data=''
|
||||||
|
)
|
||||||
|
StudentModule.save_state(self.student, self.course.id, item.location, {})
|
||||||
|
|
||||||
|
for idx in range(1, 6):
|
||||||
|
self.submit_student_answer(self.student.username, u'Problem{}'.format(idx), ['Option 1'])
|
||||||
|
|
||||||
|
student_data, _ = ProblemResponses._build_student_data(
|
||||||
|
user_id=self.instructor.id,
|
||||||
|
course_key=self.course.id,
|
||||||
|
usage_key_str_list=[str(self.course.location)],
|
||||||
|
filter_types=filters,
|
||||||
|
)
|
||||||
|
self.assertEqual(len(student_data), filtered_count)
|
||||||
|
|
||||||
@patch('lms.djangoapps.instructor_task.tasks_helper.grades.list_problem_responses')
|
@patch('lms.djangoapps.instructor_task.tasks_helper.grades.list_problem_responses')
|
||||||
@patch('xmodule.capa_module.ProblemBlock.generate_report_data', create=True)
|
@patch('xmodule.capa_module.ProblemBlock.generate_report_data', create=True)
|
||||||
def test_build_student_data_for_block_with_generate_report_data_not_implemented(
|
def test_build_student_data_for_block_with_generate_report_data_not_implemented(
|
||||||
@@ -623,14 +685,14 @@ class TestProblemResponsesReport(TestReportMixin, InstructorTaskModuleTestCase):
|
|||||||
ProblemResponses._build_student_data(
|
ProblemResponses._build_student_data(
|
||||||
user_id=self.instructor.id,
|
user_id=self.instructor.id,
|
||||||
course_key=self.course.id,
|
course_key=self.course.id,
|
||||||
usage_key_str=str(problem.location),
|
usage_key_str_list=[str(problem.location)],
|
||||||
)
|
)
|
||||||
mock_generate_report_data.assert_called_with(ANY, ANY)
|
mock_generate_report_data.assert_called_with(ANY, ANY)
|
||||||
mock_list_problem_responses.assert_called_with(self.course.id, ANY, ANY)
|
mock_list_problem_responses.assert_called_with(self.course.id, ANY, ANY)
|
||||||
|
|
||||||
def test_success(self):
|
def test_success(self):
|
||||||
task_input = {
|
task_input = {
|
||||||
'problem_location': str(self.course.location),
|
'problem_locations': str(self.course.location),
|
||||||
'user_id': self.instructor.id
|
'user_id': self.instructor.id
|
||||||
}
|
}
|
||||||
with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task'):
|
with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task'):
|
||||||
@@ -650,9 +712,43 @@ class TestProblemResponsesReport(TestReportMixin, InstructorTaskModuleTestCase):
|
|||||||
report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD')
|
report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD')
|
||||||
links = report_store.links_for(self.course.id)
|
links = report_store.links_for(self.course.id)
|
||||||
|
|
||||||
self.assertEqual(len(links), 1)
|
assert len(links) == 1
|
||||||
self.assertDictContainsSubset({'attempted': 3, 'succeeded': 3, 'failed': 0}, result)
|
assert set(({'attempted': 3, 'succeeded': 3, 'failed': 0}).items()).issubset(set(result.items()))
|
||||||
self.assertIn("report_name", result)
|
assert "report_name" in result
|
||||||
|
|
||||||
|
@ddt.data(
|
||||||
|
('blkid', None, 'edx_1.23x_test_course_student_state_from_blkid_2020-01-01-0000.csv'),
|
||||||
|
('blkid', 'poll,survey', 'edx_1.23x_test_course_student_state_from_blkid_for_poll,survey_2020-01-01-0000.csv'),
|
||||||
|
('blkid1,blkid2', None, 'edx_1.23x_test_course_student_state_from_multiple_blocks_2020-01-01-0000.csv'),
|
||||||
|
(
|
||||||
|
'blkid1,blkid2',
|
||||||
|
'poll,survey',
|
||||||
|
'edx_1.23x_test_course_student_state_from_multiple_blocks_for_poll,survey_2020-01-01-0000.csv',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@ddt.unpack
|
||||||
|
def test_file_names(self, problem_locations, problem_types_filter, file_name):
|
||||||
|
task_input = {
|
||||||
|
'problem_locations': problem_locations,
|
||||||
|
'problem_types_filter': problem_types_filter,
|
||||||
|
'user_id': self.instructor.id
|
||||||
|
}
|
||||||
|
with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task'), \
|
||||||
|
freeze_time('2020-01-01'):
|
||||||
|
with patch('lms.djangoapps.instructor_task.tasks_helper.grades'
|
||||||
|
'.ProblemResponses._build_student_data') as mock_build_student_data:
|
||||||
|
mock_build_student_data.return_value = (
|
||||||
|
[
|
||||||
|
{'username': 'user0', 'state': u'state0'},
|
||||||
|
{'username': 'user1', 'state': u'state1'},
|
||||||
|
{'username': 'user2', 'state': u'state2'},
|
||||||
|
],
|
||||||
|
['username', 'state']
|
||||||
|
)
|
||||||
|
result = ProblemResponses.generate(
|
||||||
|
None, None, self.course.id, task_input, 'calculated'
|
||||||
|
)
|
||||||
|
assert result.get('report_name') == file_name
|
||||||
|
|
||||||
|
|
||||||
@ddt.ddt
|
@ddt.ddt
|
||||||
|
|||||||
Reference in New Issue
Block a user