Commit 63f4aa8c by Muhammad Ammar

Merge pull request #7761 from edx/ammar/tnl1886-add-certificate-columns-in-grade-report

Add certificate columns in grade report
parents 4bd15cf2 3bb7a250
...@@ -41,6 +41,7 @@ class UserProfileFactory(DjangoModelFactory): ...@@ -41,6 +41,7 @@ class UserProfileFactory(DjangoModelFactory):
gender = u'm' gender = u'm'
mailing_address = None mailing_address = None
goals = u'Learn a lot' goals = u'Learn a lot'
allow_certificate = True
class CourseModeFactory(DjangoModelFactory): class CourseModeFactory(DjangoModelFactory):
......
...@@ -187,6 +187,33 @@ def certificate_status_for_student(student, course_id): ...@@ -187,6 +187,33 @@ def certificate_status_for_student(student, course_id):
return {'status': CertificateStatuses.unavailable, 'mode': GeneratedCertificate.MODES.honor} return {'status': CertificateStatuses.unavailable, 'mode': GeneratedCertificate.MODES.honor}
def certificate_info_for_user(user, course_id, grade, user_is_whitelisted=None):
"""
Returns the certificate info for a user for grade report.
"""
if user_is_whitelisted is None:
user_is_whitelisted = CertificateWhitelist.objects.filter(
user=user, course_id=course_id, whitelist=True
).exists()
eligible_for_certificate = (user_is_whitelisted or grade is not None) and user.profile.allow_certificate
if eligible_for_certificate:
user_is_eligible = 'Y'
certificate_status = certificate_status_for_student(user, course_id)
certificate_generated = certificate_status['status'] == CertificateStatuses.downloadable
certificate_is_delivered = 'Y' if certificate_generated else 'N'
certificate_type = certificate_status['mode'] if certificate_generated else 'N/A'
else:
user_is_eligible = 'N'
certificate_is_delivered = 'N'
certificate_type = 'N/A'
return [user_is_eligible, certificate_is_delivered, certificate_type]
class ExampleCertificateSet(TimeStampedModel): class ExampleCertificateSet(TimeStampedModel):
"""A set of example certificates. """A set of example certificates.
......
from factory.django import DjangoModelFactory from factory.django import DjangoModelFactory
from certificates.models import GeneratedCertificate, CertificateStatuses, CertificateHtmlViewConfiguration from certificates.models import (
GeneratedCertificate, CertificateStatuses, CertificateHtmlViewConfiguration, CertificateWhitelist
)
# Factories are self documenting # Factories are self documenting
...@@ -15,6 +17,14 @@ class GeneratedCertificateFactory(DjangoModelFactory): ...@@ -15,6 +17,14 @@ class GeneratedCertificateFactory(DjangoModelFactory):
name = '' name = ''
class CertificateWhitelistFactory(DjangoModelFactory):
FACTORY_FOR = CertificateWhitelist
course_id = None
whitelist = True
class CertificateHtmlViewConfigurationFactory(DjangoModelFactory): class CertificateHtmlViewConfigurationFactory(DjangoModelFactory):
FACTORY_FOR = CertificateHtmlViewConfiguration FACTORY_FOR = CertificateHtmlViewConfiguration
......
""" """
Tests for the certificates models. Tests for the certificates models.
""" """
from ddt import ddt, data, unpack
from mock import patch from mock import patch
from django.conf import settings from django.conf import settings
...@@ -9,7 +9,12 @@ from xmodule.modulestore.tests.factories import CourseFactory ...@@ -9,7 +9,12 @@ from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from certificates.models import CertificateStatuses, GeneratedCertificate, certificate_status_for_student from certificates.models import (
CertificateStatuses,
GeneratedCertificate,
certificate_status_for_student,
certificate_info_for_user
)
from certificates.tests.factories import GeneratedCertificateFactory from certificates.tests.factories import GeneratedCertificateFactory
from util.milestones_helpers import ( from util.milestones_helpers import (
...@@ -19,6 +24,7 @@ from util.milestones_helpers import ( ...@@ -19,6 +24,7 @@ from util.milestones_helpers import (
) )
@ddt
class CertificatesModelTest(ModuleStoreTestCase): class CertificatesModelTest(ModuleStoreTestCase):
""" """
Tests for the GeneratedCertificate model Tests for the GeneratedCertificate model
...@@ -32,6 +38,26 @@ class CertificatesModelTest(ModuleStoreTestCase): ...@@ -32,6 +38,26 @@ class CertificatesModelTest(ModuleStoreTestCase):
self.assertEqual(certificate_status['status'], CertificateStatuses.unavailable) self.assertEqual(certificate_status['status'], CertificateStatuses.unavailable)
self.assertEqual(certificate_status['mode'], GeneratedCertificate.MODES.honor) self.assertEqual(certificate_status['mode'], GeneratedCertificate.MODES.honor)
@unpack
@data(
{'allow_certificate': False, 'whitelisted': False, 'grade': None, 'output': ['N', 'N', 'N/A']},
{'allow_certificate': True, 'whitelisted': True, 'grade': None, 'output': ['Y', 'N', 'N/A']},
{'allow_certificate': True, 'whitelisted': False, 'grade': 0.9, 'output': ['Y', 'N', 'N/A']},
{'allow_certificate': False, 'whitelisted': True, 'grade': 0.8, 'output': ['N', 'N', 'N/A']},
{'allow_certificate': False, 'whitelisted': None, 'grade': 0.8, 'output': ['N', 'N', 'N/A']}
)
def test_certificate_info_for_user(self, allow_certificate, whitelisted, grade, output):
"""
Verify that certificate_info_for_user works.
"""
student = UserFactory()
course = CourseFactory.create(org='edx', number='verified', display_name='Verified Course')
student.profile.allow_certificate = allow_certificate
student.profile.save()
certificate_info = certificate_info_for_user(student, course.id, grade, whitelisted)
self.assertEqual(certificate_info, output)
@patch.dict(settings.FEATURES, {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True}) @patch.dict(settings.FEATURES, {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
def test_course_milestone_collected(self): def test_course_milestone_collected(self):
seed_milestone_relationship_types() seed_milestone_relationship_types()
......
...@@ -22,6 +22,7 @@ from util.file import course_filename_prefix_generator, UniversalNewlineIterator ...@@ -22,6 +22,7 @@ from util.file import course_filename_prefix_generator, UniversalNewlineIterator
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.split_test_module import get_split_user_partitions from xmodule.split_test_module import get_split_user_partitions
from certificates.models import CertificateWhitelist, certificate_info_for_user
from courseware.courses import get_course_by_id, get_problems_in_section from courseware.courses import get_course_by_id, get_problems_in_section
from courseware.grades import iterate_grades_for from courseware.grades import iterate_grades_for
from courseware.models import StudentModule from courseware.models import StudentModule
...@@ -36,6 +37,7 @@ from openedx.core.djangoapps.course_groups.models import CourseUserGroup ...@@ -36,6 +37,7 @@ from openedx.core.djangoapps.course_groups.models import CourseUserGroup
from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.keys import UsageKey
from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort, is_course_cohorted from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort, is_course_cohorted
from student.models import CourseEnrollment from student.models import CourseEnrollment
from verify_student.models import SoftwareSecurePhotoVerification
# define different loggers for use within tasks and on client side # define different loggers for use within tasks and on client side
...@@ -549,7 +551,7 @@ def upload_csv_to_report_store(rows, csv_name, course_id, timestamp): ...@@ -549,7 +551,7 @@ def upload_csv_to_report_store(rows, csv_name, course_id, timestamp):
) )
def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input, action_name): def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input, action_name): # pylint: disable=too-many-statements
""" """
For a given `course_id`, generate a grades CSV file for all students that For a given `course_id`, generate a grades CSV file for all students that
are enrolled, and store using a `ReportStore`. Once created, the files can are enrolled, and store using a `ReportStore`. Once created, the files can
...@@ -584,6 +586,10 @@ def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input, ...@@ -584,6 +586,10 @@ def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input,
experiment_partitions = get_split_user_partitions(course.user_partitions) experiment_partitions = get_split_user_partitions(course.user_partitions)
group_configs_header = [u'Experiment Group ({})'.format(partition.name) for partition in experiment_partitions] group_configs_header = [u'Experiment Group ({})'.format(partition.name) for partition in experiment_partitions]
certificate_info_header = ['Certificate Eligible', 'Certificate Delivered', 'Certificate Type']
certificate_whitelist = CertificateWhitelist.objects.filter(course_id=course_id, whitelist=True)
whitelisted_user_ids = [entry.user_id for entry in certificate_whitelist]
# Loop over all our students and build our CSV lists in memory # Loop over all our students and build our CSV lists in memory
header = None header = None
rows = [] rows = []
...@@ -623,7 +629,8 @@ def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input, ...@@ -623,7 +629,8 @@ def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input,
if not header: if not header:
header = [section['label'] for section in gradeset[u'section_breakdown']] header = [section['label'] for section in gradeset[u'section_breakdown']]
rows.append( rows.append(
["id", "email", "username", "grade"] + header + cohorts_header + group_configs_header ["id", "email", "username", "grade"] + header + cohorts_header +
group_configs_header + ['Enrollment Track', 'Verification Status'] + certificate_info_header
) )
percents = { percents = {
...@@ -642,6 +649,19 @@ def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input, ...@@ -642,6 +649,19 @@ def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input,
group = LmsPartitionService(student, course_id).get_group(partition, assign=False) group = LmsPartitionService(student, course_id).get_group(partition, assign=False)
group_configs_group_names.append(group.name if group else '') group_configs_group_names.append(group.name if group else '')
enrollment_mode = CourseEnrollment.enrollment_mode_for_user(student, course_id)[0]
verification_status = SoftwareSecurePhotoVerification.verification_status_for_user(
student,
course_id,
enrollment_mode
)
certificate_info = certificate_info_for_user(
student,
course_id,
gradeset['grade'],
student.id in whitelisted_user_ids
)
# Not everybody has the same gradable items. If the item is not # Not everybody has the same gradable items. If the item is not
# found in the user's gradeset, just assume it's a 0. The aggregated # found in the user's gradeset, just assume it's a 0. The aggregated
# grades for their sections and overall course will be calculated # grades for their sections and overall course will be calculated
...@@ -651,7 +671,8 @@ def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input, ...@@ -651,7 +671,8 @@ def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input,
row_percents = [percents.get(label, 0.0) for label in header] row_percents = [percents.get(label, 0.0) for label in header]
rows.append( rows.append(
[student.id, student.email, student.username, gradeset['percent']] + [student.id, student.email, student.username, gradeset['percent']] +
row_percents + cohorts_group_name + group_configs_group_names row_percents + cohorts_group_name + group_configs_group_names +
[enrollment_mode] + [verification_status] + certificate_info
) )
else: else:
# An empty gradeset means we failed to grade a student. # An empty gradeset means we failed to grade a student.
......
...@@ -10,9 +10,11 @@ import unicodecsv ...@@ -10,9 +10,11 @@ import unicodecsv
from uuid import uuid4 from uuid import uuid4
from celery.states import SUCCESS, FAILURE from celery.states import SUCCESS, FAILURE
from django.core.urlresolvers import reverse
from django.conf import settings from django.conf import settings
from django.test.testcases import TestCase from django.test.testcases import TestCase
from django.contrib.auth.models import User from django.contrib.auth.models import User
from lms.djangoapps.lms_xblock.runtime import quote_slashes
from opaque_keys.edx.locations import Location, SlashSeparatedCourseKey from opaque_keys.edx.locations import Location, SlashSeparatedCourseKey
from capa.tests.response_xml_factory import OptionResponseXMLFactory from capa.tests.response_xml_factory import OptionResponseXMLFactory
...@@ -147,21 +149,21 @@ class InstructorTaskCourseTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase) ...@@ -147,21 +149,21 @@ class InstructorTaskCourseTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase)
self.login(InstructorTaskCourseTestCase.get_user_email(username), "test") self.login(InstructorTaskCourseTestCase.get_user_email(username), "test")
self.current_user = username self.current_user = username
def _create_user(self, username, email=None, is_staff=False): def _create_user(self, username, email=None, is_staff=False, mode='honor'):
"""Creates a user and enrolls them in the test course.""" """Creates a user and enrolls them in the test course."""
if email is None: if email is None:
email = InstructorTaskCourseTestCase.get_user_email(username) email = InstructorTaskCourseTestCase.get_user_email(username)
thisuser = UserFactory.create(username=username, email=email, is_staff=is_staff) thisuser = UserFactory.create(username=username, email=email, is_staff=is_staff)
CourseEnrollmentFactory.create(user=thisuser, course_id=self.course.id) CourseEnrollmentFactory.create(user=thisuser, course_id=self.course.id, mode=mode)
return thisuser return thisuser
def create_instructor(self, username, email=None): def create_instructor(self, username, email=None):
"""Creates an instructor for the test course.""" """Creates an instructor for the test course."""
return self._create_user(username, email, is_staff=True) return self._create_user(username, email, is_staff=True)
def create_student(self, username, email=None): def create_student(self, username, email=None, mode='honor'):
"""Creates a student for the test course.""" """Creates a student for the test course."""
return self._create_user(username, email, is_staff=False) return self._create_user(username, email, is_staff=False, mode=mode)
@staticmethod @staticmethod
def get_task_status(task_id): def get_task_status(task_id):
...@@ -236,6 +238,40 @@ class InstructorTaskModuleTestCase(InstructorTaskCourseTestCase): ...@@ -236,6 +238,40 @@ class InstructorTaskModuleTestCase(InstructorTaskCourseTestCase):
module_state_key=descriptor.location, module_state_key=descriptor.location,
) )
def submit_student_answer(self, username, problem_url_name, responses):
"""
Use ajax interface to submit a student answer.
Assumes the input list of responses has two values.
"""
def get_input_id(response_id):
"""Creates input id using information about the test course and the current problem."""
# Note that this is a capa-specific convention. The form is a version of the problem's
# 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.
return 'input_i4x-{0}-{1}-problem-{2}_{3}'.format(TEST_COURSE_ORG.lower(),
TEST_COURSE_NUMBER.replace('.', '_'),
problem_url_name, response_id)
# make sure that the requested user is logged in, so that the ajax call works
# on the right problem:
self.login_username(username)
# make ajax call:
modx_url = reverse('xblock_handler', kwargs={
'course_id': self.course.id.to_deprecated_string(),
'usage_id': quote_slashes(
InstructorTaskModuleTestCase.problem_location(problem_url_name).to_deprecated_string()
),
'handler': 'xmodule_handler',
'suffix': 'problem_check',
})
# assign correct identifier to each response.
resp = self.client.post(modx_url, {
get_input_id('{}_1').format(index): response for index, response in enumerate(responses, 2)
})
return resp
class TestReportMixin(object): class TestReportMixin(object):
""" """
...@@ -246,7 +282,7 @@ class TestReportMixin(object): ...@@ -246,7 +282,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): def verify_rows_in_csv(self, expected_rows, 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.
...@@ -259,12 +295,20 @@ class TestReportMixin(object): ...@@ -259,12 +295,20 @@ class TestReportMixin(object):
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
that the content matches. that the content matches.
ignore_other_columns (boolean): When True, we verify that `expected_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)[0][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)]
if ignore_other_columns:
csv_rows = [
{key: row.get(key) for key in expected_rows[index].keys()} for index, row in enumerate(csv_rows)
]
if verify_order: if verify_order:
self.assertEqual(csv_rows, expected_rows) self.assertEqual(csv_rows, expected_rows)
else: else:
......
...@@ -43,39 +43,6 @@ class TestIntegrationTask(InstructorTaskModuleTestCase): ...@@ -43,39 +43,6 @@ class TestIntegrationTask(InstructorTaskModuleTestCase):
Base class to provide general methods used for "integration" testing of particular tasks. Base class to provide general methods used for "integration" testing of particular tasks.
""" """
def submit_student_answer(self, username, problem_url_name, responses):
"""
Use ajax interface to submit a student answer.
Assumes the input list of responses has two values.
"""
def get_input_id(response_id):
"""Creates input id using information about the test course and the current problem."""
# Note that this is a capa-specific convention. The form is a version of the problem's
# 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.
return 'input_i4x-{0}-{1}-problem-{2}_{3}'.format(TEST_COURSE_ORG.lower(),
TEST_COURSE_NUMBER.replace('.', '_'),
problem_url_name, response_id)
# make sure that the requested user is logged in, so that the ajax call works
# on the right problem:
self.login_username(username)
# make ajax call:
modx_url = reverse('xblock_handler', kwargs={
'course_id': self.course.id.to_deprecated_string(),
'usage_id': quote_slashes(InstructorTaskModuleTestCase.problem_location(problem_url_name).to_deprecated_string()),
'handler': 'xmodule_handler',
'suffix': 'problem_check',
})
# we assume we have two responses, so assign them the correct identifiers.
resp = self.client.post(modx_url, {
get_input_id('2_1'): responses[0],
get_input_id('3_1'): responses[1],
})
return resp
def _assert_task_failure(self, entry_id, task_type, problem_url_name, expected_message): def _assert_task_failure(self, entry_id, task_type, problem_url_name, expected_message):
"""Confirm that expected values are stored in InstructorTask on task failure.""" """Confirm that expected values are stored in InstructorTask on task failure."""
instructor_task = InstructorTask.objects.get(id=entry_id) instructor_task = InstructorTask.objects.get(id=entry_id)
...@@ -606,7 +573,7 @@ class TestGradeReportConditionalContent(TestReportMixin, TestIntegrationTask): ...@@ -606,7 +573,7 @@ class TestGradeReportConditionalContent(TestReportMixin, TestIntegrationTask):
""" """
self.assertDictContainsSubset({'attempted': 2, 'succeeded': 2, 'failed': 0}, task_result) self.assertDictContainsSubset({'attempted': 2, 'succeeded': 2, 'failed': 0}, task_result)
def verify_grades_in_csv(self, students_grades): def verify_grades_in_csv(self, students_grades, ignore_other_columns=False):
""" """
Verify that the grades CSV contains the expected grades data. Verify that the grades CSV contains the expected grades data.
...@@ -642,7 +609,8 @@ class TestGradeReportConditionalContent(TestReportMixin, TestIntegrationTask): ...@@ -642,7 +609,8 @@ class TestGradeReportConditionalContent(TestReportMixin, TestIntegrationTask):
user_partition_group(student) user_partition_group(student)
) )
for student_grades in students_grades for student, grades in student_grades.iteritems() for student_grades in students_grades for student, grades in student_grades.iteritems()
] ],
ignore_other_columns=ignore_other_columns
) )
def test_both_groups_problems(self): def test_both_groups_problems(self):
...@@ -668,7 +636,8 @@ class TestGradeReportConditionalContent(TestReportMixin, TestIntegrationTask): ...@@ -668,7 +636,8 @@ class TestGradeReportConditionalContent(TestReportMixin, TestIntegrationTask):
[ [
{self.student_a: {'grade': '1.0', 'HW': '1.0'}}, {self.student_a: {'grade': '1.0', 'HW': '1.0'}},
{self.student_b: {'grade': '0.5', 'HW': '0.5'}} {self.student_b: {'grade': '0.5', 'HW': '0.5'}}
] ],
ignore_other_columns=True
) )
def test_one_group_problem(self): def test_one_group_problem(self):
...@@ -690,5 +659,6 @@ class TestGradeReportConditionalContent(TestReportMixin, TestIntegrationTask): ...@@ -690,5 +659,6 @@ class TestGradeReportConditionalContent(TestReportMixin, TestIntegrationTask):
[ [
{self.student_a: {'grade': '1.0', 'HW': '1.0'}}, {self.student_a: {'grade': '1.0', 'HW': '1.0'}},
{self.student_b: {'grade': '0.0', 'HW': '0.0'}} {self.student_b: {'grade': '0.0', 'HW': '0.0'}}
] ],
ignore_other_columns=True
) )
...@@ -11,18 +11,21 @@ from mock import Mock, patch ...@@ -11,18 +11,21 @@ from mock import Mock, patch
import tempfile import tempfile
import unicodecsv import unicodecsv
from xmodule.modulestore.tests.factories import CourseFactory from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
from student.tests.factories import UserFactory from certificates.tests.factories import GeneratedCertificateFactory, CertificateWhitelistFactory
from student.models import CourseEnrollment from course_modes.models import CourseMode
from xmodule.partitions.partitions import Group, UserPartition from instructor_task.models import ReportStore
from instructor_task.tasks_helper import cohort_students_and_upload, upload_grades_csv, upload_students_csv
from instructor_task.tests.test_base import InstructorTaskCourseTestCase, TestReportMixin, InstructorTaskModuleTestCase
from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup 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
import openedx.core.djangoapps.user_api.course_tag.api as course_tag_api import openedx.core.djangoapps.user_api.course_tag.api as course_tag_api
from openedx.core.djangoapps.user_api.partition_schemes import RandomUserPartitionScheme from openedx.core.djangoapps.user_api.partition_schemes import RandomUserPartitionScheme
from instructor_task.models import ReportStore from student.tests.factories import UserFactory
from instructor_task.tasks_helper import cohort_students_and_upload, upload_grades_csv, upload_students_csv from student.models import CourseEnrollment
from instructor_task.tests.test_base import InstructorTaskCourseTestCase, TestReportMixin from verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.partitions.partitions import Group, UserPartition
@ddt.ddt @ddt.ddt
...@@ -250,7 +253,7 @@ class TestInstructorGradeReport(TestReportMixin, InstructorTaskCourseTestCase): ...@@ -250,7 +253,7 @@ class TestInstructorGradeReport(TestReportMixin, InstructorTaskCourseTestCase):
mock_iterate_grades_for.return_value = [ mock_iterate_grades_for.return_value = [
( (
self.create_student('username', 'student@example.com'), self.create_student('username', 'student@example.com'),
{'section_breakdown': [{'label': u'\u8282\u540e\u9898 01'}], 'percent': 0}, {'section_breakdown': [{'label': u'\u8282\u540e\u9898 01'}], 'percent': 0, 'grade': None},
'Cannot grade student' 'Cannot grade student'
) )
] ]
...@@ -538,3 +541,141 @@ class TestCohortStudents(TestReportMixin, InstructorTaskCourseTestCase): ...@@ -538,3 +541,141 @@ class TestCohortStudents(TestReportMixin, InstructorTaskCourseTestCase):
], ],
verify_order=False verify_order=False
) )
@ddt.ddt
@patch('instructor_task.tasks_helper.DefaultStorage', new=MockDefaultStorage)
class TestGradeReportEnrollmentAndCertificateInfo(TestReportMixin, InstructorTaskModuleTestCase):
"""
Test that grade report has correct user enrolment, verification, and certificate information.
"""
def setUp(self):
super(TestGradeReportEnrollmentAndCertificateInfo, self).setUp()
self.initialize_course()
self.create_problem()
self.columns_to_check = [
'Enrollment Track',
'Verification Status',
'Certificate Eligible',
'Certificate Delivered',
'Certificate Type'
]
def create_problem(self, problem_display_name='test_problem', parent=None):
"""
Create a multiple choice response problem.
"""
if parent is None:
parent = self.problem_section
factory = MultipleChoiceResponseXMLFactory()
args = {'choices': [False, True, False]}
problem_xml = factory.build_xml(**args)
ItemFactory.create(
parent_location=parent.location,
parent=parent,
category="problem",
display_name=problem_display_name,
data=problem_xml
)
def user_is_embargoed(self, user, is_embargoed):
"""
Set a users emabargo state.
"""
user_profile = UserFactory(username=user.username, email=user.email).profile
user_profile.allow_certificate = not is_embargoed
user_profile.save()
def _verify_csv_data(self, username, expected_data):
"""
Verify grade report data.
"""
with patch('instructor_task.tasks_helper._get_current_task'):
upload_grades_csv(None, None, self.course.id, None, 'graded')
report_store = ReportStore.from_config()
report_csv_filename = report_store.links_for(self.course.id)[0][0]
with open(report_store.path_to(self.course.id, report_csv_filename)) as csv_file:
for row in unicodecsv.DictReader(csv_file):
if row.get('username') == username:
csv_row_data = [row[column] for column in self.columns_to_check]
self.assertEqual(csv_row_data, expected_data)
def _create_user_data(self,
user_enroll_mode,
has_passed,
whitelisted,
is_embargoed,
verification_status,
certificate_status,
certificate_mode):
"""
Create user data to be used during grade report generation.
"""
user = self.create_student('u1', mode=user_enroll_mode)
if has_passed:
self.submit_student_answer('u1', 'test_problem', ['choice_1'])
CertificateWhitelistFactory.create(user=user, course_id=self.course.id, whitelist=whitelisted)
self.user_is_embargoed(user, is_embargoed)
if user_enroll_mode in CourseMode.VERIFIED_MODES:
SoftwareSecurePhotoVerificationFactory.create(user=user, status=verification_status)
GeneratedCertificateFactory.create(
user=user,
course_id=self.course.id,
status=certificate_status,
mode=certificate_mode
)
return user
@ddt.data(
(
'verified', False, False, False, 'approved', 'notpassing', 'honor',
['verified', 'ID Verified', 'N', 'N', 'N/A']
),
(
'verified', False, True, False, 'approved', 'downloadable', 'verified',
['verified', 'ID Verified', 'Y', 'Y', 'verified']
),
(
'honor', True, True, True, 'approved', 'restricted', 'honor',
['honor', 'N/A', 'N', 'N', 'N/A']
),
(
'verified', True, True, False, 'must_retry', 'downloadable', 'honor',
['verified', 'Not ID Verified', 'Y', 'Y', 'honor']
),
)
@ddt.unpack
def test_grade_report_enrollment_and_certificate_info(
self,
user_enroll_mode,
has_passed,
whitelisted,
is_embargoed,
verification_status,
certificate_status,
certificate_mode,
expected_output
):
user = self._create_user_data(
user_enroll_mode,
has_passed,
whitelisted,
is_embargoed,
verification_status,
certificate_status,
certificate_mode
)
self._verify_csv_data(user.username, expected_output)
...@@ -13,6 +13,8 @@ from email.utils import formatdate ...@@ -13,6 +13,8 @@ from email.utils import formatdate
import functools import functools
import json import json
import logging import logging
from course_modes.models import CourseMode
import pytz import pytz
import requests import requests
import uuid import uuid
...@@ -935,6 +937,25 @@ class SoftwareSecurePhotoVerification(PhotoVerification): ...@@ -935,6 +937,25 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
attempt.submit() attempt.submit()
return attempt return attempt
@classmethod
def verification_status_for_user(cls, user, course_id, user_enrollment_mode):
"""
Returns the verification status for use in grade report.
"""
if user_enrollment_mode not in CourseMode.VERIFIED_MODES:
return 'N/A'
user_is_verified = cls.user_is_verified(user)
if not user_is_verified:
return 'Not ID Verified'
else:
user_is_re_verified = cls.user_is_reverified_for_all(course_id, user)
if not user_is_re_verified:
return 'ID Verification Expired'
else:
return 'ID Verified'
class VerificationCheckpoint(models.Model): class VerificationCheckpoint(models.Model):
"""Represents a point at which a user is challenged to reverify his or her identity. """Represents a point at which a user is challenged to reverify his or her identity.
......
"""
Factories related to student verification.
"""
from factory.django import DjangoModelFactory
from verify_student.models import SoftwareSecurePhotoVerification
class SoftwareSecurePhotoVerificationFactory(DjangoModelFactory):
"""
Factory for SoftwareSecurePhotoVerification
"""
FACTORY_FOR = SoftwareSecurePhotoVerification
status = 'approved'
...@@ -128,7 +128,8 @@ def mock_software_secure_post_unavailable(url, headers=None, data=None, **kwargs ...@@ -128,7 +128,8 @@ def mock_software_secure_post_unavailable(url, headers=None, data=None, **kwargs
@patch('verify_student.models.S3Connection', new=MockS3Connection) @patch('verify_student.models.S3Connection', new=MockS3Connection)
@patch('verify_student.models.Key', new=MockKey) @patch('verify_student.models.Key', new=MockKey)
@patch('verify_student.models.requests.post', new=mock_software_secure_post) @patch('verify_student.models.requests.post', new=mock_software_secure_post)
class TestPhotoVerification(TestCase): @ddt.ddt
class TestPhotoVerification(ModuleStoreTestCase):
def test_state_transitions(self): def test_state_transitions(self):
""" """
...@@ -505,6 +506,29 @@ class TestPhotoVerification(TestCase): ...@@ -505,6 +506,29 @@ class TestPhotoVerification(TestCase):
result = SoftwareSecurePhotoVerification.verification_for_datetime(deadline, query) result = SoftwareSecurePhotoVerification.verification_for_datetime(deadline, query)
self.assertEqual(result, second_attempt) self.assertEqual(result, second_attempt)
@ddt.unpack
@ddt.data(
{'enrollment_mode': 'honor', 'status': (None, None), 'output': 'N/A'},
{'enrollment_mode': 'verified', 'status': (False, False), 'output': 'Not ID Verified'},
{'enrollment_mode': 'verified', 'status': (True, True), 'output': 'ID Verified'},
{'enrollment_mode': 'verified', 'status': (True, False), 'output': 'ID Verification Expired'}
)
def test_verification_status_for_user(self, enrollment_mode, status, output):
"""
Verify verification_status_for_user returns correct status.
"""
user = UserFactory.create()
course = CourseFactory.create()
user_reverified_path = 'verify_student.models.SoftwareSecurePhotoVerification.user_is_reverified_for_all'
with patch('verify_student.models.SoftwareSecurePhotoVerification.user_is_verified') as mock_verification:
with patch(user_reverified_path) as mock_re_verification:
mock_verification.return_value = status[0]
mock_re_verification.return_value = status[1]
status = SoftwareSecurePhotoVerification.verification_status_for_user(user, course.id, enrollment_mode)
self.assertEqual(status, output)
@patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS) @patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS)
@patch('verify_student.models.S3Connection', new=MockS3Connection) @patch('verify_student.models.S3Connection', new=MockS3Connection)
......
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