Commit 3acd7a00 by Daniel Friedman Committed by Diana Huang

Refactor and add tests for new grade report.

* Handle grading errors
parent 84f3c33d
...@@ -24,12 +24,16 @@ class GradesheetTest(unittest.TestCase): ...@@ -24,12 +24,16 @@ class GradesheetTest(unittest.TestCase):
scores.append(Score(earned=3, possible=5, graded=True, section="summary", module_id=None)) scores.append(Score(earned=3, possible=5, graded=True, section="summary", module_id=None))
all_total, graded_total = aggregate_scores(scores) all_total, graded_total = aggregate_scores(scores)
self.assertAlmostEqual(all_total, Score(earned=3, possible=10, graded=False, section="summary", module_id=None)) self.assertAlmostEqual(all_total, Score(earned=3, possible=10, graded=False, section="summary", module_id=None))
self.assertAlmostEqual(graded_total, Score(earned=3, possible=5, graded=True, section="summary", module_id=None)) self.assertAlmostEqual(
graded_total, Score(earned=3, possible=5, graded=True, section="summary", module_id=None)
)
scores.append(Score(earned=2, possible=5, graded=True, section="summary", module_id=None)) scores.append(Score(earned=2, possible=5, graded=True, section="summary", module_id=None))
all_total, graded_total = aggregate_scores(scores) all_total, graded_total = aggregate_scores(scores)
self.assertAlmostEqual(all_total, Score(earned=5, possible=15, graded=False, section="summary", module_id=None)) self.assertAlmostEqual(all_total, Score(earned=5, possible=15, graded=False, section="summary", module_id=None))
self.assertAlmostEqual(graded_total, Score(earned=5, possible=10, graded=True, section="summary", module_id=None)) self.assertAlmostEqual(
graded_total, Score(earned=5, possible=10, graded=True, section="summary", module_id=None)
)
class GraderTest(unittest.TestCase): class GraderTest(unittest.TestCase):
......
...@@ -703,7 +703,7 @@ class DataDownloadPage(PageObject): ...@@ -703,7 +703,7 @@ class DataDownloadPage(PageObject):
return self.q(css='a[data-section=data_download].active-section').present return self.q(css='a[data-section=data_download].active-section').present
@property @property
def generate_student_profile_report_button(self): def generate_student_report_button(self):
""" """
Returns the "Download profile information as a CSV" button. Returns the "Download profile information as a CSV" button.
""" """
...@@ -717,7 +717,7 @@ class DataDownloadPage(PageObject): ...@@ -717,7 +717,7 @@ class DataDownloadPage(PageObject):
return self.q(css='input[name=calculate-grades-csv]') return self.q(css='input[name=calculate-grades-csv]')
@property @property
def generate_problem_grade_report_button(self): def generate_problem_report_button(self):
""" """
Returns the "Generate Problem Grade Report" button. Returns the "Generate Problem Grade Report" button.
""" """
......
...@@ -320,24 +320,16 @@ class DataDownloadsTest(BaseInstructorDashboardTest): ...@@ -320,24 +320,16 @@ class DataDownloadsTest(BaseInstructorDashboardTest):
""" """
Verifies that the correct event is emitted when a report is requested. Verifies that the correct event is emitted when a report is requested.
""" """
self.verify_events_of_type( self.assert_matching_events_were_emitted(
self.instructor_username, event_filter={'name': u'edx.instructor.report.requested', 'report_type': report_type}
u"edx.instructor.report.requested",
[{
u"report_type": report_type
}]
) )
def verify_report_downloaded_event(self, report_url): def verify_report_downloaded_event(self, report_url):
""" """
Verifies that the correct event is emitted when a report is downloaded. Verifies that the correct event is emitted when a report is downloaded.
""" """
self.verify_events_of_type( self.assert_matching_events_were_emitted(
self.instructor_username, event_filter={'name': u'edx.instructor.report.downloaded', 'report_url': report_url}
u"edx.instructor.report.downloaded",
[{
u"report_url": report_url
}]
) )
def verify_report_download(self, report_name): def verify_report_download(self, report_name):
...@@ -364,7 +356,7 @@ class DataDownloadsTest(BaseInstructorDashboardTest): ...@@ -364,7 +356,7 @@ class DataDownloadsTest(BaseInstructorDashboardTest):
Then a report downloaded event should be emitted Then a report downloaded event should be emitted
""" """
report_name = u"student_profile_info" report_name = u"student_profile_info"
self.data_download_section.generate_student_profile_report_button.click() self.data_download_section.generate_student_report_button.click()
self.data_download_section.wait_for_available_report() self.data_download_section.wait_for_available_report()
self.verify_report_requested_event(report_name) self.verify_report_requested_event(report_name)
self.verify_report_download(report_name) self.verify_report_download(report_name)
...@@ -400,7 +392,7 @@ class DataDownloadsTest(BaseInstructorDashboardTest): ...@@ -400,7 +392,7 @@ class DataDownloadsTest(BaseInstructorDashboardTest):
Then a report downloaded event should be emitted Then a report downloaded event should be emitted
""" """
report_name = u"problem_grade_report" report_name = u"problem_grade_report"
self.data_download_section.generate_problem_grade_report_button.click() self.data_download_section.generate_problem_report_button.click()
self.data_download_section.wait_for_available_report() self.data_download_section.wait_for_available_report()
self.verify_report_requested_event(report_name) self.verify_report_requested_event(report_name)
self.verify_report_download(report_name) self.verify_report_download(report_name)
""" """
API implementation of the Course Structure API for Python code. API implementation of the Course Structure API for Python code.
Note: The course list and course detail functionality isn't currently supported here because of the tricky interactions between DRF and the code. Note: The course list and course detail functionality isn't currently supported here because
of the tricky interactions between DRF and the code.
Most of that information is available by accessing the course objects directly. Most of that information is available by accessing the course objects directly.
""" """
...@@ -62,8 +63,8 @@ def course_structure(course_key): ...@@ -62,8 +63,8 @@ def course_structure(course_key):
""" """
course = _retrieve_course(course_key) course = _retrieve_course(course_key)
try: try:
course_structure = models.CourseStructure.objects.get(course_id=course.id) requested_course_structure = models.CourseStructure.objects.get(course_id=course.id)
return serializers.CourseStructureSerializer(course_structure.structure).data return serializers.CourseStructureSerializer(requested_course_structure.structure).data
except models.CourseStructure.DoesNotExist: except models.CourseStructure.DoesNotExist:
# If we don't have data stored, generate it and return an error. # If we don't have data stored, generate it and return an error.
tasks.update_course_structure.delay(unicode(course_key)) tasks.update_course_structure.delay(unicode(course_key))
......
""" Errors used by the Course Structure API. """
class CourseNotFoundError(Exception): class CourseNotFoundError(Exception):
""" The course was not found. """ """ The course was not found. """
......
...@@ -15,7 +15,6 @@ from course_structure_api.v0 import api, serializers ...@@ -15,7 +15,6 @@ from course_structure_api.v0 import api, serializers
from course_structure_api.v0.errors import CourseNotFoundError, CourseStructureNotAvailableError from course_structure_api.v0.errors import CourseNotFoundError, CourseStructureNotAvailableError
from courseware import courses from courseware import courses
from courseware.access import has_access from courseware.access import has_access
from openedx.core.djangoapps.content.course_structures import models, tasks
from openedx.core.lib.api.permissions import IsAuthenticatedOrDebug from openedx.core.lib.api.permissions import IsAuthenticatedOrDebug
from openedx.core.lib.api.serializers import PaginationSerializer from openedx.core.lib.api.serializers import PaginationSerializer
from student.roles import CourseInstructorRole, CourseStaffRole from student.roles import CourseInstructorRole, CourseStaffRole
...@@ -253,7 +252,7 @@ class CourseStructure(CourseViewMixin, RetrieveAPIView): ...@@ -253,7 +252,7 @@ class CourseStructure(CourseViewMixin, RetrieveAPIView):
""" """
@CourseViewMixin.course_check @CourseViewMixin.course_check
def get(self, request, course_id=None): def get(self, request, **kwargs):
try: try:
return Response(api.course_structure(self.course_key)) return Response(api.course_structure(self.course_key))
except CourseStructureNotAvailableError: except CourseStructureNotAvailableError:
...@@ -289,5 +288,5 @@ class CourseGradingPolicy(CourseViewMixin, ListAPIView): ...@@ -289,5 +288,5 @@ class CourseGradingPolicy(CourseViewMixin, ListAPIView):
allow_empty = False allow_empty = False
@CourseViewMixin.course_check @CourseViewMixin.course_check
def get(self, request, course_id=None): def get(self, request, **kwargs):
return Response(api.course_grading_policy(self.course_key)) return Response(api.course_grading_policy(self.course_key))
...@@ -229,7 +229,13 @@ def _grade(student, request, course, keep_raw_scores): ...@@ -229,7 +229,13 @@ def _grade(student, request, course, keep_raw_scores):
graded = False graded = False
scores.append( scores.append(
Score(correct, total, graded, module_descriptor.display_name_with_default, module_descriptor.location) Score(
correct,
total,
graded,
module_descriptor.display_name_with_default,
module_descriptor.location
)
) )
_, graded_total = graders.aggregate_scores(scores, section_name) _, graded_total = graders.aggregate_scores(scores, section_name)
......
...@@ -68,7 +68,7 @@ class TestGradeIteration(ModuleStoreTestCase): ...@@ -68,7 +68,7 @@ class TestGradeIteration(ModuleStoreTestCase):
def test_all_empty_grades(self): def test_all_empty_grades(self):
"""No students have grade entries""" """No students have grade entries"""
all_gradesets, all_errors = self._gradesets_and_errors_for(self.course.id, self.students, keep_raw_scores=True) all_gradesets, all_errors = self._gradesets_and_errors_for(self.course.id, self.students)
self.assertEqual(len(all_errors), 0) self.assertEqual(len(all_errors), 0)
for gradeset in all_gradesets.values(): for gradeset in all_gradesets.values():
self.assertIsNone(gradeset['grade']) self.assertIsNone(gradeset['grade'])
...@@ -107,7 +107,7 @@ class TestGradeIteration(ModuleStoreTestCase): ...@@ -107,7 +107,7 @@ class TestGradeIteration(ModuleStoreTestCase):
self.assertTrue(all_gradesets[student5]) self.assertTrue(all_gradesets[student5])
################################# Helpers ################################# ################################# Helpers #################################
def _gradesets_and_errors_for(self, course_id, students, keep_raw_scores=False): def _gradesets_and_errors_for(self, course_id, students):
"""Simple helper method to iterate through student grades and give us """Simple helper method to iterate through student grades and give us
two dictionaries -- one that has all students and their respective two dictionaries -- one that has all students and their respective
gradesets, and one that has only students that could not be graded and gradesets, and one that has only students that could not be graded and
...@@ -115,7 +115,7 @@ class TestGradeIteration(ModuleStoreTestCase): ...@@ -115,7 +115,7 @@ class TestGradeIteration(ModuleStoreTestCase):
students_to_gradesets = {} students_to_gradesets = {}
students_to_errors = {} students_to_errors = {}
for student, gradeset, err_msg in iterate_grades_for(course_id, students, keep_raw_scores): for student, gradeset, err_msg in iterate_grades_for(course_id, students):
students_to_gradesets[student] = gradeset students_to_gradesets[student] = gradeset
if err_msg: if err_msg:
students_to_errors[student] = err_msg students_to_errors[student] = err_msg
......
...@@ -15,10 +15,11 @@ from django_comment_client.tests.group_id import ( ...@@ -15,10 +15,11 @@ from django_comment_client.tests.group_id import (
NonCohortedTopicGroupIdTestMixin NonCohortedTopicGroupIdTestMixin
) )
from django_comment_client.tests.unicode import UnicodeTestMixin from django_comment_client.tests.unicode import UnicodeTestMixin
from django_comment_client.tests.utils import CohortedTestCase, ContentGroupTestCase from django_comment_client.tests.utils import CohortedTestCase
from django_comment_client.utils import strip_none from django_comment_client.utils import strip_none
from student.tests.factories import UserFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory
from util.testing import UrlResetMixin from util.testing import UrlResetMixin
from openedx.core.djangoapps.util.testing import ContentGroupTestCase
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ( from xmodule.modulestore.tests.django_utils import (
......
...@@ -15,12 +15,12 @@ from edxmako import add_lookup ...@@ -15,12 +15,12 @@ from edxmako import add_lookup
from django_comment_client.tests.factories import RoleFactory from django_comment_client.tests.factories import RoleFactory
from django_comment_client.tests.unicode import UnicodeTestMixin from django_comment_client.tests.unicode import UnicodeTestMixin
from django_comment_client.tests.utils import ContentGroupTestCase
import django_comment_client.utils as utils import django_comment_client.utils as utils
from courseware.tests.factories import InstructorFactory from courseware.tests.factories import InstructorFactory
from openedx.core.djangoapps.course_groups.cohorts import set_course_cohort_settings from openedx.core.djangoapps.course_groups.cohorts import set_course_cohort_settings
from student.tests.factories import UserFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory
from openedx.core.djangoapps.util.testing import ContentGroupTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
......
""" """
Utilities for tests within the django_comment_client module. Utilities for tests within the django_comment_client module.
""" """
from datetime import datetime
from mock import patch from mock import patch
from pytz import UTC
from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
from django_comment_common.models import Role from django_comment_common.models import Role
from django_comment_common.utils import seed_permissions_roles from django_comment_common.utils import seed_permissions_roles
from student.tests.factories import CourseEnrollmentFactory, UserFactory from student.tests.factories import CourseEnrollmentFactory, UserFactory
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.partitions.partitions import UserPartition, Group
class CohortedTestCase(ModuleStoreTestCase): class CohortedTestCase(ModuleStoreTestCase):
...@@ -49,91 +45,3 @@ class CohortedTestCase(ModuleStoreTestCase): ...@@ -49,91 +45,3 @@ class CohortedTestCase(ModuleStoreTestCase):
self.moderator.roles.add(Role.objects.get(name="Moderator", course_id=self.course.id)) self.moderator.roles.add(Role.objects.get(name="Moderator", course_id=self.course.id))
self.student_cohort.users.add(self.student) self.student_cohort.users.add(self.student)
self.moderator_cohort.users.add(self.moderator) self.moderator_cohort.users.add(self.moderator)
class ContentGroupTestCase(ModuleStoreTestCase):
"""
Sets up discussion modules visible to content groups 'Alpha' and
'Beta', as well as a module visible to all students. Creates a
staff user, users with access to Alpha/Beta (by way of cohorts),
and a non-cohorted user with no special access.
"""
def setUp(self):
super(ContentGroupTestCase, self).setUp()
self.course = CourseFactory.create(
org='org', number='number', run='run',
# This test needs to use a course that has already started --
# discussion topics only show up if the course has already started,
# and the default start date for courses is Jan 1, 2030.
start=datetime(2012, 2, 3, tzinfo=UTC),
user_partitions=[
UserPartition(
0,
'Content Group Configuration',
'',
[Group(1, 'Alpha'), Group(2, 'Beta')],
scheme_id='cohort'
)
],
grading_policy={
"GRADER": [{
"type": "Homework",
"min_count": 1,
"drop_count": 0,
"short_label": "HW",
"weight": 1.0
}]
},
cohort_config={'cohorted': True},
discussion_topics={}
)
self.staff_user = UserFactory.create(is_staff=True)
self.alpha_user = UserFactory.create()
self.beta_user = UserFactory.create()
self.non_cohorted_user = UserFactory.create()
for user in [self.staff_user, self.alpha_user, self.beta_user, self.non_cohorted_user]:
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
alpha_cohort = CohortFactory(
course_id=self.course.id,
name='Cohort Alpha',
users=[self.alpha_user]
)
beta_cohort = CohortFactory(
course_id=self.course.id,
name='Cohort Beta',
users=[self.beta_user]
)
CourseUserGroupPartitionGroup.objects.create(
course_user_group=alpha_cohort,
partition_id=self.course.user_partitions[0].id,
group_id=self.course.user_partitions[0].groups[0].id
)
CourseUserGroupPartitionGroup.objects.create(
course_user_group=beta_cohort,
partition_id=self.course.user_partitions[0].id,
group_id=self.course.user_partitions[0].groups[1].id
)
self.alpha_module = ItemFactory.create(
parent_location=self.course.location,
category='discussion',
discussion_id='alpha_group_discussion',
discussion_target='Visible to Alpha',
group_access={self.course.user_partitions[0].id: [self.course.user_partitions[0].groups[0].id]}
)
self.beta_module = ItemFactory.create(
parent_location=self.course.location,
category='discussion',
discussion_id='beta_group_discussion',
discussion_target='Visible to Beta',
group_access={self.course.user_partitions[0].id: [self.course.user_partitions[0].groups[1].id]}
)
self.global_module = ItemFactory.create(
parent_location=self.course.location,
category='discussion',
discussion_id='global_group_discussion',
discussion_target='Visible to Everyone'
)
self.course = self.store.get_item(self.course.location)
...@@ -556,12 +556,7 @@ def upload_csv_to_report_store(rows, csv_name, course_id, timestamp): ...@@ -556,12 +556,7 @@ def upload_csv_to_report_store(rows, csv_name, course_id, timestamp):
), ),
rows rows
) )
tracker.emit( tracker.emit(REPORT_REQUESTED_EVENT_NAME, {"report_type": csv_name, })
REPORT_REQUESTED_EVENT_NAME,
{
"report_type": csv_name,
}
)
def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input, action_name): # pylint: disable=too-many-statements def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input, action_name): # pylint: disable=too-many-statements
...@@ -721,6 +716,14 @@ def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input, ...@@ -721,6 +716,14 @@ def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input,
def _order_problems(blocks): def _order_problems(blocks):
""" """
Sort the problems by the assignment type and assignment that it belongs to. Sort the problems by the assignment type and assignment that it belongs to.
Args:
blocks (OrderedDict) - A course structure containing blocks that have been ordered
(i.e. when we iterate over them, we will see them in the order
that they appear in the course).
Returns:
an OrderedDict that maps a problem id to its headers in the final report.
""" """
problems = OrderedDict() problems = OrderedDict()
assignments = dict() assignments = dict()
...@@ -749,7 +752,7 @@ def _order_problems(blocks): ...@@ -749,7 +752,7 @@ def _order_problems(blocks):
for assignment_type in assignments: for assignment_type in assignments:
for assignment_index, assignment in enumerate(assignments[assignment_type].keys(), start=1): for assignment_index, assignment in enumerate(assignments[assignment_type].keys(), start=1):
for problem in assignments[assignment_type][assignment]: for problem in assignments[assignment_type][assignment]:
header_name = "{assignment_type} {assignment_index}: {assignment_name} - {block}".format( header_name = u"{assignment_type} {assignment_index}: {assignment_name} - {block}".format(
block=blocks[problem]['display_name'], block=blocks[problem]['display_name'],
assignment_type=assignment_type, assignment_type=assignment_type,
assignment_index=assignment_index, assignment_index=assignment_index,
...@@ -771,10 +774,9 @@ def upload_problem_grade_report(_xmodule_instance_args, _entry_id, course_id, _t ...@@ -771,10 +774,9 @@ def upload_problem_grade_report(_xmodule_instance_args, _entry_id, course_id, _t
enrolled_students = CourseEnrollment.users_enrolled_in(course_id) enrolled_students = CourseEnrollment.users_enrolled_in(course_id)
task_progress = TaskProgress(action_name, enrolled_students.count(), start_time) task_progress = TaskProgress(action_name, enrolled_students.count(), start_time)
# This struct encapsulates both the display names of each static item in # This struct encapsulates both the display names of each static item in the
# the header row as values as well as the django User field names of those # header row as values as well as the django User field names of those items
# items as the keys. It is structured in this way to keep the values # as the keys. It is structured in this way to keep the values related.
# related.
header_row = OrderedDict([('id', 'Student ID'), ('email', 'Email'), ('username', 'Username')]) header_row = OrderedDict([('id', 'Student ID'), ('email', 'Email'), ('username', 'Username')])
try: try:
...@@ -782,14 +784,25 @@ def upload_problem_grade_report(_xmodule_instance_args, _entry_id, course_id, _t ...@@ -782,14 +784,25 @@ def upload_problem_grade_report(_xmodule_instance_args, _entry_id, course_id, _t
blocks = course_structure.ordered_blocks blocks = course_structure.ordered_blocks
problems = _order_problems(blocks) problems = _order_problems(blocks)
except CourseStructure.DoesNotExist: except CourseStructure.DoesNotExist:
return task_progress.update_task_state(extra_meta={'step': 'Generating course structure. Please refresh and try again.'}) return task_progress.update_task_state(
extra_meta={'step': 'Generating course structure. Please refresh and try again.'}
)
# Just generate the static fields for now. # Just generate the static fields for now.
rows = [list(header_row.values()) + ['Final Grade'] + list(chain.from_iterable(problems.values()))] rows = [list(header_row.values()) + ['Final Grade'] + list(chain.from_iterable(problems.values()))]
error_rows = [list(header_row.values()) + ['error_msg']]
current_step = {'step': 'Calculating Grades'} current_step = {'step': 'Calculating Grades'}
for student, gradeset, err_msg in iterate_grades_for(course_id, enrolled_students, keep_raw_scores=True): for student, gradeset, err_msg in iterate_grades_for(course_id, enrolled_students, keep_raw_scores=True):
student_fields = [getattr(student, field_name) for field_name in header_row] student_fields = [getattr(student, field_name) for field_name in header_row]
task_progress.attempted += 1
if err_msg:
# There was an error grading this student.
error_rows.append(student_fields + [err_msg])
task_progress.failed += 1
continue
final_grade = gradeset['percent'] final_grade = gradeset['percent']
# Only consider graded problems # Only consider graded problems
problem_scores = {unicode(score.module_id): score for score in gradeset['raw_scores'] if score.graded} problem_scores = {unicode(score.module_id): score for score in gradeset['raw_scores'] if score.graded}
...@@ -807,13 +820,17 @@ def upload_problem_grade_report(_xmodule_instance_args, _entry_id, course_id, _t ...@@ -807,13 +820,17 @@ def upload_problem_grade_report(_xmodule_instance_args, _entry_id, course_id, _t
earned_possible_values.append(['N/A', 'N/A']) earned_possible_values.append(['N/A', 'N/A'])
rows.append(student_fields + [final_grade] + list(chain.from_iterable(earned_possible_values))) rows.append(student_fields + [final_grade] + list(chain.from_iterable(earned_possible_values)))
task_progress.attempted += 1
task_progress.succeeded += 1 task_progress.succeeded += 1
if task_progress.attempted % status_interval == 0: if task_progress.attempted % status_interval == 0:
task_progress.update_task_state(extra_meta=current_step) task_progress.update_task_state(extra_meta=current_step)
# Perform the upload # Perform the upload if any students have been successfully graded
upload_csv_to_report_store(rows, 'problem_grade_report', course_id, start_date) if len(rows) > 1:
upload_csv_to_report_store(rows, 'problem_grade_report', course_id, start_date)
# If there are any error rows, write them out as well
if len(error_rows) > 1:
upload_csv_to_report_store(error_rows, 'problem_grade_report_err', course_id, start_date)
return task_progress.update_task_state(extra_meta={'step': 'Uploading CSV'}) return task_progress.update_task_state(extra_meta={'step': 'Uploading CSV'})
......
...@@ -130,6 +130,9 @@ class InstructorTaskCourseTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase) ...@@ -130,6 +130,9 @@ class InstructorTaskCourseTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase)
self.add_course_content() self.add_course_content()
def add_course_content(self): def add_course_content(self):
"""
Add a chapter and a sequential to the current course.
"""
# Add a chapter to the course # Add a chapter to the course
chapter = ItemFactory.create(parent_location=self.course.location, chapter = ItemFactory.create(parent_location=self.course.location,
display_name=TEST_SECTION_NAME) display_name=TEST_SECTION_NAME)
...@@ -217,7 +220,7 @@ class InstructorTaskModuleTestCase(InstructorTaskCourseTestCase): ...@@ -217,7 +220,7 @@ class InstructorTaskModuleTestCase(InstructorTaskCourseTestCase):
ItemFactory.create(parent_location=parent.location, ItemFactory.create(parent_location=parent.location,
parent=parent, parent=parent,
category="problem", category="problem",
display_name=str(problem_url_name), display_name=problem_url_name,
data=problem_xml, data=problem_xml,
**kwargs) **kwargs)
...@@ -256,9 +259,12 @@ class InstructorTaskModuleTestCase(InstructorTaskCourseTestCase): ...@@ -256,9 +259,12 @@ class InstructorTaskModuleTestCase(InstructorTaskCourseTestCase):
# URL, modified so that it can be easily stored in html, prepended with "input-" and # URL, modified so that it can be easily stored in html, prepended with "input-" and
# appended with a sequence identifier for the particular response the input goes to. # appended with a sequence identifier for the particular response the input goes to.
course_key = self.course.id course_key = self.course.id
return 'input_i4x-{0}-{1}-problem-{2}_{3}'.format(course_key.org, return u'input_i4x-{0}-{1}-problem-{2}_{3}'.format(
course_key.course.replace('.', '_'), course_key.org.replace(u'.', u'_'),
problem_url_name, response_id) course_key.course.replace(u'.', u'_'),
problem_url_name,
response_id
)
# make sure that the requested user is logged in, so that the ajax call works # make sure that the requested user is logged in, so that the ajax call works
# on the right problem: # on the right problem:
...@@ -275,7 +281,7 @@ class InstructorTaskModuleTestCase(InstructorTaskCourseTestCase): ...@@ -275,7 +281,7 @@ class InstructorTaskModuleTestCase(InstructorTaskCourseTestCase):
# assign correct identifier to each response. # assign correct identifier to each response.
resp = self.client.post(modx_url, { resp = self.client.post(modx_url, {
get_input_id('{}_1').format(index): response for index, response in enumerate(responses, 2) get_input_id(u'{}_1').format(index): response for index, response in enumerate(responses, 2)
}) })
return resp return resp
...@@ -289,7 +295,7 @@ class TestReportMixin(object): ...@@ -289,7 +295,7 @@ class TestReportMixin(object):
if os.path.exists(reports_download_path): if os.path.exists(reports_download_path):
shutil.rmtree(reports_download_path) shutil.rmtree(reports_download_path)
def verify_rows_in_csv(self, expected_rows, verify_order=True, ignore_other_columns=False): def verify_rows_in_csv(self, expected_rows, file_index=0, verify_order=True, ignore_other_columns=False):
""" """
Verify that the last ReportStore CSV contains the expected content. Verify that the last ReportStore CSV contains the expected content.
...@@ -298,6 +304,9 @@ class TestReportMixin(object): ...@@ -298,6 +304,9 @@ class TestReportMixin(object):
where each dict represents a row of data in the last where each dict represents a row of data in the last
ReportStore CSV. Each dict maps keys from the CSV ReportStore CSV. Each dict maps keys from the CSV
header to values in that row's corresponding cell. header to values in that row's corresponding cell.
file_index (int): Describes which report store file to
open. Files are ordered by last modified date, and 0
corresponds to the most recently modified file.
verify_order (boolean): When True, we verify that both the verify_order (boolean): When True, we verify that both the
content and order of `expected_rows` matches the content and order of `expected_rows` matches the
actual csv rows. When False (default), we only verify actual csv rows. When False (default), we only verify
...@@ -306,7 +315,7 @@ class TestReportMixin(object): ...@@ -306,7 +315,7 @@ class TestReportMixin(object):
contain data which is the subset of actual csv rows. contain data which is the subset of actual csv rows.
""" """
report_store = ReportStore.from_config() report_store = ReportStore.from_config()
report_csv_filename = report_store.links_for(self.course.id)[0][0] report_csv_filename = report_store.links_for(self.course.id)[file_index][0]
with open(report_store.path_to(self.course.id, report_csv_filename)) as csv_file: with open(report_store.path_to(self.course.id, report_csv_filename)) as csv_file:
# Expand the dict reader generator so we don't lose it's content # Expand the dict reader generator so we don't lose it's content
csv_rows = [row for row in unicodecsv.DictReader(csv_file)] csv_rows = [row for row in unicodecsv.DictReader(csv_file)]
......
...@@ -14,9 +14,9 @@ from celery.states import SUCCESS, FAILURE ...@@ -14,9 +14,9 @@ from celery.states import SUCCESS, FAILURE
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from openedx.core.djangoapps.util.testing import TestConditionalContent
from capa.tests.response_xml_factory import (CodeResponseXMLFactory, from capa.tests.response_xml_factory import (CodeResponseXMLFactory,
CustomResponseXMLFactory) CustomResponseXMLFactory)
from openedx.core.djangoapps.user_api.tests.factories import UserCourseTagFactory
from xmodule.modulestore.tests.factories import ItemFactory from xmodule.modulestore.tests.factories import ItemFactory
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.partitions.partitions import Group, UserPartition from xmodule.partitions.partitions import Group, UserPartition
...@@ -28,7 +28,7 @@ from instructor_task.api import (submit_rescore_problem_for_all_students, ...@@ -28,7 +28,7 @@ from instructor_task.api import (submit_rescore_problem_for_all_students,
submit_reset_problem_attempts_for_all_students, submit_reset_problem_attempts_for_all_students,
submit_delete_problem_state_for_all_students) submit_delete_problem_state_for_all_students)
from instructor_task.models import InstructorTask from instructor_task.models import InstructorTask
from instructor_task.tasks_helper import upload_grades_csv, upload_problem_grade_report from instructor_task.tasks_helper import upload_grades_csv
from instructor_task.tests.test_base import (InstructorTaskModuleTestCase, TestReportMixin, TEST_COURSE_ORG, from instructor_task.tests.test_base import (InstructorTaskModuleTestCase, TestReportMixin, TEST_COURSE_ORG,
TEST_COURSE_NUMBER, OPTION_1, OPTION_2) TEST_COURSE_NUMBER, OPTION_1, OPTION_2)
from capa.responsetypes import StudentInputError from capa.responsetypes import StudentInputError
...@@ -465,103 +465,10 @@ class TestDeleteProblemTask(TestIntegrationTask): ...@@ -465,103 +465,10 @@ class TestDeleteProblemTask(TestIntegrationTask):
self.assertEqual(instructor_task.task_state, SUCCESS) self.assertEqual(instructor_task.task_state, SUCCESS)
class TestGradeReportConditionalContent(TestReportMixin, TestIntegrationTask): class TestGradeReportConditionalContent(TestReportMixin, TestConditionalContent, TestIntegrationTask):
""" """
Check that grade export works when graded content exists within Test grade report in cases where there are problems contained within split tests.
split modules.
""" """
def setUp(self):
"""
Set up a course with graded problems within a split test.
Course hierarchy is as follows (modeled after how split tests
are created in studio):
-> course
-> chapter
-> sequential (graded)
-> vertical
-> split_test
-> vertical (Group A)
-> problem
-> vertical (Group B)
-> problem
"""
super(TestGradeReportConditionalContent, self).setUp()
# Create user partitions
self.user_partition_group_a = 0
self.user_partition_group_b = 1
self.partition = UserPartition(
0,
'first_partition',
'First Partition',
[
Group(self.user_partition_group_a, 'Group A'),
Group(self.user_partition_group_b, 'Group B')
]
)
# Create course with group configurations and grading policy
self.initialize_course(
course_factory_kwargs={
'user_partitions': [self.partition],
'grading_policy': {
"GRADER": [{
"type": "Homework",
"min_count": 1,
"drop_count": 0,
"short_label": "HW",
"weight": 1.0
}]
}
}
)
# Create users and partition them
self.student_a = self.create_student('student_a')
self.student_b = self.create_student('student_b')
UserCourseTagFactory(
user=self.student_a,
course_id=self.course.id,
key='xblock.partition_service.partition_{0}'.format(self.partition.id), # pylint: disable=no-member
value=str(self.user_partition_group_a)
)
UserCourseTagFactory(
user=self.student_b,
course_id=self.course.id,
key='xblock.partition_service.partition_{0}'.format(self.partition.id), # pylint: disable=no-member
value=str(self.user_partition_group_b)
)
# Create a vertical to contain our split test
problem_vertical = ItemFactory.create(
parent_location=self.problem_section.location,
category='vertical',
display_name='Problem Unit'
)
# Create the split test and child vertical containers
vertical_a_url = self.course.id.make_usage_key('vertical', 'split_test_vertical_a')
vertical_b_url = self.course.id.make_usage_key('vertical', 'split_test_vertical_b')
self.split_test = ItemFactory.create(
parent_location=problem_vertical.location,
category='split_test',
display_name='Split Test',
user_partition_id=self.partition.id, # pylint: disable=no-member
group_id_to_child={str(index): url for index, url in enumerate([vertical_a_url, vertical_b_url])}
)
self.vertical_a = ItemFactory.create(
parent_location=self.split_test.location,
category='vertical',
display_name='Group A problem container',
location=vertical_a_url
)
self.vertical_b = ItemFactory.create(
parent_location=self.split_test.location,
category='vertical',
display_name='Group B problem container',
location=vertical_b_url
)
def verify_csv_task_success(self, task_result): def verify_csv_task_success(self, task_result):
""" """
......
...@@ -29,6 +29,9 @@ class CourseStructure(TimeStampedModel): ...@@ -29,6 +29,9 @@ class CourseStructure(TimeStampedModel):
@property @property
def ordered_blocks(self): def ordered_blocks(self):
"""
Return the blocks in the order with which they're seen in the courseware. Parents are ordered before children.
"""
if self.structure: if self.structure:
ordered_blocks = OrderedDict() ordered_blocks = OrderedDict()
self._traverse_tree(self.structure['root'], self.structure['blocks'], ordered_blocks) self._traverse_tree(self.structure['root'], self.structure['blocks'], ordered_blocks)
......
...@@ -68,8 +68,8 @@ class CourseStructureTaskTests(ModuleStoreTestCase): ...@@ -68,8 +68,8 @@ class CourseStructureTaskTests(ModuleStoreTestCase):
} }
} }
structure_json = json.dumps(structure) structure_json = json.dumps(structure)
cs = CourseStructure.objects.create(course_id=self.course.id, structure_json=structure_json) structure = CourseStructure.objects.create(course_id=self.course.id, structure_json=structure_json)
self.assertEqual(cs.structure_json, structure_json) self.assertEqual(structure.structure_json, structure_json)
# Reload the data to ensure the init signal is fired to decompress the data. # Reload the data to ensure the init signal is fired to decompress the data.
cs = CourseStructure.objects.get(course_id=self.course.id) cs = CourseStructure.objects.get(course_id=self.course.id)
...@@ -91,7 +91,6 @@ class CourseStructureTaskTests(ModuleStoreTestCase): ...@@ -91,7 +91,6 @@ class CourseStructureTaskTests(ModuleStoreTestCase):
cs = CourseStructure.objects.create(course_id=self.course.id, structure_json=structure_json) cs = CourseStructure.objects.create(course_id=self.course.id, structure_json=structure_json)
self.assertDictEqual(cs.structure, structure) self.assertDictEqual(cs.structure, structure)
def test_ordered_blocks(self): def test_ordered_blocks(self):
structure = { structure = {
'root': 'a/b/c', 'root': 'a/b/c',
...@@ -121,9 +120,11 @@ class CourseStructureTaskTests(ModuleStoreTestCase): ...@@ -121,9 +120,11 @@ class CourseStructureTaskTests(ModuleStoreTestCase):
} }
in_order_blocks = ['a/b/c', 'g/h/i', 'j/k/l', 'd/e/f'] in_order_blocks = ['a/b/c', 'g/h/i', 'j/k/l', 'd/e/f']
structure_json = json.dumps(structure) structure_json = json.dumps(structure)
cs = CourseStructure.objects.create(course_id=self.course.id, structure_json=structure_json) retrieved_course_structure = CourseStructure.objects.create(
course_id=self.course.id, structure_json=structure_json
)
self.assertEqual(cs.ordered_blocks.keys(), in_order_blocks) self.assertEqual(retrieved_course_structure.ordered_blocks.keys(), in_order_blocks)
def test_block_with_missing_fields(self): def test_block_with_missing_fields(self):
""" """
......
""" Mixins for setting up particular course structures (such as split tests or cohorted content) """
from datetime import datetime
from pytz import UTC
from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
from openedx.core.djangoapps.user_api.tests.factories import UserCourseTagFactory
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.partitions.partitions import UserPartition, Group
from student.tests.factories import CourseEnrollmentFactory, UserFactory
class ContentGroupTestCase(ModuleStoreTestCase):
"""
Sets up discussion modules visible to content groups 'Alpha' and
'Beta', as well as a module visible to all students. Creates a
staff user, users with access to Alpha/Beta (by way of cohorts),
and a non-cohorted user with no special access.
"""
def setUp(self):
super(ContentGroupTestCase, self).setUp()
self.course = CourseFactory.create(
org='org', number='number', run='run',
# This test needs to use a course that has already started --
# discussion topics only show up if the course has already started,
# and the default start date for courses is Jan 1, 2030.
start=datetime(2012, 2, 3, tzinfo=UTC),
user_partitions=[
UserPartition(
0,
'Content Group Configuration',
'',
[Group(1, 'Alpha'), Group(2, 'Beta')],
scheme_id='cohort'
)
],
grading_policy={
"GRADER": [{
"type": "Homework",
"min_count": 1,
"drop_count": 0,
"short_label": "HW",
"weight": 1.0
}]
},
cohort_config={'cohorted': True},
discussion_topics={}
)
self.staff_user = UserFactory.create(is_staff=True)
self.alpha_user = UserFactory.create()
self.beta_user = UserFactory.create()
self.non_cohorted_user = UserFactory.create()
for user in [self.staff_user, self.alpha_user, self.beta_user, self.non_cohorted_user]:
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
alpha_cohort = CohortFactory(
course_id=self.course.id,
name='Cohort Alpha',
users=[self.alpha_user]
)
beta_cohort = CohortFactory(
course_id=self.course.id,
name='Cohort Beta',
users=[self.beta_user]
)
CourseUserGroupPartitionGroup.objects.create(
course_user_group=alpha_cohort,
partition_id=self.course.user_partitions[0].id,
group_id=self.course.user_partitions[0].groups[0].id
)
CourseUserGroupPartitionGroup.objects.create(
course_user_group=beta_cohort,
partition_id=self.course.user_partitions[0].id,
group_id=self.course.user_partitions[0].groups[1].id
)
self.alpha_module = ItemFactory.create(
parent_location=self.course.location,
category='discussion',
discussion_id='alpha_group_discussion',
discussion_target='Visible to Alpha',
group_access={self.course.user_partitions[0].id: [self.course.user_partitions[0].groups[0].id]}
)
self.beta_module = ItemFactory.create(
parent_location=self.course.location,
category='discussion',
discussion_id='beta_group_discussion',
discussion_target='Visible to Beta',
group_access={self.course.user_partitions[0].id: [self.course.user_partitions[0].groups[1].id]}
)
self.global_module = ItemFactory.create(
parent_location=self.course.location,
category='discussion',
discussion_id='global_group_discussion',
discussion_target='Visible to Everyone'
)
self.course = self.store.get_item(self.course.location)
class TestConditionalContent(ModuleStoreTestCase):
"""
Construct a course with graded problems that exist within a split test.
"""
TEST_SECTION_NAME = 'Problem'
def setUp(self):
"""
Set up a course with graded problems within a split test.
Course hierarchy is as follows (modeled after how split tests
are created in studio):
-> course
-> chapter
-> sequential (graded)
-> vertical
-> split_test
-> vertical (Group A)
-> problem
-> vertical (Group B)
-> problem
"""
super(TestConditionalContent, self).setUp()
# Create user partitions
self.user_partition_group_a = 0
self.user_partition_group_b = 1
self.partition = UserPartition(
0,
'first_partition',
'First Partition',
[
Group(self.user_partition_group_a, 'Group A'),
Group(self.user_partition_group_b, 'Group B')
]
)
# Create course with group configurations and grading policy
self.course = CourseFactory.create(
user_partitions=[self.partition],
grading_policy={
"GRADER": [{
"type": "Homework",
"min_count": 1,
"drop_count": 0,
"short_label": "HW",
"weight": 1.0
}]
}
)
chapter = ItemFactory.create(parent_location=self.course.location,
display_name='Chapter')
# add a sequence to the course to which the problems can be added
self.problem_section = ItemFactory.create(parent_location=chapter.location,
category='sequential',
metadata={'graded': True, 'format': 'Homework'},
display_name=self.TEST_SECTION_NAME)
# Create users and partition them
self.student_a = UserFactory.create(username='student_a', email='student_a@example.com')
CourseEnrollmentFactory.create(user=self.student_a, course_id=self.course.id)
self.student_b = UserFactory.create(username='student_b', email='student_b@example.com')
CourseEnrollmentFactory.create(user=self.student_b, course_id=self.course.id)
UserCourseTagFactory(
user=self.student_a,
course_id=self.course.id,
key='xblock.partition_service.partition_{0}'.format(self.partition.id), # pylint: disable=no-member
value=str(self.user_partition_group_a)
)
UserCourseTagFactory(
user=self.student_b,
course_id=self.course.id,
key='xblock.partition_service.partition_{0}'.format(self.partition.id), # pylint: disable=no-member
value=str(self.user_partition_group_b)
)
# Create a vertical to contain our split test
problem_vertical = ItemFactory.create(
parent_location=self.problem_section.location,
category='vertical',
display_name='Problem Unit'
)
# Create the split test and child vertical containers
vertical_a_url = self.course.id.make_usage_key('vertical', 'split_test_vertical_a')
vertical_b_url = self.course.id.make_usage_key('vertical', 'split_test_vertical_b')
self.split_test = ItemFactory.create(
parent_location=problem_vertical.location,
category='split_test',
display_name='Split Test',
user_partition_id=self.partition.id, # pylint: disable=no-member
group_id_to_child={str(index): url for index, url in enumerate([vertical_a_url, vertical_b_url])}
)
self.vertical_a = ItemFactory.create(
parent_location=self.split_test.location,
category='vertical',
display_name='Group A problem container',
location=vertical_a_url
)
self.vertical_b = ItemFactory.create(
parent_location=self.split_test.location,
category='vertical',
display_name='Group B problem container',
location=vertical_b_url
)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment