# -*- coding: utf-8 -*- import datetime import copy import ddt from django.db import DatabaseError, connection, transaction from django.core.cache import cache from django.test import TestCase from nose.tools import raises from mock import patch import pytz from submissions import api as api from submissions.models import ScoreSummary, ScoreAnnotation, Submission, StudentItem, score_set from submissions.serializers import StudentItemSerializer STUDENT_ITEM = dict( student_id="Tim", course_id="Demo_Course", item_id="item_one", item_type="Peer_Submission", ) SECOND_STUDENT_ITEM = dict( student_id="Alice", course_id="Demo_Course", item_id="item_one", item_type="Peer_Submission", ) ANSWER_ONE = u"this is my answer!" ANSWER_TWO = u"this is my other answer!" ANSWER_THREE = u'' + 'c' * (Submission.MAXSIZE + 1) # Test a non-string JSON-serializable answer ANSWER_DICT = {"text": "foobar"} @ddt.ddt class TestSubmissionsApi(TestCase): """ Testing Submissions """ def setUp(self): """ Clear the cache. """ cache.clear() @ddt.data(ANSWER_ONE, ANSWER_DICT) def test_create_submission(self, answer): submission = api.create_submission(STUDENT_ITEM, answer) student_item = self._get_student_item(STUDENT_ITEM) self._assert_submission(submission, answer, student_item.pk, 1) def test_create_huge_submission_fails(self): with self.assertRaises(api.SubmissionRequestError): api.create_submission(STUDENT_ITEM, ANSWER_THREE) def test_get_submission_and_student(self): submission = api.create_submission(STUDENT_ITEM, ANSWER_ONE) # Retrieve the submission by its uuid retrieved = api.get_submission_and_student(submission['uuid']) self.assertItemsEqual(submission, retrieved) # Should raise an exception if the student item does not exist with self.assertRaises(api.SubmissionNotFoundError): api.get_submission_and_student(u'no such uuid') def test_get_submissions(self): api.create_submission(STUDENT_ITEM, ANSWER_ONE) api.create_submission(STUDENT_ITEM, ANSWER_TWO) submissions = api.get_submissions(STUDENT_ITEM) student_item = self._get_student_item(STUDENT_ITEM) self._assert_submission(submissions[1], ANSWER_ONE, student_item.pk, 1) self._assert_submission(submissions[0], ANSWER_TWO, student_item.pk, 2) def test_get_all_submissions(self): api.create_submission(SECOND_STUDENT_ITEM, ANSWER_TWO) api.create_submission(STUDENT_ITEM, ANSWER_ONE) api.create_submission(STUDENT_ITEM, ANSWER_TWO) api.create_submission(SECOND_STUDENT_ITEM, ANSWER_ONE) with self.assertNumQueries(1): submissions = list(api.get_all_submissions( STUDENT_ITEM['course_id'], STUDENT_ITEM['item_id'], STUDENT_ITEM['item_type'], read_replica=False, )) student_item = self._get_student_item(STUDENT_ITEM) second_student_item = self._get_student_item(SECOND_STUDENT_ITEM) # The result is assumed to be sorted by student_id, which is not part of the specification # of get_all_submissions(), but it is what it currently does. self._assert_submission(submissions[0], ANSWER_ONE, second_student_item.pk, 2) self.assertEqual(submissions[0]['student_id'], SECOND_STUDENT_ITEM['student_id']) self._assert_submission(submissions[1], ANSWER_TWO, student_item.pk, 2) self.assertEqual(submissions[1]['student_id'], STUDENT_ITEM['student_id']) def test_get_submission(self): # Test base case that we can create a submission and get it back sub_dict1 = api.create_submission(STUDENT_ITEM, ANSWER_ONE) sub_dict2 = api.get_submission(sub_dict1["uuid"]) self.assertEqual(sub_dict1, sub_dict2) # Test invalid inputs with self.assertRaises(api.SubmissionRequestError): api.get_submission(20) with self.assertRaises(api.SubmissionRequestError): api.get_submission({}) # Test not found with self.assertRaises(api.SubmissionNotFoundError): api.get_submission("notarealuuid") with self.assertRaises(api.SubmissionNotFoundError): api.get_submission("0" * 50) # This is bigger than our field size @patch.object(Submission.objects, 'get') @raises(api.SubmissionInternalError) def test_get_submission_deep_error(self, mock_get): # Test deep explosions are wrapped mock_get.side_effect = DatabaseError("Kaboom!") api.get_submission("000000000000000") def test_two_students(self): api.create_submission(STUDENT_ITEM, ANSWER_ONE) api.create_submission(SECOND_STUDENT_ITEM, ANSWER_TWO) submissions = api.get_submissions(STUDENT_ITEM) self.assertEqual(1, len(submissions)) student_item = self._get_student_item(STUDENT_ITEM) self._assert_submission(submissions[0], ANSWER_ONE, student_item.pk, 1) submissions = api.get_submissions(SECOND_STUDENT_ITEM) self.assertEqual(1, len(submissions)) student_item = self._get_student_item(SECOND_STUDENT_ITEM) self._assert_submission(submissions[0], ANSWER_TWO, student_item.pk, 1) @ddt.file_data('data/valid_student_items.json') def test_various_student_items(self, valid_student_item): api.create_submission(valid_student_item, ANSWER_ONE) student_item = self._get_student_item(valid_student_item) submission = api.get_submissions(valid_student_item)[0] self._assert_submission(submission, ANSWER_ONE, student_item.pk, 1) def test_get_latest_submission(self): past_date = datetime.datetime(2007, 9, 12, 0, 0, 0, 0, pytz.UTC) more_recent_date = datetime.datetime(2007, 9, 13, 0, 0, 0, 0, pytz.UTC) api.create_submission(STUDENT_ITEM, ANSWER_ONE, more_recent_date) api.create_submission(STUDENT_ITEM, ANSWER_TWO, past_date) # Test a limit on the submissions submissions = api.get_submissions(STUDENT_ITEM, 1) self.assertEqual(1, len(submissions)) self.assertEqual(ANSWER_ONE, submissions[0]["answer"]) self.assertEqual(more_recent_date.year, submissions[0]["submitted_at"].year) def test_set_attempt_number(self): api.create_submission(STUDENT_ITEM, ANSWER_ONE, None, 2) submissions = api.get_submissions(STUDENT_ITEM) student_item = self._get_student_item(STUDENT_ITEM) self._assert_submission(submissions[0], ANSWER_ONE, student_item.pk, 2) @raises(api.SubmissionRequestError) @ddt.file_data('data/bad_student_items.json') def test_error_checking(self, bad_student_item): api.create_submission(bad_student_item, -100) @raises(api.SubmissionRequestError) def test_error_checking_submissions(self): # Attempt number should be >= 0 api.create_submission(STUDENT_ITEM, ANSWER_ONE, None, -1) @patch.object(Submission.objects, 'filter') @raises(api.SubmissionInternalError) def test_error_on_submission_creation(self, mock_filter): mock_filter.side_effect = DatabaseError("Bad things happened") api.create_submission(STUDENT_ITEM, ANSWER_ONE) def test_create_non_json_answer(self): with self.assertRaises(api.SubmissionRequestError): api.create_submission(STUDENT_ITEM, datetime.datetime.now()) def test_load_non_json_answer(self): submission = api.create_submission(STUDENT_ITEM, ANSWER_ONE) sub_model = Submission.objects.get(uuid=submission['uuid']) # This should never happen, if folks are using the public API. # Create a submission with a raw answer that is NOT valid JSON with transaction.atomic(): query = "UPDATE submissions_submission SET raw_answer = '}' WHERE id = %s" connection.cursor().execute(query, [str(sub_model.id)]) with self.assertRaises(api.SubmissionInternalError): api.get_submission(sub_model.uuid) with self.assertRaises(api.SubmissionInternalError): api.get_submission_and_student(sub_model.uuid) @patch.object(StudentItemSerializer, 'save') @raises(api.SubmissionInternalError) def test_create_student_item_validation(self, mock_save): mock_save.side_effect = DatabaseError("Bad things happened") api.create_submission(STUDENT_ITEM, ANSWER_ONE) def test_unicode_enforcement(self): api.create_submission(STUDENT_ITEM, "Testing unicode answers.") submissions = api.get_submissions(STUDENT_ITEM, 1) self.assertEqual(u"Testing unicode answers.", submissions[0]["answer"]) def _assert_submission(self, submission, expected_answer, expected_item, expected_attempt): self.assertIsNotNone(submission) self.assertEqual(submission["answer"], expected_answer) self.assertEqual(submission["student_item"], expected_item) self.assertEqual(submission["attempt_number"], expected_attempt) def _get_student_item(self, student_item): return StudentItem.objects.get( student_id=student_item["student_id"], course_id=student_item["course_id"], item_id=student_item["item_id"] ) def test_caching(self): sub = api.create_submission(STUDENT_ITEM, "Hello World!") # The first request to get the submission hits the database... with self.assertNumQueries(1): db_sub = api.get_submission(sub["uuid"]) # The next one hits the cache only... with self.assertNumQueries(0): cached_sub = api.get_submission(sub["uuid"]) # The data that gets passed back matches the original in both cases self.assertEqual(sub, db_sub) self.assertEqual(sub, cached_sub) """ Testing Scores """ def test_create_score(self): submission = api.create_submission(STUDENT_ITEM, ANSWER_ONE) student_item = self._get_student_item(STUDENT_ITEM) self._assert_submission(submission, ANSWER_ONE, student_item.pk, 1) api.set_score(submission["uuid"], 11, 12) score = api.get_latest_score_for_submission(submission["uuid"]) self._assert_score(score, 11, 12) self.assertFalse(ScoreAnnotation.objects.all().exists()) @patch.object(score_set, 'send') def test_set_score_signal(self, send_mock): submission = api.create_submission(STUDENT_ITEM, ANSWER_ONE) api.set_score(submission['uuid'], 11, 12) # Verify that the send method was properly called send_mock.assert_called_with( sender=None, points_possible=12, points_earned=11, anonymous_user_id=STUDENT_ITEM['student_id'], course_id=STUDENT_ITEM['course_id'], item_id=STUDENT_ITEM['item_id'] ) @ddt.data(u"First score was incorrect", u"☃") def test_set_score_with_annotation(self, reason): submission = api.create_submission(STUDENT_ITEM, ANSWER_ONE) creator_uuid = "Bob" annotation_type = "staff_override" api.set_score(submission["uuid"], 11, 12, creator_uuid, annotation_type, reason) score = api.get_latest_score_for_submission(submission["uuid"]) self._assert_score(score, 11, 12) # We need to do this to verify that one score annotation exists and was # created for this score. We do not have an api point for retrieving # annotations, and it doesn't make sense to expose them, since they're # for auditing purposes. annotations = ScoreAnnotation.objects.all() self.assertGreater(len(annotations), 0) annotation = annotations[0] self.assertEqual(annotation.score.points_earned, 11) self.assertEqual(annotation.score.points_possible, 12) self.assertEqual(annotation.annotation_type, annotation_type) self.assertEqual(annotation.creator, creator_uuid) self.assertEqual(annotation.reason, reason) def test_get_score(self): 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']) def test_get_score_for_submission_hidden_score(self): # Create a "hidden" score for the submission # (by convention, a score with points possible set to 0) submission = api.create_submission(STUDENT_ITEM, ANSWER_ONE) api.set_score(submission["uuid"], 0, 0) # Expect that the retrieved score is None score = api.get_latest_score_for_submission(submission['uuid']) self.assertIs(score, None) def test_get_score_no_student_id(self): student_item = copy.deepcopy(STUDENT_ITEM) student_item['student_id'] = None self.assertIs(api.get_score(student_item), None) def test_get_scores(self): student_item = copy.deepcopy(STUDENT_ITEM) student_item["course_id"] = "get_scores_course" student_item["item_id"] = "i4x://a/b/c/s1" s1 = api.create_submission(student_item, "Hello World") student_item["item_id"] = "i4x://a/b/c/s2" s2 = api.create_submission(student_item, "Hello World") student_item["item_id"] = "i4x://a/b/c/s3" s3 = api.create_submission(student_item, "Hello World") api.set_score(s1['uuid'], 3, 5) api.set_score(s1['uuid'], 4, 5) api.set_score(s1['uuid'], 2, 5) # Should overwrite previous lines api.set_score(s2['uuid'], 0, 10) api.set_score(s3['uuid'], 4, 4) # Getting the scores for a user should never take more than one query with self.assertNumQueries(1): scores = api.get_scores( student_item["course_id"], student_item["student_id"] ) self.assertEqual( scores, { u"i4x://a/b/c/s1": (2, 5), u"i4x://a/b/c/s2": (0, 10), u"i4x://a/b/c/s3": (4, 4), } ) def test_get_top_submissions(self): student_item_1 = copy.deepcopy(STUDENT_ITEM) student_item_1['student_id'] = 'Tim' student_item_2 = copy.deepcopy(STUDENT_ITEM) student_item_2['student_id'] = 'Bob' student_item_3 = copy.deepcopy(STUDENT_ITEM) student_item_3['student_id'] = 'Li' student_1 = api.create_submission(student_item_1, "Hello World") student_2 = api.create_submission(student_item_2, "Hello World") student_3 = api.create_submission(student_item_3, "Hello World") api.set_score(student_1['uuid'], 8, 10) api.set_score(student_2['uuid'], 4, 10) api.set_score(student_3['uuid'], 2, 10) # Get top scores works correctly with self.assertNumQueries(1): top_scores = api.get_top_submissions( STUDENT_ITEM["course_id"], STUDENT_ITEM["item_id"], "Peer_Submission", 3, use_cache=False, read_replica=False, ) self.assertEqual( top_scores, [ { 'content': "Hello World", 'score': 8 }, { 'content': "Hello World", 'score': 4 }, { 'content': "Hello World", 'score': 2 }, ] ) # Fewer top scores available than the number requested. top_scores = api.get_top_submissions( STUDENT_ITEM["course_id"], STUDENT_ITEM["item_id"], "Peer_Submission", 10, use_cache=False, read_replica=False ) self.assertEqual( top_scores, [ { 'content': "Hello World", 'score': 8 }, { 'content': "Hello World", 'score': 4 }, { 'content': "Hello World", 'score': 2 }, ] ) # More top scores available than the number requested. top_scores = api.get_top_submissions( STUDENT_ITEM["course_id"], STUDENT_ITEM["item_id"], "Peer_Submission", 2, use_cache=False, read_replica=False ) self.assertEqual( top_scores, [ { 'content': "Hello World", 'score': 8 }, { 'content': "Hello World", 'score': 4 } ] ) def test_get_top_submissions_with_score_greater_than_zero(self): student_item_1 = copy.deepcopy(STUDENT_ITEM) student_item_1['student_id'] = 'Tim' student_item_2 = copy.deepcopy(STUDENT_ITEM) student_item_2['student_id'] = 'Bob' student_item_3 = copy.deepcopy(STUDENT_ITEM) student_item_3['student_id'] = 'Li' student_1 = api.create_submission(student_item_1, "Hello World") student_2 = api.create_submission(student_item_2, "Hello World") student_3 = api.create_submission(student_item_3, "Hello World") api.set_score(student_1['uuid'], 8, 10) api.set_score(student_2['uuid'], 4, 10) # These scores should not appear in top submissions. # because we are considering the scores which are # latest and greater than 0. api.set_score(student_3['uuid'], 5, 10) api.set_score(student_3['uuid'], 0, 10) # Get greater than 0 top scores works correctly with self.assertNumQueries(1): top_scores = api.get_top_submissions( STUDENT_ITEM["course_id"], STUDENT_ITEM["item_id"], "Peer_Submission", 3, use_cache=False, read_replica=False, ) self.assertEqual( top_scores, [ { 'content': "Hello World", 'score': 8 }, { 'content': "Hello World", 'score': 4 } ] ) def test_get_top_submissions_from_cache(self): student_item_1 = copy.deepcopy(STUDENT_ITEM) student_item_1['student_id'] = 'Tim' student_item_2 = copy.deepcopy(STUDENT_ITEM) student_item_2['student_id'] = 'Bob' student_item_3 = copy.deepcopy(STUDENT_ITEM) student_item_3['student_id'] = 'Li' student_1 = api.create_submission(student_item_1, "Hello World") student_2 = api.create_submission(student_item_2, "Hello World") student_3 = api.create_submission(student_item_3, "Hello World") api.set_score(student_1['uuid'], 8, 10) api.set_score(student_2['uuid'], 4, 10) api.set_score(student_3['uuid'], 2, 10) # The first call should hit the database with self.assertNumQueries(1): scores = api.get_top_submissions( STUDENT_ITEM["course_id"], STUDENT_ITEM["item_id"], STUDENT_ITEM["item_type"], 2, use_cache=True, read_replica=False ) self.assertEqual(scores, [ {"content": "Hello World", "score": 8}, {"content": "Hello World", "score": 4}, ]) # The second call should use the cache with self.assertNumQueries(0): cached_scores = api.get_top_submissions( STUDENT_ITEM["course_id"], STUDENT_ITEM["item_id"], STUDENT_ITEM["item_type"], 2, use_cache=True, read_replica=False ) self.assertEqual(cached_scores, scores) def test_get_top_submissions_from_cache_having_greater_than_0_score(self): student_item_1 = copy.deepcopy(STUDENT_ITEM) student_item_1['student_id'] = 'Tim' student_item_2 = copy.deepcopy(STUDENT_ITEM) student_item_2['student_id'] = 'Bob' student_item_3 = copy.deepcopy(STUDENT_ITEM) student_item_3['student_id'] = 'Li' student_1 = api.create_submission(student_item_1, "Hello World") student_2 = api.create_submission(student_item_2, "Hello World") student_3 = api.create_submission(student_item_3, "Hello World") api.set_score(student_1['uuid'], 8, 10) api.set_score(student_2['uuid'], 4, 10) api.set_score(student_3['uuid'], 0, 10) # The first call should hit the database with self.assertNumQueries(1): scores = api.get_top_submissions( STUDENT_ITEM["course_id"], STUDENT_ITEM["item_id"], STUDENT_ITEM["item_type"], 3, use_cache=True, read_replica=False ) self.assertEqual(scores, [ {"content": "Hello World", "score": 8}, {"content": "Hello World", "score": 4}, ]) # The second call should use the cache with self.assertNumQueries(0): cached_scores = api.get_top_submissions( STUDENT_ITEM["course_id"], STUDENT_ITEM["item_id"], STUDENT_ITEM["item_type"], 3, use_cache=True, read_replica=False ) 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) def test_error_on_get_top_submissions_too_few(self): student_item = copy.deepcopy(STUDENT_ITEM) student_item["course_id"] = "get_scores_course" student_item["item_id"] = "i4x://a/b/c/s1" api.get_top_submissions( student_item["course_id"], student_item["item_id"], "Peer_Submission", 0, read_replica=False ) @raises(api.SubmissionRequestError) def test_error_on_get_top_submissions_too_many(self): student_item = copy.deepcopy(STUDENT_ITEM) student_item["course_id"] = "get_scores_course" student_item["item_id"] = "i4x://a/b/c/s1" api.get_top_submissions( student_item["course_id"], student_item["item_id"], "Peer_Submission", api.MAX_TOP_SUBMISSIONS + 1, read_replica=False ) @patch.object(ScoreSummary.objects, 'filter') @raises(api.SubmissionInternalError) def test_error_on_get_top_submissions_db_error(self, mock_filter): mock_filter.side_effect = DatabaseError("Bad things happened") student_item = copy.deepcopy(STUDENT_ITEM) api.get_top_submissions( student_item["course_id"], student_item["item_id"], "Peer_Submission", 1, read_replica=False ) @patch.object(ScoreSummary.objects, 'filter') @raises(api.SubmissionInternalError) def test_error_on_get_scores(self, mock_filter): mock_filter.side_effect = DatabaseError("Bad things happened") api.get_scores("some_course", "some_student") def _assert_score(self, score, expected_points_earned, expected_points_possible): self.assertIsNotNone(score) self.assertEqual(score["points_earned"], expected_points_earned) self.assertEqual(score["points_possible"], expected_points_possible)