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):
gender = u'm'
mailing_address = None
goals = u'Learn a lot'
allow_certificate = True
class CourseModeFactory(DjangoModelFactory):
......
......@@ -187,6 +187,33 @@ def certificate_status_for_student(student, course_id):
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):
"""A set of example certificates.
......
from factory.django import DjangoModelFactory
from certificates.models import GeneratedCertificate, CertificateStatuses, CertificateHtmlViewConfiguration
from certificates.models import (
GeneratedCertificate, CertificateStatuses, CertificateHtmlViewConfiguration, CertificateWhitelist
)
# Factories are self documenting
......@@ -15,6 +17,14 @@ class GeneratedCertificateFactory(DjangoModelFactory):
name = ''
class CertificateWhitelistFactory(DjangoModelFactory):
FACTORY_FOR = CertificateWhitelist
course_id = None
whitelist = True
class CertificateHtmlViewConfigurationFactory(DjangoModelFactory):
FACTORY_FOR = CertificateHtmlViewConfiguration
......
"""
Tests for the certificates models.
"""
from ddt import ddt, data, unpack
from mock import patch
from django.conf import settings
......@@ -9,7 +9,12 @@ from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
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 util.milestones_helpers import (
......@@ -19,6 +24,7 @@ from util.milestones_helpers import (
)
@ddt
class CertificatesModelTest(ModuleStoreTestCase):
"""
Tests for the GeneratedCertificate model
......@@ -32,6 +38,26 @@ class CertificatesModelTest(ModuleStoreTestCase):
self.assertEqual(certificate_status['status'], CertificateStatuses.unavailable)
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})
def test_course_milestone_collected(self):
seed_milestone_relationship_types()
......
......@@ -22,6 +22,7 @@ from util.file import course_filename_prefix_generator, UniversalNewlineIterator
from xmodule.modulestore.django import modulestore
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.grades import iterate_grades_for
from courseware.models import StudentModule
......@@ -36,6 +37,7 @@ from openedx.core.djangoapps.course_groups.models import CourseUserGroup
from opaque_keys.edx.keys import UsageKey
from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort, is_course_cohorted
from student.models import CourseEnrollment
from verify_student.models import SoftwareSecurePhotoVerification
# 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):
)
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
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,
experiment_partitions = get_split_user_partitions(course.user_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
header = None
rows = []
......@@ -623,7 +629,8 @@ def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input,
if not header:
header = [section['label'] for section in gradeset[u'section_breakdown']]
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 = {
......@@ -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_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
# found in the user's gradeset, just assume it's a 0. The aggregated
# 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,
row_percents = [percents.get(label, 0.0) for label in header]
rows.append(
[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:
# An empty gradeset means we failed to grade a student.
......
......@@ -10,9 +10,11 @@ import unicodecsv
from uuid import uuid4
from celery.states import SUCCESS, FAILURE
from django.core.urlresolvers import reverse
from django.conf import settings
from django.test.testcases import TestCase
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 capa.tests.response_xml_factory import OptionResponseXMLFactory
......@@ -147,21 +149,21 @@ class InstructorTaskCourseTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase)
self.login(InstructorTaskCourseTestCase.get_user_email(username), "test")
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."""
if email is None:
email = InstructorTaskCourseTestCase.get_user_email(username)
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
def create_instructor(self, username, email=None):
"""Creates an instructor for the test course."""
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."""
return self._create_user(username, email, is_staff=False)
return self._create_user(username, email, is_staff=False, mode=mode)
@staticmethod
def get_task_status(task_id):
......@@ -236,6 +238,40 @@ class InstructorTaskModuleTestCase(InstructorTaskCourseTestCase):
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):
"""
......@@ -246,7 +282,7 @@ class TestReportMixin(object):
if os.path.exists(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.
......@@ -259,12 +295,20 @@ class TestReportMixin(object):
content and order of `expected_rows` matches the
actual csv rows. When False (default), we only verify
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_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:
# Expand the dict reader generator so we don't lose it's content
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:
self.assertEqual(csv_rows, expected_rows)
else:
......
......@@ -43,39 +43,6 @@ class TestIntegrationTask(InstructorTaskModuleTestCase):
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):
"""Confirm that expected values are stored in InstructorTask on task failure."""
instructor_task = InstructorTask.objects.get(id=entry_id)
......@@ -606,7 +573,7 @@ class TestGradeReportConditionalContent(TestReportMixin, TestIntegrationTask):
"""
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.
......@@ -642,7 +609,8 @@ class TestGradeReportConditionalContent(TestReportMixin, TestIntegrationTask):
user_partition_group(student)
)
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):
......@@ -668,7 +636,8 @@ class TestGradeReportConditionalContent(TestReportMixin, TestIntegrationTask):
[
{self.student_a: {'grade': '1.0', 'HW': '1.0'}},
{self.student_b: {'grade': '0.5', 'HW': '0.5'}}
]
],
ignore_other_columns=True
)
def test_one_group_problem(self):
......@@ -690,5 +659,6 @@ class TestGradeReportConditionalContent(TestReportMixin, TestIntegrationTask):
[
{self.student_a: {'grade': '1.0', 'HW': '1.0'}},
{self.student_b: {'grade': '0.0', 'HW': '0.0'}}
]
],
ignore_other_columns=True
)
......@@ -11,18 +11,21 @@ from mock import Mock, patch
import tempfile
import unicodecsv
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory
from student.models import CourseEnrollment
from xmodule.partitions.partitions import Group, UserPartition
from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
from certificates.tests.factories import GeneratedCertificateFactory, CertificateWhitelistFactory
from course_modes.models import CourseMode
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.tests.helpers import CohortFactory
import openedx.core.djangoapps.user_api.course_tag.api as course_tag_api
from openedx.core.djangoapps.user_api.partition_schemes import RandomUserPartitionScheme
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
from student.tests.factories import UserFactory
from student.models import CourseEnrollment
from verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.partitions.partitions import Group, UserPartition
@ddt.ddt
......@@ -250,7 +253,7 @@ class TestInstructorGradeReport(TestReportMixin, InstructorTaskCourseTestCase):
mock_iterate_grades_for.return_value = [
(
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'
)
]
......@@ -538,3 +541,141 @@ class TestCohortStudents(TestReportMixin, InstructorTaskCourseTestCase):
],
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
import functools
import json
import logging
from course_modes.models import CourseMode
import pytz
import requests
import uuid
......@@ -935,6 +937,25 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
attempt.submit()
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):
"""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
@patch('verify_student.models.S3Connection', new=MockS3Connection)
@patch('verify_student.models.Key', new=MockKey)
@patch('verify_student.models.requests.post', new=mock_software_secure_post)
class TestPhotoVerification(TestCase):
@ddt.ddt
class TestPhotoVerification(ModuleStoreTestCase):
def test_state_transitions(self):
"""
......@@ -505,6 +506,29 @@ class TestPhotoVerification(TestCase):
result = SoftwareSecurePhotoVerification.verification_for_datetime(deadline, query)
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('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