diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 08844018af..b522d51a0f 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -1213,12 +1213,15 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): """ + # If this descriptor has been bound to a student, return the corresponding + # XModule. If not, just use the descriptor itself try: module = getattr(self, '_xmodule', None) if not module: module = self except UndefinedContext: module = self + all_descriptors = [] graded_sections = {} diff --git a/lms/djangoapps/ccx/overrides.py b/lms/djangoapps/ccx/overrides.py index 9a2c5d4e98..aced5410f8 100644 --- a/lms/djangoapps/ccx/overrides.py +++ b/lms/djangoapps/ccx/overrides.py @@ -7,6 +7,8 @@ import threading from contextlib import contextmanager +from django.db import transaction, IntegrityError + from courseware.field_overrides import FieldOverrideProvider from ccx import ACTIVE_CCX_KEY @@ -97,20 +99,29 @@ def _get_overrides_for_ccx(ccx, block): return overrides +@transaction.commit_on_success def override_field_for_ccx(ccx, block, name, value): """ Overrides a field for the `ccx`. `block` and `name` specify the block and the name of the field on that block to override. `value` is the value to set for the given field. """ - override, created = CcxFieldOverride.objects.get_or_create( + field = block.fields[name] + value = json.dumps(field.to_json(value)) + try: + override = CcxFieldOverride.objects.create( + ccx=ccx, + location=block.location, + field=name, + value=value) + except IntegrityError: + transaction.commit() + override = CcxFieldOverride.objects.get( ccx=ccx, location=block.location, field=name) - field = block.fields[name] - override.value = json.dumps(field.to_json(value)) + override.value = value override.save() - if hasattr(block, '_ccx_overrides'): del block._ccx_overrides[ccx.id] diff --git a/lms/djangoapps/ccx/tests/test_overrides.py b/lms/djangoapps/ccx/tests/test_overrides.py index 875a1c0249..c3e624a769 100644 --- a/lms/djangoapps/ccx/tests/test_overrides.py +++ b/lms/djangoapps/ccx/tests/test_overrides.py @@ -11,6 +11,7 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from ..models import CustomCourseForEdX from ..overrides import override_field_for_ccx +from .test_views import flatten, iter_blocks @override_settings(FIELD_OVERRIDE_PROVIDERS=( 'ccx.overrides.CustomCoursesForEdxOverrideProvider',)) @@ -76,6 +77,28 @@ class TestFieldOverrides(ModuleStoreTestCase): override_field_for_ccx(self.ccx, chapter, 'start', ccx_start) self.assertEquals(chapter.start, ccx_start) + def test_override_num_queries(self): + """ + Test that overriding and accessing a field produce same number of queries. + """ + ccx_start = datetime.datetime(2014, 12, 25, 00, 00, tzinfo=pytz.UTC) + chapter = self.course.get_children()[0] + with self.assertNumQueries(4): + override_field_for_ccx(self.ccx, chapter, 'start', ccx_start) + dummy = chapter.start + + def test_overriden_field_access_produces_no_extra_queries(self): + """ + Test no extra queries when accessing an overriden field more than once. + """ + ccx_start = datetime.datetime(2014, 12, 25, 00, 00, tzinfo=pytz.UTC) + chapter = self.course.get_children()[0] + with self.assertNumQueries(4): + override_field_for_ccx(self.ccx, chapter, 'start', ccx_start) + dummy1 = chapter.start + dummy2 = chapter.start + dummy3 = chapter.start + def test_override_is_inherited(self): """ Test that sequentials inherit overridden start date from chapter. @@ -98,22 +121,3 @@ class TestFieldOverrides(ModuleStoreTestCase): override_field_for_ccx(self.ccx, chapter, 'due', ccx_due) vertical = chapter.get_children()[0].get_children()[0] self.assertEqual(vertical.due, ccx_due) - - -def flatten(seq): - """ - For [[1, 2], [3, 4]] returns [1, 2, 3, 4]. Does not recurse. - """ - return [x for sub in seq for x in sub] - - -def iter_blocks(course): - """ - Returns an iterator over all of the blocks in a course. - """ - def visit(block): - yield block - for child in block.get_children(): - for descendant in visit(child): # wish they'd backport yield from - yield descendant - return visit(course) diff --git a/lms/djangoapps/ccx/tests/test_utils.py b/lms/djangoapps/ccx/tests/test_utils.py index 764dfca2ad..d9c21152f0 100644 --- a/lms/djangoapps/ccx/tests/test_utils.py +++ b/lms/djangoapps/ccx/tests/test_utils.py @@ -253,7 +253,7 @@ class TestEnrollEmail(ModuleStoreTestCase): """enroll a non-user email and send an enrollment email to them """ # ensure no emails are in the outbox now - self.assertEqual(len(self.outbox), 0) + self.assertEqual(self.outbox, []) test_email = "nobody@nowhere.com" before, after = self.call_FUT( student_email=test_email, email_students=True @@ -273,7 +273,7 @@ class TestEnrollEmail(ModuleStoreTestCase): """ self.create_user() # ensure no emails are in the outbox now - self.assertEqual(len(self.outbox), 0) + self.assertEqual(self.outbox, []) before, after = self.call_FUT(email_students=True) # there should be a membership set for this email address now @@ -290,7 +290,7 @@ class TestEnrollEmail(ModuleStoreTestCase): """ self.register_user_in_ccx() # ensure no emails are in the outbox now - self.assertEqual(len(self.outbox), 0) + self.assertEqual(self.outbox, []) before, after = self.call_FUT(email_students=True) # there should be a membership set for this email address now @@ -306,7 +306,7 @@ class TestEnrollEmail(ModuleStoreTestCase): """register a non-user via email address but send no email """ # ensure no emails are in the outbox now - self.assertEqual(len(self.outbox), 0) + self.assertEqual(self.outbox, []) test_email = "nobody@nowhere.com" before, after = self.call_FUT( student_email=test_email, email_students=False @@ -317,13 +317,13 @@ class TestEnrollEmail(ModuleStoreTestCase): for state in [before, after]: self.check_enrollment_state(state, False, None, False) # ensure there are still no emails in the outbox now - self.assertEqual(len(self.outbox), 0) + self.assertEqual(self.outbox, []) def test_enroll_non_member_no_email(self): """register a non-member but send no email""" self.create_user() # ensure no emails are in the outbox now - self.assertEqual(len(self.outbox), 0) + self.assertEqual(self.outbox, []) before, after = self.call_FUT(email_students=False) # there should be a membership set for this email address now @@ -331,14 +331,14 @@ class TestEnrollEmail(ModuleStoreTestCase): self.check_enrollment_state(before, False, self.user, True) self.check_enrollment_state(after, True, self.user, True) # ensure there are still no emails in the outbox now - self.assertEqual(len(self.outbox), 0) + self.assertEqual(self.outbox, []) def test_enroll_member_no_email(self): """enroll a member but send no email """ self.register_user_in_ccx() # ensure no emails are in the outbox now - self.assertEqual(len(self.outbox), 0) + self.assertEqual(self.outbox, []) before, after = self.call_FUT(email_students=False) # there should be a membership set for this email address now @@ -346,7 +346,7 @@ class TestEnrollEmail(ModuleStoreTestCase): for state in [before, after]: self.check_enrollment_state(state, True, self.user, True) # ensure there are still no emails in the outbox now - self.assertEqual(len(self.outbox), 0) + self.assertEqual(self.outbox, []) # TODO: deal with changes in behavior for auto_enroll @@ -364,11 +364,6 @@ class TestUnenrollEmail(ModuleStoreTestCase): self.ccx = CcxFactory(course_id=course.id, coach=coach) self.outbox = self.get_outbox() - def tearDown(self): - for attr in ['user', 'email']: - if hasattr(self, attr): - delattr(self, attr) - def get_outbox(self): """Return the django mail outbox""" from django.core import mail @@ -428,7 +423,7 @@ class TestUnenrollEmail(ModuleStoreTestCase): self.make_ccx_future_membership() # assert that a membership exists and that no emails have been sent self.assertTrue(self.check_membership(future=True)) - self.assertEqual(len(self.outbox), 0) + self.assertEqual(self.outbox, []) # unenroll the student before, after = self.call_FUT(email_students=True) @@ -447,7 +442,7 @@ class TestUnenrollEmail(ModuleStoreTestCase): self.make_ccx_membership() # assert that a membership exists and that no emails have been sent self.assertTrue(self.check_membership()) - self.assertEqual(len(self.outbox), 0) + self.assertEqual(self.outbox, []) # unenroll the student before, after = self.call_FUT(email_students=True) @@ -467,7 +462,7 @@ class TestUnenrollEmail(ModuleStoreTestCase): self.make_ccx_future_membership() # assert that a membership exists and that no emails have been sent self.assertTrue(self.check_membership(future=True)) - self.assertEqual(len(self.outbox), 0) + self.assertEqual(self.outbox, []) # unenroll the student before, after = self.call_FUT() @@ -477,7 +472,7 @@ class TestUnenrollEmail(ModuleStoreTestCase): for state in [before, after]: self.check_enrollment_state(state, False, None, False) # no email was sent to the student - self.assertEqual(len(self.outbox), 0) + self.assertEqual(self.outbox, []) def test_unenroll_member_no_email(self): """unenroll a current member but send no email @@ -485,7 +480,7 @@ class TestUnenrollEmail(ModuleStoreTestCase): self.make_ccx_membership() # assert that a membership exists and that no emails have been sent self.assertTrue(self.check_membership()) - self.assertEqual(len(self.outbox), 0) + self.assertEqual(self.outbox, []) # unenroll the student before, after = self.call_FUT() @@ -495,7 +490,7 @@ class TestUnenrollEmail(ModuleStoreTestCase): self.check_enrollment_state(after, False, self.user, True) self.check_enrollment_state(before, True, self.user, True) # no email was sent to the student - self.assertEqual(len(self.outbox), 0) + self.assertEqual(self.outbox, []) class TestUserCCXList(ModuleStoreTestCase): @@ -530,16 +525,16 @@ class TestUserCCXList(ModuleStoreTestCase): def test_anonymous_sees_no_ccx(self): memberships = self.call_FUT(self.anonymous) - self.assertEqual(len(memberships), 0) + self.assertEqual(memberships, []) def test_unenrolled_sees_no_ccx(self): memberships = self.call_FUT(self.user) - self.assertEqual(len(memberships), 0) + self.assertEqual(memberships, []) def test_enrolled_inactive_sees_no_ccx(self): self.register_user_in_ccx() memberships = self.call_FUT(self.user) - self.assertEqual(len(memberships), 0) + self.assertEqual(memberships, []) def test_enrolled_sees_a_ccx(self): self.register_user_in_ccx(active=True) diff --git a/lms/djangoapps/ccx/tests/test_views.py b/lms/djangoapps/ccx/tests/test_views.py index 1b4cb1db69..3f03288859 100644 --- a/lms/djangoapps/ccx/tests/test_views.py +++ b/lms/djangoapps/ccx/tests/test_views.py @@ -223,7 +223,7 @@ class TestCoachDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase): enrollment = CourseEnrollmentFactory(course_id=self.course.id) student = enrollment.user outbox = self.get_outbox() - self.assertEqual(len(outbox), 0) + self.assertEqual(outbox, []) url = reverse( 'ccx_invite', @@ -254,7 +254,7 @@ class TestCoachDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase): enrollment = CourseEnrollmentFactory(course_id=self.course.id) student = enrollment.user outbox = self.get_outbox() - self.assertEqual(len(outbox), 0) + self.assertEqual(outbox, []) # student is member of CCX: CcxMembershipFactory(ccx=ccx, student=student) @@ -286,7 +286,7 @@ class TestCoachDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase): self.make_coach() ccx = self.make_ccx() outbox = self.get_outbox() - self.assertEqual(len(outbox), 0) + self.assertEqual(outbox, []) url = reverse( 'ccx_invite', @@ -318,7 +318,7 @@ class TestCoachDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase): ccx = self.make_ccx() outbox = self.get_outbox() CcxFutureMembershipFactory(ccx=ccx, email=test_email) - self.assertEqual(len(outbox), 0) + self.assertEqual(outbox, []) url = reverse( 'ccx_invite', @@ -351,7 +351,7 @@ class TestCoachDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase): student = enrollment.user # no emails have been sent so far outbox = self.get_outbox() - self.assertEqual(len(outbox), 0) + self.assertEqual(outbox, []) url = reverse( 'ccx_manage_student', @@ -366,7 +366,7 @@ class TestCoachDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase): # we were redirected to our current location self.assertEqual(len(response.redirect_chain), 1) self.assertTrue(302 in response.redirect_chain[0]) - self.assertEqual(len(outbox), 0) + self.assertEqual(outbox, []) # a CcxMembership exists for this student self.assertTrue( CcxMembership.objects.filter(ccx=ccx, student=student).exists() @@ -382,7 +382,7 @@ class TestCoachDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase): CcxMembershipFactory(ccx=ccx, student=student) # no emails have been sent so far outbox = self.get_outbox() - self.assertEqual(len(outbox), 0) + self.assertEqual(outbox, []) url = reverse( 'ccx_manage_student', @@ -397,7 +397,7 @@ class TestCoachDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase): # we were redirected to our current location self.assertEqual(len(response.redirect_chain), 1) self.assertTrue(302 in response.redirect_chain[0]) - self.assertEqual(len(outbox), 0) + self.assertEqual(outbox, []) # a CcxMembership exists for this student self.assertFalse( CcxMembership.objects.filter(ccx=ccx, student=student).exists() diff --git a/lms/djangoapps/ccx/utils.py b/lms/djangoapps/ccx/utils.py index a587bce4ea..8dca532dd4 100644 --- a/lms/djangoapps/ccx/utils.py +++ b/lms/djangoapps/ccx/utils.py @@ -65,8 +65,8 @@ def enroll_email(ccx, student_email, auto_enroll=False, email_students=False, em previous_state = EmailEnrollmentState(ccx, student_email) if previous_state.user: + user = User.objects.get(email=student_email) if not previous_state.in_ccx: - user = User.objects.get(email=student_email) membership = CcxMembership( ccx=ccx, student=user, active=True ) diff --git a/lms/djangoapps/ccx/views.py b/lms/djangoapps/ccx/views.py index ddedf4fc3e..a42018639f 100644 --- a/lms/djangoapps/ccx/views.py +++ b/lms/djangoapps/ccx/views.py @@ -32,7 +32,7 @@ from courseware.grades import iterate_grades_for from courseware.model_data import FieldDataCache from courseware.module_render import get_module_for_descriptor from edxmako.shortcuts import render_to_response -from opaque_keys.edx.locations import SlashSeparatedCourseKey +from opaque_keys.edx.keys import CourseKey from student.roles import CourseCcxCoachRole from instructor.offline_gradecalc import student_grades @@ -69,7 +69,7 @@ def coach_dashboard(view): Wraps the view function, performing access check, loading the course, and modifying the view's call signature. """ - course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) + course_key = CourseKey.from_string(course_id) role = CourseCcxCoachRole(course_key) if not role.has_user(request.user): return HttpResponseForbidden( @@ -283,6 +283,8 @@ def get_ccx_schedule(course, ccx): def visit(node, depth=1): """ Recursive generator function which yields CCX schedule nodes. + We convert dates to string to get them ready for use by the js date + widgets, which use text inputs. """ for child in node.get_children(): start = get_override_for_ccx(ccx, child, 'start', None) @@ -358,7 +360,7 @@ def ccx_invite(request, course): if action == "Unenroll": unenroll_email(ccx, email, email_students=email_students) except ValidationError: - pass # maybe log this? + log.info('Invalid user name or email when trying to invite students: %s', email) url = reverse('ccx_coach_dashboard', kwargs={'course_id': course.id}) return redirect(url) @@ -389,7 +391,7 @@ def ccx_student_management(request, course): elif action == 'revoke': unenroll_email(ccx, email, email_students=False) except ValidationError: - pass # XXX: log, report? + log.info('Invalid user name or email when trying to enroll student: %s', email) url = reverse('ccx_coach_dashboard', kwargs={'course_id': course.id}) return redirect(url) @@ -499,8 +501,7 @@ def ccx_grades_csv(request, course): def switch_active_ccx(request, course_id, ccx_id=None): """set the active CCX for the logged-in user """ - user = request.user - course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) + course_key = CourseKey.from_string(course_id) # will raise Http404 if course_id is bad course = get_course_by_id(course_key) course_url = reverse( @@ -509,7 +510,7 @@ def switch_active_ccx(request, course_id, ccx_id=None): if ccx_id is not None: try: requested_ccx = CustomCourseForEdX.objects.get(pk=ccx_id) - assert requested_ccx.course_id.to_deprecated_string() == course_id + assert unicode(requested_ccx.course_id) == course_id if not CcxMembership.objects.filter( ccx=requested_ccx, student=request.user, active=True ).exists(): diff --git a/lms/djangoapps/courseware/field_overrides.py b/lms/djangoapps/courseware/field_overrides.py index a84dc001b4..e7b0c8e121 100644 --- a/lms/djangoapps/courseware/field_overrides.py +++ b/lms/djangoapps/courseware/field_overrides.py @@ -199,20 +199,7 @@ def _lineage(block): Returns an iterator over all ancestors of the given block, starting with its immediate parent and ending at the root of the block tree. """ - parent = _get_parent(block) + parent = block.get_parent() while parent: yield parent - parent = _get_parent(parent) - - -def _get_parent(block): - """ - Gets parent of a block, trying to cache result. - """ - if not hasattr(block, '_parent'): - store = modulestore() - # Call to get_parent_location is fairly expensive. Is there a more - # performant way to get at the ancestors of a block? - location = store.get_parent_location(block.location) - block._parent = store.get_item(location) if location else None - return block._parent + parent = parent.get_parent() diff --git a/lms/djangoapps/courseware/migrations/0012_auto__del_unique_studentfieldoverride_course_id_location_student__add_.py b/lms/djangoapps/courseware/migrations/0012_auto__del_unique_studentfieldoverride_course_id_location_student__add_.py new file mode 100644 index 0000000000..d79f119a24 --- /dev/null +++ b/lms/djangoapps/courseware/migrations/0012_auto__del_unique_studentfieldoverride_course_id_location_student__add_.py @@ -0,0 +1,138 @@ +# -*- 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 unique constraint on 'StudentFieldOverride', fields ['course_id', 'field', 'location', 'student'] + db.create_unique('courseware_studentfieldoverride', ['course_id', 'field', 'location', 'student_id']) + + + def backwards(self, orm): + # Removing unique constraint on 'StudentFieldOverride', fields ['course_id', 'field', 'location', 'student'] + db.delete_unique('courseware_studentfieldoverride', ['course_id', 'field', 'location', 'student_id']) + + + 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'}) + }, + 'courseware.offlinecomputedgrade': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'OfflineComputedGrade'}, + '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'}), + 'gradeset': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': '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']"}) + }, + 'courseware.offlinecomputedgradelog': { + 'Meta': {'ordering': "['-created']", 'object_name': 'OfflineComputedGradeLog'}, + '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'}), + 'nstudents': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'seconds': ('django.db.models.fields.IntegerField', [], {'default': '0'}) + }, + 'courseware.studentfieldoverride': { + 'Meta': {'unique_together': "(('course_id', 'field', 'location', 'student'),)", 'object_name': 'StudentFieldOverride'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'field': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'location': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'courseware.studentmodule': { + 'Meta': {'unique_together': "(('student', 'module_state_key', 'course_id'),)", 'object_name': 'StudentModule'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}), + 'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'module_state_key': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}), + 'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}), + 'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'courseware.studentmodulehistory': { + 'Meta': {'object_name': 'StudentModuleHistory'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}), + 'grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'student_module': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['courseware.StudentModule']"}), + 'version': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'null': 'True', 'blank': 'True'}) + }, + 'courseware.xmodulestudentinfofield': { + 'Meta': {'unique_together': "(('student', 'field_name'),)", 'object_name': 'XModuleStudentInfoField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'courseware.xmodulestudentprefsfield': { + 'Meta': {'unique_together': "(('student', 'module_type', 'field_name'),)", 'object_name': 'XModuleStudentPrefsField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'module_type': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'courseware.xmoduleuserstatesummaryfield': { + 'Meta': {'unique_together': "(('usage_id', 'field_name'),)", 'object_name': 'XModuleUserStateSummaryField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'usage_id': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + } + } + + complete_apps = ['courseware'] diff --git a/lms/djangoapps/courseware/migrations/0013_auto__add_field_studentfieldoverride_created__add_field_studentfieldov.py b/lms/djangoapps/courseware/migrations/0013_auto__add_field_studentfieldoverride_created__add_field_studentfieldov.py new file mode 100644 index 0000000000..bd67226137 --- /dev/null +++ b/lms/djangoapps/courseware/migrations/0013_auto__add_field_studentfieldoverride_created__add_field_studentfieldov.py @@ -0,0 +1,155 @@ +# -*- 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 field 'StudentFieldOverride.created' + db.add_column('courseware_studentfieldoverride', 'created', + self.gf('model_utils.fields.AutoCreatedField')(default=datetime.datetime.now), + keep_default=False) + + # Adding field 'StudentFieldOverride.modified' + db.add_column('courseware_studentfieldoverride', 'modified', + self.gf('model_utils.fields.AutoLastModifiedField')(default=datetime.datetime.now), + keep_default=False) + + # Adding index on 'StudentFieldOverride', fields ['course_id', 'location', 'student'] + db.create_index('courseware_studentfieldoverride', ['course_id', 'location', 'student_id']) + + + def backwards(self, orm): + # Deleting field 'StudentFieldOverride.created' + db.delete_column('courseware_studentfieldoverride', 'created') + + # Deleting field 'StudentFieldOverride.modified' + db.delete_column('courseware_studentfieldoverride', 'modified') + + # Removing index on 'StudentFieldOverride', fields ['course_id', 'location', 'student'] + db.delete_index('courseware_studentfieldoverride', ['course_id', 'location', 'student_id']) + + + 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'}) + }, + 'courseware.offlinecomputedgrade': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'OfflineComputedGrade'}, + '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'}), + 'gradeset': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': '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']"}) + }, + 'courseware.offlinecomputedgradelog': { + 'Meta': {'ordering': "['-created']", 'object_name': 'OfflineComputedGradeLog'}, + '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'}), + 'nstudents': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'seconds': ('django.db.models.fields.IntegerField', [], {'default': '0'}) + }, + 'courseware.studentfieldoverride': { + 'Meta': {'unique_together': "(('course_id', 'field', 'location', 'student'),)", 'object_name': 'StudentFieldOverride'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'field': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'location': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'courseware.studentmodule': { + 'Meta': {'unique_together': "(('student', 'module_state_key', 'course_id'),)", 'object_name': 'StudentModule'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}), + 'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'module_state_key': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}), + 'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}), + 'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'courseware.studentmodulehistory': { + 'Meta': {'object_name': 'StudentModuleHistory'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}), + 'grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'student_module': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['courseware.StudentModule']"}), + 'version': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'null': 'True', 'blank': 'True'}) + }, + 'courseware.xmodulestudentinfofield': { + 'Meta': {'unique_together': "(('student', 'field_name'),)", 'object_name': 'XModuleStudentInfoField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'courseware.xmodulestudentprefsfield': { + 'Meta': {'unique_together': "(('student', 'module_type', 'field_name'),)", 'object_name': 'XModuleStudentPrefsField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'module_type': ('xmodule_django.models.BlockTypeKeyField', [], {'max_length': '64', 'db_index': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'courseware.xmoduleuserstatesummaryfield': { + 'Meta': {'unique_together': "(('usage_id', 'field_name'),)", 'object_name': 'XModuleUserStateSummaryField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'usage_id': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + } + } + + complete_apps = ['courseware'] diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index 75a7afffa3..0b5e650677 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -18,6 +18,8 @@ from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver +from model_utils.models import TimeStampedModel + from xmodule_django.models import CourseKeyField, LocationKeyField, BlockTypeKeyField @@ -232,7 +234,7 @@ class OfflineComputedGradeLog(models.Model): return "[OCGLog] %s: %s" % (self.course_id.to_deprecated_string(), self.created) # pylint: disable=no-member -class StudentFieldOverride(models.Model): +class StudentFieldOverride(TimeStampedModel): """ Holds the value of a specific field overriden for a student. This is used by the code in the `courseware.student_field_overrides` module to provide @@ -243,7 +245,7 @@ class StudentFieldOverride(models.Model): student = models.ForeignKey(User, db_index=True) class Meta: # pylint: disable=missing-docstring - unique_together = (('course_id', 'location', 'student'),) + unique_together = (('course_id', 'field', 'location', 'student'),) field = models.CharField(max_length=255) value = models.TextField(default='null') diff --git a/lms/djangoapps/instructor/tests/test_tools.py b/lms/djangoapps/instructor/tests/test_tools.py index 1f6e596b63..7be040a674 100644 --- a/lms/djangoapps/instructor/tests/test_tools.py +++ b/lms/djangoapps/instructor/tests/test_tools.py @@ -242,6 +242,12 @@ class TestSetDueDateExtension(ModuleStoreTestCase): self.assertEqual(self.homework.due, extended) self.assertEqual(self.assignment.due, extended) + def test_set_due_date_extension_num_queries(self): + extended = datetime.datetime(2013, 12, 25, 0, 0, tzinfo=utc) + with self.assertNumQueries(4): + tools.set_due_date_extension(self.course, self.week1, self.user, extended) + self._clear_field_data_cache() + def test_set_due_date_extension_invalid_date(self): extended = datetime.datetime(2009, 1, 1, 0, 0, tzinfo=utc) with self.assertRaises(tools.DashboardError): diff --git a/lms/static/js/ccx/schedule.js b/lms/static/js/ccx/schedule.js index 328019c02a..9773669f49 100644 --- a/lms/static/js/ccx/schedule.js +++ b/lms/static/js/ccx/schedule.js @@ -54,55 +54,6 @@ var edx = edx || {}; self.schedule_collection.set(self.schedule); self.render(); }); - }, - - render: function() { - self.schedule = this.schedule_collection.toJSON(); - self.hidden = this.pruned(self.schedule, function(node) { - return node.hidden || node.category !== 'vertical'}); - this.showing = this.pruned(self.schedule, function(node) { - return !node.hidden}); - this.$el.html(schedule_template({chapters: this.showing})); - $('table.ccx-schedule .sequential,.vertical').hide(); - $('table.ccx-schedule .toggle-collapse').on('click', this.toggle_collapse); - // - // Hidden hover fields for empty date fields - $('table.ccx-schedule .date a').each(function() { - if (! $(this).text()) { - $(this).text('Set date').addClass('empty'); - } - }); - - // Handle date edit clicks - $('table.ccx-schedule .date a').attr('href', '#enter-date-modal') - .leanModal({closeButton: '.close-modal'}); - $('table.ccx-schedule .due-date a').on('click', this.enterNewDate('due')); - $('table.ccx-schedule .start-date a').on('click', this.enterNewDate('start')); - // Click handler for remove all - $('table.ccx-schedule a#remove-all').on('click', function(event) { - event.preventDefault(); - self.schedule_apply(self.schedule, self.hide); - self.dirty = true; - self.schedule_collection.set(self.schedule); - self.render(); - }); - - // Show or hide form - if (this.hidden.length) { - // Populate chapters select, depopulate others - this.chapter_select.html('') - .append('') - .append(self.schedule_options(this.hidden)); - this.sequential_select.html('').prop('disabled', true); - this.vertical_select.html('').prop('disabled', true); - $('form#add-unit').show(); - $('#all-units-added').hide(); - $('#add-unit-button').prop('disabled', true); - } - else { - $('form#add-unit').hide(); - $('#all-units-added').show(); - } // Add unit handlers this.chapter_select.on('change', function(event) { @@ -174,6 +125,44 @@ var edx = edx || {}; self.render(); }); + // Handle save button + $('#dirty-schedule #save-changes').on('click', function(event) { + event.preventDefault(); + self.save(); + }); + + }, + + render: function() { + self.schedule = this.schedule_collection.toJSON(); + self.hidden = this.pruned(self.schedule, function(node) { + return node.hidden || node.category !== 'vertical'}); + this.showing = this.pruned(self.schedule, function(node) { + return !node.hidden}); + this.$el.html(schedule_template({chapters: this.showing})); + $('table.ccx-schedule .sequential,.vertical').hide(); + $('table.ccx-schedule .toggle-collapse').on('click', this.toggle_collapse); + // + // Hidden hover fields for empty date fields + $('table.ccx-schedule .date a').each(function() { + if (! $(this).text()) { + $(this).text('Set date').addClass('empty'); + } + }); + + // Handle date edit clicks + $('table.ccx-schedule .date a').attr('href', '#enter-date-modal') + .leanModal({closeButton: '.close-modal'}); + $('table.ccx-schedule .due-date a').on('click', this.enterNewDate('due')); + $('table.ccx-schedule .start-date a').on('click', this.enterNewDate('start')); + // Click handler for remove all + $('table.ccx-schedule a#remove-all').on('click', function(event) { + event.preventDefault(); + self.schedule_apply(self.schedule, self.hide); + self.dirty = true; + self.schedule_collection.set(self.schedule); + self.render(); + }); // Remove unit handler $('table.ccx-schedule a.remove-unit').on('click', function(event) { var row = $(this).closest('tr'), @@ -185,16 +174,28 @@ var edx = edx || {}; self.render(); }); + + // Show or hide form + if (this.hidden.length) { + // Populate chapters select, depopulate others + this.chapter_select.html('') + .append('') + .append(self.schedule_options(this.hidden)); + this.sequential_select.html('').prop('disabled', true); + this.vertical_select.html('').prop('disabled', true); + $('form#add-unit').show(); + $('#all-units-added').hide(); + $('#add-unit-button').prop('disabled', true); + } + else { + $('form#add-unit').hide(); + $('#all-units-added').show(); + } + // Show or hide save button if (this.dirty) $('#dirty-schedule').show() else $('#dirty-schedule').hide(); - // Handle save button - $('#dirty-schedule #save-changes').on('click', function(event) { - event.preventDefault(); - self.save(); - }); - $('#ajax-error').hide(); return this;