diff --git a/lms/djangoapps/grades/api/v1/tests/test_views.py b/lms/djangoapps/grades/api/v1/tests/test_views.py index 4cbbfd6b0b..9626fbc7f1 100644 --- a/lms/djangoapps/grades/api/v1/tests/test_views.py +++ b/lms/djangoapps/grades/api/v1/tests/test_views.py @@ -21,7 +21,7 @@ from lms.djangoapps.grades.api.v1.views import CourseGradesView from lms.djangoapps.grades.config.waffle import waffle_flags, WRITABLE_GRADEBOOK from lms.djangoapps.grades.course_data import CourseData from lms.djangoapps.grades.course_grade import CourseGrade -from lms.djangoapps.grades.models import PersistentSubsectionGrade +from lms.djangoapps.grades.models import PersistentSubsectionGrade, PersistentSubsectionGradeOverrideHistory from lms.djangoapps.grades.subsection_grade import ReadSubsectionGrade from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory @@ -1308,3 +1308,11 @@ class GradebookBulkUpdateViewTest(GradebookViewTestBase): expected_value = getattr(expected_grades, field_name) self.assertEqual(expected_value, getattr(grade, field_name)) self.assertEqual(expected_value, getattr(grade.override, field_name + '_override')) + + update_records = PersistentSubsectionGradeOverrideHistory.objects.filter(user=self.global_staff) + self.assertEqual(update_records.count(), 3) + for audit_item in update_records: + self.assertEqual(audit_item.user, self.global_staff) + self.assertIsNotNone(audit_item.created) + self.assertEqual(audit_item.feature, PersistentSubsectionGradeOverrideHistory.GRADEBOOK) + self.assertEqual(audit_item.action, PersistentSubsectionGradeOverrideHistory.CREATE_OR_UPDATE) diff --git a/lms/djangoapps/grades/api/v1/views.py b/lms/djangoapps/grades/api/v1/views.py index 2ef1a412e2..fc539c3a21 100644 --- a/lms/djangoapps/grades/api/v1/views.py +++ b/lms/djangoapps/grades/api/v1/views.py @@ -24,7 +24,11 @@ from lms.djangoapps.grades.constants import ScoreDatabaseTableEnum from lms.djangoapps.grades.course_data import CourseData from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory from lms.djangoapps.grades.events import SUBSECTION_GRADE_CALCULATED, subsection_grade_calculated -from lms.djangoapps.grades.models import PersistentSubsectionGrade, PersistentSubsectionGradeOverride +from lms.djangoapps.grades.models import ( + PersistentSubsectionGrade, + PersistentSubsectionGradeOverride, + PersistentSubsectionGradeOverrideHistory +) from lms.djangoapps.grades.subsection_grade import CreateSubsectionGrade from lms.djangoapps.grades.tasks import recalculate_subsection_grade_v3, are_grades_frozen from opaque_keys import InvalidKeyError @@ -803,7 +807,7 @@ class GradebookBulkUpdateView(GradeViewMixin, PaginatedAPIView): continue if subsection_grade_model: - self._create_override(subsection_grade_model, **user_data['grade']) + self._create_override(request.user, subsection_grade_model, **user_data['grade']) result.append(GradebookUpdateResponseItem( user_id=user.id, usage_id=text_type(usage_key), @@ -826,7 +830,7 @@ class GradebookBulkUpdateView(GradeViewMixin, PaginatedAPIView): subsection_grade = CreateSubsectionGrade(subsection, course_data.structure, {}, {}) return subsection_grade.update_or_create_model(user, force_update_subsections=True) - def _create_override(self, subsection_grade_model, **override_data): + def _create_override(self, request_user, subsection_grade_model, **override_data): """ Helper method to create a `PersistentSubsectionGradeOverride` object and send a `SUBSECTION_OVERRIDE_CHANGED` signal. @@ -836,6 +840,13 @@ class GradebookBulkUpdateView(GradeViewMixin, PaginatedAPIView): defaults=self._clean_override_data(override_data), ) + _ = PersistentSubsectionGradeOverrideHistory.objects.create( + override_id=override.id, + user=request_user, + feature=PersistentSubsectionGradeOverrideHistory.GRADEBOOK, + action=PersistentSubsectionGradeOverrideHistory.CREATE_OR_UPDATE, + ) + set_event_transaction_type(SUBSECTION_GRADE_CALCULATED) create_new_event_transaction_id() diff --git a/lms/djangoapps/grades/migrations/0014_persistentsubsectiongradeoverridehistory.py b/lms/djangoapps/grades/migrations/0014_persistentsubsectiongradeoverridehistory.py new file mode 100644 index 0000000000..bc7dd0cc5b --- /dev/null +++ b/lms/djangoapps/grades/migrations/0014_persistentsubsectiongradeoverridehistory.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.16 on 2018-11-27 20:53 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('grades', '0013_persistentsubsectiongradeoverride'), + ] + + operations = [ + migrations.CreateModel( + name='PersistentSubsectionGradeOverrideHistory', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('override_id', models.IntegerField(db_index=True)), + ('feature', models.CharField(choices=[(b'PROCTORING', b'proctoring'), (b'GRADEBOOK', b'gradebook')], default=b'PROCTORING', max_length=32)), + ('action', models.CharField(choices=[(b'CREATEORUPDATE', b'create_or_update'), (b'DELETE', b'delete')], default=b'CREATEORUPDATE', max_length=32)), + ('comments', models.CharField(blank=True, max_length=300, null=True)), + ('created', models.DateTimeField(auto_now_add=True, db_index=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/lms/djangoapps/grades/models.py b/lms/djangoapps/grades/models.py index 6cfcf8b754..b90cd000e0 100644 --- a/lms/djangoapps/grades/models.py +++ b/lms/djangoapps/grades/models.py @@ -14,6 +14,7 @@ from base64 import b64encode from collections import namedtuple from hashlib import sha1 +from django.contrib.auth.models import User from django.db import models from django.utils.timezone import now from lazy import lazy @@ -636,3 +637,55 @@ class PersistentSubsectionGradeOverride(models.Model): def prefetch(user, course_key): PersistentSubsectionGradeOverride.prefetch(user.id, course_key) VisibleBlocks.bulk_read(user.id, course_key) + + +class PersistentSubsectionGradeOverrideHistory(models.Model): + """ + A django model tracking persistent grades override audit records. + """ + PROCTORING = 'PROCTORING' + GRADEBOOK = 'GRADEBOOK' + OVERRIDE_FEATURES = ( + (PROCTORING, 'proctoring'), + (GRADEBOOK, 'gradebook'), + ) + + CREATE_OR_UPDATE = 'CREATEORUPDATE' + DELETE = 'DELETE' + OVERRIDE_ACTIONS = ( + (CREATE_OR_UPDATE, 'create_or_update'), + (DELETE, 'delete') + ) + + class Meta(object): + app_label = "grades" + + override_id = models.IntegerField(db_index=True) + feature = models.CharField( + max_length=32, + choices=OVERRIDE_FEATURES, + default=PROCTORING + ) + action = models.CharField( + max_length=32, + choices=OVERRIDE_ACTIONS, + default=CREATE_OR_UPDATE + ) + user = models.ForeignKey(User, blank=True, null=True) + comments = models.CharField(max_length=300, blank=True, null=True) + created = models.DateTimeField(auto_now_add=True, db_index=True) + + def __unicode__(self): + """ + String representation of this model. + """ + return ( + u"{} override_id: {}, user_id: {}, feature: {}, action: {}, created: {}" + ).format( + type(self).__name__, + self.override_id, + self.user, + self.feature, + self.action, + self.created + ) diff --git a/lms/djangoapps/grades/services.py b/lms/djangoapps/grades/services.py index bd4abfb03c..a2d9f4f9a1 100644 --- a/lms/djangoapps/grades/services.py +++ b/lms/djangoapps/grades/services.py @@ -12,7 +12,11 @@ from track.event_transaction_utils import create_new_event_transaction_id, set_e from .config.waffle import waffle_flags, REJECTED_EXAM_OVERRIDES_GRADE from .constants import ScoreDatabaseTableEnum from .events import SUBSECTION_OVERRIDE_EVENT_TYPE -from .models import PersistentSubsectionGrade, PersistentSubsectionGradeOverride +from .models import ( + PersistentSubsectionGrade, + PersistentSubsectionGradeOverride, + PersistentSubsectionGradeOverrideHistory +) from .signals.signals import SUBSECTION_OVERRIDE_CHANGED @@ -78,6 +82,12 @@ class GradesService(object): earned_graded_override=earned_graded ) + _ = PersistentSubsectionGradeOverrideHistory.objects.create( + override_id=override.id, + feature=PersistentSubsectionGradeOverrideHistory.PROCTORING, + action=PersistentSubsectionGradeOverrideHistory.CREATE_OR_UPDATE + ) + # Cache a new event id and event type which the signal handler will use to emit a tracking log event. create_new_event_transaction_id() set_event_transaction_type(SUBSECTION_OVERRIDE_EVENT_TYPE) @@ -112,6 +122,11 @@ class GradesService(object): # Older rejected exam attempts that transition to verified might not have an override created if override is not None: + _ = PersistentSubsectionGradeOverrideHistory.objects.create( + override_id=override.id, + feature=PersistentSubsectionGradeOverrideHistory.PROCTORING, + action=PersistentSubsectionGradeOverrideHistory.DELETE + ) override.delete() # Cache a new event id and event type which the signal handler will use to emit a tracking log event. diff --git a/lms/djangoapps/grades/tests/test_services.py b/lms/djangoapps/grades/tests/test_services.py index 0cd45bc798..4d25e2b7a9 100644 --- a/lms/djangoapps/grades/tests/test_services.py +++ b/lms/djangoapps/grades/tests/test_services.py @@ -5,7 +5,11 @@ from datetime import datetime import ddt import pytz from freezegun import freeze_time -from lms.djangoapps.grades.models import PersistentSubsectionGrade, PersistentSubsectionGradeOverride +from lms.djangoapps.grades.models import ( + PersistentSubsectionGrade, + PersistentSubsectionGradeOverride, + PersistentSubsectionGradeOverrideHistory, +) from lms.djangoapps.grades.services import GradesService from mock import patch, call from student.tests.factories import UserFactory @@ -129,6 +133,12 @@ class GradesServiceTests(ModuleStoreTestCase): 'earned_graded_override': override.earned_graded_override }) + def _verify_override_history(self, override_history, history_action): + self.assertIsNone(override_history.user) + self.assertIsNotNone(override_history.created) + self.assertEqual(override_history.feature, PersistentSubsectionGradeOverrideHistory.PROCTORING) + self.assertEqual(override_history.action, history_action) + @ddt.data( [{ 'earned_all': 0.0, @@ -191,11 +201,13 @@ class GradesServiceTests(ModuleStoreTestCase): score_db_table=ScoreDatabaseTableEnum.overrides ) ) + override_history = PersistentSubsectionGradeOverrideHistory.objects.filter(override_id=override_obj.id).first() + self._verify_override_history(override_history, PersistentSubsectionGradeOverrideHistory.CREATE_OR_UPDATE) @freeze_time('2017-01-01') def test_undo_override_subsection_grade(self): override, _ = PersistentSubsectionGradeOverride.objects.update_or_create(grade=self.grade) - + override_id = override.id self.service.undo_override_subsection_grade( user_id=self.user.id, course_key_or_id=self.course.id, @@ -218,6 +230,8 @@ class GradesServiceTests(ModuleStoreTestCase): score_db_table=ScoreDatabaseTableEnum.overrides ) ) + override_history = PersistentSubsectionGradeOverrideHistory.objects.filter(override_id=override_id).first() + self._verify_override_history(override_history, PersistentSubsectionGradeOverrideHistory.DELETE) @freeze_time('2018-01-01') def test_undo_override_subsection_grade_without_grade(self):