Commit 86b9122c by Eric Fischer

Soft-delete Submissions to reset student state

Previous methods of "deleting state" for a student had just issued
reset scores, so the (unscored) submission was still hanging around.
With this change, the submission can be "deleted" for all django-
relevant purposes, while still remaining in the database in case
it turns out to be interesting to analytics later.

Includes a migration that may take some time to run, and will acquire a
table lock. This will cause an outage for any other request trying to
hit the submissions table during the migration. We've estimated the outage
to last approximately 3 minutes on a database with 700,000 rows.

Test also included.
parent 02ccd59d
...@@ -217,7 +217,7 @@ def get_submission(submission_uuid, read_replica=False): ...@@ -217,7 +217,7 @@ def get_submission(submission_uuid, read_replica=False):
msg="submission_uuid ({!r}) must be a string type".format(submission_uuid) msg="submission_uuid ({!r}) must be a string type".format(submission_uuid)
) )
cache_key = "submissions.submission.{}".format(submission_uuid) cache_key = Submission.get_cache_key(submission_uuid)
try: try:
cached_submission_data = cache.get(cache_key) cached_submission_data = cache.get(cache_key)
except Exception as ex: except Exception as ex:
...@@ -627,8 +627,10 @@ def get_latest_score_for_submission(submission_uuid, read_replica=False): ...@@ -627,8 +627,10 @@ def get_latest_score_for_submission(submission_uuid, read_replica=False):
""" """
try: try:
# Ensure that submission_uuid is valid before fetching score
submission_model = Submission.objects.get(uuid=submission_uuid)
score_qs = Score.objects.filter( score_qs = Score.objects.filter(
submission__uuid=submission_uuid submission__uuid=submission_model.uuid
).order_by("-id").select_related("submission") ).order_by("-id").select_related("submission")
if read_replica: if read_replica:
...@@ -637,13 +639,13 @@ def get_latest_score_for_submission(submission_uuid, read_replica=False): ...@@ -637,13 +639,13 @@ def get_latest_score_for_submission(submission_uuid, read_replica=False):
score = score_qs[0] score = score_qs[0]
if score.is_hidden(): if score.is_hidden():
return None return None
except IndexError: except (IndexError, Submission.DoesNotExist):
return None return None
return ScoreSerializer(score).data return ScoreSerializer(score).data
def reset_score(student_id, course_id, item_id): def reset_score(student_id, course_id, item_id, clear_state=False):
""" """
Reset scores for a specific student on a specific problem. Reset scores for a specific student on a specific problem.
...@@ -655,6 +657,7 @@ def reset_score(student_id, course_id, item_id): ...@@ -655,6 +657,7 @@ def reset_score(student_id, course_id, item_id):
student_id (unicode): The ID of the student for whom to reset scores. student_id (unicode): The ID of the student for whom to reset scores.
course_id (unicode): The ID of the course containing the item to reset. course_id (unicode): The ID of the course containing the item to reset.
item_id (unicode): The ID of the item for which to reset scores. item_id (unicode): The ID of the item for which to reset scores.
clear_state (bool): If True, will appear to delete any submissions associated with the specified StudentItem
Returns: Returns:
None None
...@@ -684,6 +687,16 @@ def reset_score(student_id, course_id, item_id): ...@@ -684,6 +687,16 @@ def reset_score(student_id, course_id, item_id):
item_id=item_id, item_id=item_id,
) )
if clear_state:
for sub in student_item.submission_set.all():
# soft-delete the Submission
sub.status = Submission.DELETED
sub.save(update_fields=["status"])
# Also clear out cached values
cache_key = Submission.get_cache_key(sub.uuid)
cache.delete(cache_key)
except DatabaseError: except DatabaseError:
msg = ( msg = (
u"Error occurred while reseting scores for" u"Error occurred while reseting scores for"
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('submissions', '0002_auto_20151119_0913'),
]
operations = [
migrations.AddField(
model_name='submission',
name='status',
field=models.CharField(default=b'A', max_length=1, choices=[(b'D', b'Deleted'), (b'A', b'Active')]),
),
]
...@@ -124,6 +124,27 @@ class Submission(models.Model): ...@@ -124,6 +124,27 @@ class Submission(models.Model):
# name so it continues to use `raw_answer`. # name so it continues to use `raw_answer`.
answer = JSONField(blank=True, db_column="raw_answer") answer = JSONField(blank=True, db_column="raw_answer")
# Has this submission been soft-deleted? This allows instructors to reset student
# state on an item, while preserving the previous value for potential analytics use.
DELETED = 'D'
ACTIVE = 'A'
STATUS_CHOICES = (
(DELETED, 'Deleted'),
(ACTIVE, 'Active'),
)
status = models.CharField(max_length=1, choices=STATUS_CHOICES, default=ACTIVE)
# Override the default Manager with our custom one to filter out soft-deleted items
class SoftDeletedManager(models.Manager):
def get_queryset(self):
return super(Submission.SoftDeletedManager, self).get_queryset().exclude(status=Submission.DELETED)
objects = SoftDeletedManager()
@staticmethod
def get_cache_key(sub_uuid):
return "submissions.submission.{}".format(sub_uuid)
def __repr__(self): def __repr__(self):
return repr(dict( return repr(dict(
uuid=self.uuid, uuid=self.uuid,
......
...@@ -573,6 +573,27 @@ class TestSubmissionsApi(TestCase): ...@@ -573,6 +573,27 @@ class TestSubmissionsApi(TestCase):
) )
self.assertEqual(cached_scores, scores) self.assertEqual(cached_scores, scores)
def test_clear_state(self):
# Create a submission, give it a score, and verify that score exists
submission = api.create_submission(STUDENT_ITEM, ANSWER_ONE)
api.set_score(submission["uuid"], 11, 12)
score = api.get_score(STUDENT_ITEM)
self._assert_score(score, 11, 12)
self.assertEqual(score['submission_uuid'], submission['uuid'])
# Reset the score with clear_state=True
# This should set the submission's score to None, and make it unavailable to get_submissions
api.reset_score(
STUDENT_ITEM["student_id"],
STUDENT_ITEM["course_id"],
STUDENT_ITEM["item_id"],
clear_state=True,
)
score = api.get_score(STUDENT_ITEM)
self.assertIsNone(score)
subs = api.get_submissions(STUDENT_ITEM)
self.assertEqual(subs, [])
@raises(api.SubmissionRequestError) @raises(api.SubmissionRequestError)
def test_error_on_get_top_submissions_too_few(self): def test_error_on_get_top_submissions_too_few(self):
student_item = copy.deepcopy(STUDENT_ITEM) student_item = copy.deepcopy(STUDENT_ITEM)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment