diff --git a/common/djangoapps/student/migrations/0044_auto__add_entranceexamconfiguration__add_unique_entranceexamconfigurat.py b/common/djangoapps/student/migrations/0044_auto__add_entranceexamconfiguration__add_unique_entranceexamconfigurat.py new file mode 100644 index 0000000000..233519bc0f --- /dev/null +++ b/common/djangoapps/student/migrations/0044_auto__add_entranceexamconfiguration__add_unique_entranceexamconfigurat.py @@ -0,0 +1,203 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'EntranceExamConfiguration' + db.create_table('student_entranceexamconfiguration', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)), + ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, null=True, db_index=True, blank=True)), + ('updated', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, db_index=True, blank=True)), + ('skip_entrance_exam', self.gf('django.db.models.fields.BooleanField')(default=True)), + )) + db.send_create_signal('student', ['EntranceExamConfiguration']) + + # Adding unique constraint on 'EntranceExamConfiguration', fields ['user', 'course_id'] + db.create_unique('student_entranceexamconfiguration', ['user_id', 'course_id']) + + + def backwards(self, orm): + # Removing unique constraint on 'EntranceExamConfiguration', fields ['user', 'course_id'] + db.delete_unique('student_entranceexamconfiguration', ['user_id', 'course_id']) + + # Deleting model 'EntranceExamConfiguration' + db.delete_table('student_entranceexamconfiguration') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'student.anonymoususerid': { + 'Meta': {'object_name': 'AnonymousUserId'}, + 'anonymous_user_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseaccessrole': { + 'Meta': {'unique_together': "(('user', 'org', 'course_id', 'role'),)", 'object_name': 'CourseAccessRole'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'org': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '64', 'blank': 'True'}), + 'role': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollmentallowed': { + 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'}, + 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'student.dashboardconfiguration': { + 'Meta': {'object_name': 'DashboardConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'recent_enrollment_time_delta': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}) + }, + 'student.entranceexamconfiguration': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'EntranceExamConfiguration'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'skip_entrance_exam': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.linkedinaddtoprofileconfiguration': { + 'Meta': {'object_name': 'LinkedInAddToProfileConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'dashboard_tracking_code': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'student.loginfailures': { + 'Meta': {'object_name': 'LoginFailures'}, + 'failure_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'lockout_until': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.passwordhistory': { + 'Meta': {'object_name': 'PasswordHistory'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'time_set': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.pendingemailchange': { + 'Meta': {'object_name': 'PendingEmailChange'}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.pendingnamechange': { + 'Meta': {'object_name': 'PendingNameChange'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.registration': { + 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.userprofile': { + 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'city': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'null': 'True', 'blank': 'True'}), + 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), + 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}), + 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'student.usersignupsource': { + 'Meta': {'object_name': 'UserSignupSource'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'site': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.userstanding': { + 'Meta': {'object_name': 'UserStanding'}, + 'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"}) + }, + 'student.usertestgroup': { + 'Meta': {'object_name': 'UserTestGroup'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'}) + } + } + + complete_apps = ['student'] \ No newline at end of file diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 30b8cde045..172e88fabc 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -1500,3 +1500,43 @@ class LinkedInAddToProfileConfiguration(ConfigurationModel): ) if self.trk_partner_name else None ) + + +class EntranceExamConfiguration(models.Model): + """ + Represents a Student's entrance exam specific data for a single Course + """ + + user = models.ForeignKey(User, db_index=True) + course_id = CourseKeyField(max_length=255, db_index=True) + created = models.DateTimeField(auto_now_add=True, null=True, db_index=True) + updated = models.DateTimeField(auto_now=True, db_index=True) + + # if skip_entrance_exam is True, then student can skip entrance exam + # for the course + skip_entrance_exam = models.BooleanField(default=True) + + class Meta(object): + """ + Meta class to make user and course_id unique in the table + """ + unique_together = (('user', 'course_id'), ) + + def __unicode__(self): + return "[EntranceExamConfiguration] %s: %s (%s) = %s" % ( + self.user, self.course_id, self.created, self.skip_entrance_exam + ) + + @classmethod + def user_can_skip_entrance_exam(cls, user, course_key): + """ + Return True if given user can skip entrance exam for given course otherwise False. + """ + can_skip = False + if settings.FEATURES.get('ENTRANCE_EXAMS', False): + try: + record = EntranceExamConfiguration.objects.get(user=user, course_id=course_key) + can_skip = record.skip_entrance_exam + except EntranceExamConfiguration.DoesNotExist: + can_skip = False + return can_skip diff --git a/common/test/acceptance/pages/lms/instructor_dashboard.py b/common/test/acceptance/pages/lms/instructor_dashboard.py index 2d46756eee..e86aceb8f1 100644 --- a/common/test/acceptance/pages/lms/instructor_dashboard.py +++ b/common/test/acceptance/pages/lms/instructor_dashboard.py @@ -523,6 +523,13 @@ class StudentAdminPage(PageObject): """ return self.q(css='{} input[name=rescore-entrance-exam]'.format(self.EE_CONTAINER)) + @property + def skip_entrance_exam_button(self): + """ + Return Let Student Skip Entrance Exam button. + """ + return self.q(css='{} input[name=skip-entrance-exam]'.format(self.EE_CONTAINER)) + @property def delete_student_state_button(self): """ @@ -592,6 +599,12 @@ class StudentAdminPage(PageObject): """ return self.rescore_submission_button.click() + def click_skip_entrance_exam_button(self): + """ + clicks let student skip entrance exam button. + """ + return self.skip_entrance_exam_button.click() + def click_delete_student_state_button(self): """ clicks delete student state button. diff --git a/common/test/acceptance/pages/studio/settings.py b/common/test/acceptance/pages/studio/settings.py index 038ef51b95..6f83ad9bcd 100644 --- a/common/test/acceptance/pages/studio/settings.py +++ b/common/test/acceptance/pages/studio/settings.py @@ -76,10 +76,10 @@ class SettingsPage(CoursePage): """ press_the_notification_button(self, "save") if wait_for_confirmation: - EmptyPromise( - lambda: self.q(css='#alert-confirmation-title').present, - 'Save is confirmed' - ).fulfill() + self.wait_for_element_visibility( + '#alert-confirmation-title', + 'Save confirmation message is visible' + ) def refresh_page(self, wait_for_confirmation=True): """ @@ -91,3 +91,4 @@ class SettingsPage(CoursePage): lambda: self.q(css='body.view-settings').present, 'Page is refreshed' ).fulfill() + self.wait_for_ajax() diff --git a/common/test/acceptance/tests/helpers.py b/common/test/acceptance/tests/helpers.py index ffb726a475..cefbe18ee0 100644 --- a/common/test/acceptance/tests/helpers.py +++ b/common/test/acceptance/tests/helpers.py @@ -241,9 +241,9 @@ def element_has_text(page, css_selector, text): def get_modal_alert(browser): """ Returns instance of modal alert box shown in browser after waiting - for 4 seconds + for 6 seconds """ - WebDriverWait(browser, 4).until(EC.alert_is_present()) + WebDriverWait(browser, 6).until(EC.alert_is_present()) return browser.switch_to.alert diff --git a/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py b/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py index 04256db30e..b9f23863ec 100644 --- a/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py +++ b/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py @@ -204,6 +204,45 @@ class EntranceExamGradeTest(UniqueCourseTest): self.student_admin_section.wait_for_ajax() self.assertGreater(len(self.student_admin_section.top_notification.text[0]), 0) + def test_clicking_skip_entrance_exam_button_with_success(self): + """ + Scenario: Clicking on the Let Student Skip Entrance Exam button with + valid student email address or username should result in success prompt. + Given that I am on the Student Admin tab on the Instructor Dashboard + When I click the Let Student Skip Entrance Exam Button under + Entrance Exam Grade Adjustment after entering a valid student + email address or username + Then I should be shown an alert with success message + """ + self.student_admin_section.set_student_email(self.student_identifier) + self.student_admin_section.click_skip_entrance_exam_button() + #first we have window.confirm + alert = get_modal_alert(self.student_admin_section.browser) + alert.accept() + + # then we have alert confirming action + alert = get_modal_alert(self.student_admin_section.browser) + alert.dismiss() + + def test_clicking_skip_entrance_exam_button_with_error(self): + """ + Scenario: Clicking on the Let Student Skip Entrance Exam button with + email address or username of a non existing student should result in error message. + Given that I am on the Student Admin tab on the Instructor Dashboard + When I click the Let Student Skip Entrance Exam Button under + Entrance Exam Grade Adjustment after entering non existing + student email address or username + Then I should be shown an error message + """ + self.student_admin_section.set_student_email('non_existing@example.com') + self.student_admin_section.click_skip_entrance_exam_button() + #first we have window.confirm + alert = get_modal_alert(self.student_admin_section.browser) + alert.accept() + + self.student_admin_section.wait_for_ajax() + self.assertGreater(len(self.student_admin_section.top_notification.text[0]), 0) + def test_clicking_delete_student_attempts_button_with_success(self): """ Scenario: Clicking on the Delete Student State for entrance exam button diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index e0ec21d6eb..3fe94592a2 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -36,7 +36,7 @@ from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig from edxmako.shortcuts import render_to_string from eventtracking import tracker from psychometrics.psychoanalyze import make_psychometrics_data_update_handler -from student.models import anonymous_id_for_user, user_by_anonymous_id +from student.models import anonymous_id_for_user, user_by_anonymous_id, EntranceExamConfiguration from xblock.core import XBlock from xblock.fields import Scope from xblock.runtime import KvsFieldData, KeyValueStore @@ -129,6 +129,15 @@ def _get_required_content(course, user): if milestone_path.get('content') and len(milestone_path['content']): for content in milestone_path['content']: required_content.append(content) + + can_skip_entrance_exam = EntranceExamConfiguration.user_can_skip_entrance_exam(user, course.id) + # check if required_content has any entrance exam and user is allowed to skip it + # then remove it from required content + if required_content and getattr(course, 'entrance_exam_enabled', False) and can_skip_entrance_exam: + descriptors = [modulestore().get_item(UsageKey.from_string(content)) for content in required_content] + entrance_exam_contents = [unicode(descriptor.location) + for descriptor in descriptors if descriptor.is_entrance_exam] + required_content = list(set(required_content) - set(entrance_exam_contents)) return required_content diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index 1ba9e153c6..2c732dc0ee 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -6,7 +6,7 @@ from django.conf import settings from django.utils.translation import ugettext as _ from courseware.access import has_access -from student.models import CourseEnrollment +from student.models import CourseEnrollment, EntranceExamConfiguration from xmodule.tabs import CourseTabList if settings.FEATURES.get('MILESTONES_APP', False): @@ -40,7 +40,8 @@ def get_course_tab_list(course, user): for __, value in course_milestones_paths.iteritems(): if len(value.get('content', [])): for content in value['content']: - if content == course.entrance_exam_id: + if content == course.entrance_exam_id \ + and not EntranceExamConfiguration.user_can_skip_entrance_exam(user, course.id): entrance_exam_mode = True break diff --git a/lms/djangoapps/courseware/tests/test_entrance_exam.py b/lms/djangoapps/courseware/tests/test_entrance_exam.py index df4d810fa0..ff4c7073ca 100644 --- a/lms/djangoapps/courseware/tests/test_entrance_exam.py +++ b/lms/djangoapps/courseware/tests/test_entrance_exam.py @@ -3,10 +3,11 @@ Tests use cases related to LMS Entrance Exam behavior, such as gated content acc """ from django.test.client import RequestFactory from django.test.utils import override_settings +from django.core.urlresolvers import reverse from courseware.model_data import FieldDataCache from courseware.module_render import get_module, toc_for_course -from courseware.tests.factories import UserFactory +from courseware.tests.factories import UserFactory, InstructorFactory from milestones import api as milestones_api from milestones.models import MilestoneRelationshipType from xmodule.modulestore.django import modulestore @@ -57,14 +58,15 @@ class EntranceExamTestCases(ModuleStoreTestCase): self.entrance_exam = ItemFactory.create( parent=self.course, category="chapter", - display_name="Entrance Exam Section - Chapter 1" + display_name="Entrance Exam Section - Chapter 1", + is_entrance_exam=True ) self.exam_1 = ItemFactory.create( parent=self.entrance_exam, category='sequential', display_name="Exam Sequential - Subsection 1", graded=True, - metadata={'in_entrance_exam': True} + in_entrance_exam=True ) subsection = ItemFactory.create( parent=self.exam_1, @@ -130,13 +132,7 @@ class EntranceExamTestCases(ModuleStoreTestCase): self.course.entrance_exam_id = unicode(self.entrance_exam.scope_ids.usage_id) modulestore().update_item(self.course, user.id) # pylint: disable=no-member - def test_entrance_exam_gating(self): - """ - Unit Test: test_entrance_exam_gating - """ - # This user helps to cover a discovered bug in the milestone fulfillment logic - chaos_user = UserFactory() - expected_locked_toc = ( + self.expected_locked_toc = ( [ { 'active': True, @@ -155,60 +151,7 @@ class EntranceExamTestCases(ModuleStoreTestCase): } ] ) - locked_toc = toc_for_course( - self.request, - self.course, - self.entrance_exam.url_name, - self.exam_1.url_name, - self.field_data_cache - ) - for toc_section in expected_locked_toc: - self.assertIn(toc_section, locked_toc) - - # Set up the chaos user - # pylint: disable=maybe-no-member,no-member - grade_dict = {'value': 1, 'max_value': 1, 'user_id': chaos_user.id} - field_data_cache = FieldDataCache.cache_for_descriptor_descendents( - self.course.id, - chaos_user, - self.course, - depth=2 - ) - # pylint: disable=protected-access - module = get_module( - chaos_user, - self.request, - self.problem_1.scope_ids.usage_id, - field_data_cache, - )._xmodule - module.system.publish(self.problem_1, 'grade', grade_dict) - - # pylint: disable=maybe-no-member,no-member - grade_dict = {'value': 1, 'max_value': 1, 'user_id': self.request.user.id} - field_data_cache = FieldDataCache.cache_for_descriptor_descendents( - self.course.id, - self.request.user, - self.course, - depth=2 - ) - # pylint: disable=protected-access - module = get_module( - self.request.user, - self.request, - self.problem_1.scope_ids.usage_id, - field_data_cache, - )._xmodule - module.system.publish(self.problem_1, 'grade', grade_dict) - - module = get_module( - self.request.user, - self.request, - self.problem_2.scope_ids.usage_id, - field_data_cache, - )._xmodule # pylint: disable=protected-access - module.system.publish(self.problem_2, 'grade', grade_dict) - - expected_unlocked_toc = ( + self.expected_unlocked_toc = ( [ { 'active': False, @@ -263,6 +206,64 @@ class EntranceExamTestCases(ModuleStoreTestCase): ] ) + def test_entrance_exam_gating(self): + """ + Unit Test: test_entrance_exam_gating + """ + # This user helps to cover a discovered bug in the milestone fulfillment logic + chaos_user = UserFactory() + locked_toc = toc_for_course( + self.request, + self.course, + self.entrance_exam.url_name, + self.exam_1.url_name, + self.field_data_cache + ) + for toc_section in self.expected_locked_toc: + self.assertIn(toc_section, locked_toc) + + # Set up the chaos user + # pylint: disable=maybe-no-member,no-member + grade_dict = {'value': 1, 'max_value': 1, 'user_id': chaos_user.id} + field_data_cache = FieldDataCache.cache_for_descriptor_descendents( + self.course.id, + chaos_user, + self.course, + depth=2 + ) + # pylint: disable=protected-access + module = get_module( + chaos_user, + self.request, + self.problem_1.scope_ids.usage_id, + field_data_cache, + )._xmodule + module.system.publish(self.problem_1, 'grade', grade_dict) + + # pylint: disable=maybe-no-member,no-member + grade_dict = {'value': 1, 'max_value': 1, 'user_id': self.request.user.id} + field_data_cache = FieldDataCache.cache_for_descriptor_descendents( + self.course.id, + self.request.user, + self.course, + depth=2 + ) + # pylint: disable=protected-access + module = get_module( + self.request.user, + self.request, + self.problem_1.scope_ids.usage_id, + field_data_cache, + )._xmodule + module.system.publish(self.problem_1, 'grade', grade_dict) + + module = get_module( + self.request.user, + self.request, + self.problem_2.scope_ids.usage_id, + field_data_cache, + )._xmodule # pylint: disable=protected-access + module.system.publish(self.problem_2, 'grade', grade_dict) unlocked_toc = toc_for_course( self.request, self.course, @@ -271,5 +272,39 @@ class EntranceExamTestCases(ModuleStoreTestCase): self.field_data_cache ) - for toc_section in expected_unlocked_toc: + for toc_section in self.expected_unlocked_toc: + self.assertIn(toc_section, unlocked_toc) + + def test_skip_entrance_exame_gating(self): + """ + Tests gating is disabled if skip entrance exam is set for a user. + """ + # make sure toc is locked before allowing user to skip entrance exam + locked_toc = toc_for_course( + self.request, + self.course, + self.entrance_exam.url_name, + self.exam_1.url_name, + self.field_data_cache + ) + for toc_section in self.expected_locked_toc: + self.assertIn(toc_section, locked_toc) + + # hit skip entrance exam api in instructor app + instructor = InstructorFactory(course_key=self.course.id) + self.client.login(username=instructor.username, password='test') + url = reverse('mark_student_can_skip_entrance_exam', kwargs={'course_id': unicode(self.course.id)}) + response = self.client.post(url, { + 'unique_student_identifier': self.request.user.email, + }) + self.assertEqual(response.status_code, 200) + + unlocked_toc = toc_for_course( + self.request, + self.course, + self.entrance_exam.url_name, + self.exam_1.url_name, + self.field_data_cache + ) + for toc_section in self.expected_unlocked_toc: self.assertIn(toc_section, unlocked_toc) diff --git a/lms/djangoapps/courseware/tests/test_tabs.py b/lms/djangoapps/courseware/tests/test_tabs.py index 3bf1faab4e..3a83ea42cd 100644 --- a/lms/djangoapps/courseware/tests/test_tabs.py +++ b/lms/djangoapps/courseware/tests/test_tabs.py @@ -10,6 +10,7 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey from courseware.courses import get_course_by_id from courseware.tests.helpers import get_request_for_user, LoginEnrollmentTestCase +from courseware.tests.factories import InstructorFactory from xmodule import tabs from xmodule.modulestore.tests.django_utils import ( TEST_DATA_MIXED_TOY_MODULESTORE, TEST_DATA_MIXED_CLOSED_MODULESTORE @@ -177,6 +178,29 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): self.assertEqual(course_tab_list[0]['name'], 'Entrance Exam') self.assertEqual(course_tab_list[1]['tab_id'], 'instructor') + def test_get_course_tabs_list_skipped_entrance_exam(self): + """ + Tests tab list is not limited if user is allowed to skip entrance exam. + """ + #create a user + student = UserFactory() + # login as instructor hit skip entrance exam api in instructor app + instructor = InstructorFactory(course_key=self.course.id) + self.client.logout() + self.client.login(username=instructor.username, password='test') + + url = reverse('mark_student_can_skip_entrance_exam', kwargs={'course_id': unicode(self.course.id)}) + response = self.client.post(url, { + 'unique_student_identifier': student.email, + }) + self.assertEqual(response.status_code, 200) + + # log in again as student + self.client.logout() + self.login(self.email, self.password) + course_tab_list = get_course_tab_list(self.course, self.user) + self.assertEqual(len(course_tab_list), 5) + class TextBookTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): """ diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index f458a27ac6..638db67d17 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -23,6 +23,7 @@ from django.http import HttpRequest, HttpResponse from django.test import RequestFactory, TestCase from django.test.utils import override_settings from django.utils.timezone import utc +from django.utils.translation import ugettext as _ from mock import Mock, patch from nose.tools import raises @@ -2375,13 +2376,13 @@ class TestEntranceExamInstructorAPIRegradeTask(ModuleStoreTestCase, LoginEnrollm student=self.student, course_id=self.course.id, module_state_key=self.ee_problem_1.location, - state=json.dumps({'attempts': 10}), + state=json.dumps({'attempts': 10, 'done': True}), ) ee_module_to_reset2 = StudentModule.objects.create( student=self.student, course_id=self.course.id, module_state_key=self.ee_problem_2.location, - state=json.dumps({'attempts': 10}), + state=json.dumps({'attempts': 10, 'done': True}), ) self.ee_modules = [ee_module_to_reset1.module_state_key, ee_module_to_reset2.module_state_key] @@ -2521,6 +2522,7 @@ class TestEntranceExamInstructorAPIRegradeTask(ModuleStoreTestCase, LoginEnrollm # check response tasks = json.loads(response.content)['tasks'] self.assertEqual(len(tasks), 1) + self.assertEqual(tasks[0]['status'], _('Complete')) def test_list_entrance_exam_instructor_tasks_all_student(self): """ Test list task history for entrance exam AND all student. """ @@ -2541,6 +2543,27 @@ class TestEntranceExamInstructorAPIRegradeTask(ModuleStoreTestCase, LoginEnrollm }) self.assertEqual(response.status_code, 400) + def test_skip_entrance_exam_student(self): + """ Test skip entrance exam api for student. """ + # create a re-score entrance exam task + url = reverse('mark_student_can_skip_entrance_exam', kwargs={'course_id': unicode(self.course.id)}) + response = self.client.post(url, { + 'unique_student_identifier': self.student.email, + }) + self.assertEqual(response.status_code, 200) + # check response + message = _('This student (%s) will skip the entrance exam.') % self.student.email + self.assertContains(response, message) + + # post again with same student + response = self.client.post(url, { + 'unique_student_identifier': self.student.email, + }) + + # This time response message should be different + message = _('This student (%s) is already allowed to skip the entrance exam.') % self.student.email + self.assertContains(response, message) + @override_settings(MODULESTORE=TEST_DATA_MOCK_MODULESTORE) @patch('bulk_email.models.html_to_text', Mock(return_value='Mocking CourseEmail.text_message')) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index ec8fb0e1b1..1dd904536d 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -58,7 +58,7 @@ from shoppingcart.models import ( CourseMode, CourseRegistrationCodeInvoiceItem, ) -from student.models import CourseEnrollment, unique_id_for_user, anonymous_id_for_user +from student.models import CourseEnrollment, unique_id_for_user, anonymous_id_for_user, EntranceExamConfiguration import instructor_task.api from instructor_task.api_helper import AlreadyRunningError from instructor_task.models import ReportStore @@ -2322,3 +2322,27 @@ def spoc_gradebook(request, course_id): 'staff_access': True, 'ordered_grades': sorted(course.grade_cutoffs.items(), key=lambda i: i[1], reverse=True), }) + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@require_level('staff') +@require_POST +def mark_student_can_skip_entrance_exam(request, course_id): # pylint: disable=invalid-name + """ + Mark a student to skip entrance exam. + Takes `unique_student_identifier` as required POST parameter. + """ + course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id) + student_identifier = request.POST.get('unique_student_identifier') + student = get_student_from_identifier(student_identifier) + + __, created = EntranceExamConfiguration.objects.get_or_create(user=student, course_id=course_id) + if created: + message = _('This student (%s) will skip the entrance exam.') % student_identifier + else: + message = _('This student (%s) is already allowed to skip the entrance exam.') % student_identifier + response_payload = { + 'message': message, + } + return JsonResponse(response_payload) diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 24c0df81a9..54bec7c582 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -1,4 +1,3 @@ -# pylint: disable=bad-continuation """ Instructor API endpoint urls. """ @@ -38,15 +37,27 @@ urlpatterns = patterns( 'instructor.views.api.get_student_progress_url', name="get_student_progress_url"), url(r'^reset_student_attempts$', 'instructor.views.api.reset_student_attempts', name="reset_student_attempts"), - url(r'^rescore_problem$', 'instructor.views.api.rescore_problem', name="rescore_problem"), - # entrance exam tasks - url(r'^reset_student_attempts_for_entrance_exam$', + url( # pylint: disable=bad-continuation + r'^rescore_problem$', + 'instructor.views.api.rescore_problem', + name="rescore_problem" + ), url( + r'^reset_student_attempts_for_entrance_exam$', 'instructor.views.api.reset_student_attempts_for_entrance_exam', - name="reset_student_attempts_for_entrance_exam"), - url(r'^rescore_entrance_exam$', - 'instructor.views.api.rescore_entrance_exam', name="rescore_entrance_exam"), - url(r'^list_entrance_exam_instructor_tasks', - 'instructor.views.api.list_entrance_exam_instructor_tasks', name="list_entrance_exam_instructor_tasks"), + name="reset_student_attempts_for_entrance_exam" + ), url( + r'^rescore_entrance_exam$', + 'instructor.views.api.rescore_entrance_exam', + name="rescore_entrance_exam" + ), url( + r'^list_entrance_exam_instructor_tasks', + 'instructor.views.api.list_entrance_exam_instructor_tasks', + name="list_entrance_exam_instructor_tasks" + ), url( + r'^mark_student_can_skip_entrance_exam', + 'instructor.views.api.mark_student_can_skip_entrance_exam', + name="mark_student_can_skip_entrance_exam" + ), url(r'^list_instructor_tasks$', 'instructor.views.api.list_instructor_tasks', name="list_instructor_tasks"), diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 3fb29cb651..c9955114e8 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -310,6 +310,10 @@ def _section_student_admin(course, access): ), 'rescore_problem_url': reverse('rescore_problem', kwargs={'course_id': unicode(course_key)}), 'rescore_entrance_exam_url': reverse('rescore_entrance_exam', kwargs={'course_id': unicode(course_key)}), + 'student_can_skip_entrance_exam_url': reverse( + 'mark_student_can_skip_entrance_exam', + kwargs={'course_id': unicode(course_key)}, + ), 'list_instructor_tasks_url': reverse('list_instructor_tasks', kwargs={'course_id': unicode(course_key)}), 'list_entrace_exam_instructor_tasks_url': reverse('list_entrance_exam_instructor_tasks', kwargs={'course_id': unicode(course_key)}), diff --git a/lms/djangoapps/instructor_task/views.py b/lms/djangoapps/instructor_task/views.py index a8e7eade0b..1dda3d9d61 100644 --- a/lms/djangoapps/instructor_task/views.py +++ b/lms/djangoapps/instructor_task/views.py @@ -149,6 +149,7 @@ def get_task_completion_info(instructor_task): else: student = task_input.get('student') problem_url = task_input.get('problem_url') + entrance_exam_url = task_input.get('entrance_exam_url') email_id = task_input.get('email_id') if instructor_task.task_state == PROGRESS: @@ -167,6 +168,17 @@ def get_task_completion_info(instructor_task): succeeded = True # Translators: {action} is a past-tense verb that is localized separately. {student} is a student identifier. msg_format = _("Problem successfully {action} for student '{student}'") + elif student is not None and entrance_exam_url is not None: + # this reports on actions on entrance exam for a particular student: + if num_attempted == 0: + # Translators: {action} is a past-tense verb that is localized separately. + # {student} is a student identifier. + msg_format = _("Unable to find entrance exam submission to be {action} for student '{student}'") + else: + succeeded = True + # Translators: {action} is a past-tense verb that is localized separately. + # {student} is a student identifier. + msg_format = _("Entrance exam successfully {action} for student '{student}'") elif student is None and problem_url is not None: # this reports on actions on problems for all students: if num_attempted == 0: diff --git a/lms/static/coffee/src/instructor_dashboard/student_admin.coffee b/lms/static/coffee/src/instructor_dashboard/student_admin.coffee index c3a7cb4e68..cabef93b0c 100644 --- a/lms/static/coffee/src/instructor_dashboard/student_admin.coffee +++ b/lms/static/coffee/src/instructor_dashboard/student_admin.coffee @@ -46,6 +46,7 @@ class @StudentAdmin @$btn_reset_entrance_exam_attempts = @$section.find "input[name='reset-entrance-exam-attempts']" @$btn_delete_entrance_exam_state = @$section.find "input[name='delete-entrance-exam-state']" @$btn_rescore_entrance_exam = @$section.find "input[name='rescore-entrance-exam']" + @$btn_skip_entrance_exam = @$section.find "input[name='skip-entrance-exam']" @$btn_entrance_exam_task_history = @$section.find "input[name='entrance-exam-task-history']" @$table_entrance_exam_task_history = @$section.find ".entrance-exam-task-history-table" @@ -223,6 +224,28 @@ class @StudentAdmin full_error_message = interpolate_text(error_message, {student_id: unique_student_identifier}) @$request_response_error_ee.text full_error_message + # Mark a student to skip entrance exam + @$btn_skip_entrance_exam.click => + unique_student_identifier = @$field_entrance_exam_student_select_grade.val() + if not unique_student_identifier + return @$request_response_error_ee.text gettext("Enter a student's username or email address.") + confirm_message = gettext("Do you want to allow this student ('{student_id}') to skip the entrance exam?") + full_confirm_message = interpolate_text(confirm_message, {student_id: unique_student_identifier}) + if window.confirm full_confirm_message + send_data = + unique_student_identifier: unique_student_identifier + + $.ajax + dataType: 'json' + url: @$btn_skip_entrance_exam.data 'endpoint' + data: send_data + type: 'POST' + success: @clear_errors_then (data) -> + alert data.message + error: std_ajax_err => + error_message = gettext("An error occurred. Make sure that the student's username or email address is correct and try again.") + @$request_response_error_ee.text error_message + # delete student state for entrance exam @$btn_delete_entrance_exam_state.click => unique_student_identifier = @$field_entrance_exam_student_select_grade.val() @@ -249,7 +272,7 @@ class @StudentAdmin @$btn_entrance_exam_task_history.click => unique_student_identifier = @$field_entrance_exam_student_select_grade.val() if not unique_student_identifier - return @$request_response_error_ee.text gettext("Please enter a student email address or username.") + return @$request_response_error_ee.text gettext("Enter a student's username or email address.") send_data = unique_student_identifier: unique_student_identifier diff --git a/lms/static/js/fixtures/instructor_dashboard/student_admin.html b/lms/static/js/fixtures/instructor_dashboard/student_admin.html index f6e02f61b8..cbf94bb0dc 100644 --- a/lms/static/js/fixtures/instructor_dashboard/student_admin.html +++ b/lms/static/js/fixtures/instructor_dashboard/student_admin.html @@ -94,6 +94,7 @@ +