Example #1
0
    def test_send_scores_when_not_successful(self):
        """send_scores generates proper request and does not update anything when not successful."""

        potions_val_exam: Exam = Exam.objects.get(id=2)
        some_orca: ScoresOrchestration = ScoresOrchestration(
            self.api_handler, potions_val_exam)
        val_subs: List[Submission] = list(
            some_orca.exam.submissions.filter(transmitted=False))
        scores_to_send: List[Dict[str, str]] = [
            sub.prepare_score() for sub in val_subs
        ]

        with patch.object(ApiUtil, 'api_call', autospec=True) as mock_send:
            mock_send.return_value = MagicMock(spec=Response,
                                               status_code=404,
                                               text=json.dumps({}))
            some_orca.send_scores(val_subs)

        mock_send.assert_called_with(self.api_handler,
                                     MPATHWAYS_URL,
                                     MPATHWAYS_SCOPE,
                                     'PUT',
                                     payload=json.dumps({
                                         'putPlcExamScore': {
                                             'Student': scores_to_send
                                         }
                                     }),
                                     api_specific_headers=[{
                                         'Content-Type':
                                         'application/json'
                                     }])
        self.assertEqual(mock_send.call_count, 1)
        untransmitted_qs: QuerySet = some_orca.exam.submissions.filter(
            transmitted=False)
        self.assertEqual(len(untransmitted_qs), 2)
Example #2
0
    def test_get_sub_dicts_for_exam_discards_subs_with_null_scores(self):
        """
        get_sub_dicts_for_exam discards a Canvas submission without a score and keeps another submission with a score.
        """
        dada_place_exam: Exam = Exam.objects.get(id=3)
        some_orca: ScoresOrchestration = ScoresOrchestration(
            self.api_handler, dada_place_exam)

        with patch('pe.orchestration.api_call_with_retries',
                   autospec=True) as mock_retry_func:
            mock_retry_func.return_value = MagicMock(
                spec=Response,
                ok=True,
                links={},
                text=json.dumps(self.canvas_dada_place_subs_two))
            with self.assertLogs(level='INFO') as cm:
                sub_dicts: List[Dict[
                    str, Any]] = some_orca.get_sub_dicts_for_exam()

        self.assertEqual(len(sub_dicts), 1)
        self.assertTrue(
            'INFO:pe.orchestration:Discarded 1 Canvas submission(s) with no score(s)'
            in cm.output)
        sub_dict: Dict[str, Any] = sub_dicts[0]
        self.assertEqual(
            (sub_dict['id'], sub_dict['score'], sub_dict['user']['login_id']),
            (888889, 600.0, 'hpotter'))
Example #3
0
    def test_create_sub_records_with_null_submitted_timestamp_and_attempt_num(
            self):
        """
        create_sub_records stores submissions when submitted_timestamp and attempt_num are not provided.
        The fixture used mimics what a submission would look like if a grade was entered manually.
        """
        dada_place_exam: Exam = Exam.objects.get(id=3)
        some_orca: ScoresOrchestration = ScoresOrchestration(
            self.api_handler, dada_place_exam)
        some_orca.create_sub_records(self.canvas_dada_place_subs_one)

        new_dada_place_sub_qs: QuerySet = some_orca.exam.submissions.filter(
            submission_id=888888)
        self.assertEqual(len(new_dada_place_sub_qs), 1)
        self.assertEqual(
            len(
                new_dada_place_sub_qs.filter(transmitted=False,
                                             transmitted_timestamp=None)), 1)

        sub_dict: Dict[str, Any] = new_dada_place_sub_qs.values(
            *self.test_sub_fields).first()
        self.assertEqual(
            sub_dict, {
                'submission_id': 888888,
                'attempt_num': None,
                'student_uniqname': 'nlongbottom',
                'score': 500.0,
                'submitted_timestamp': None,
                'graded_timestamp': datetime(
                    2020, 7, 7, 13, 22, 49, tzinfo=utc)
            })
Example #4
0
    def test_send_scores_when_mix_of_success_and_error(self):
        """
        send_scores updates exam-specific records with transmitted as True and timestamp only when successful.
        """
        resp_data: Dict[str, Any] = self.mpathways_resp_data[1]
        current_dt: datetime = datetime.now(tz=utc)
        potions_val_exam: Exam = Exam.objects.get(id=2)
        some_orca: ScoresOrchestration = ScoresOrchestration(
            self.api_handler, potions_val_exam)
        val_subs: List[Submission] = some_orca.exam.submissions.filter(
            transmitted=False).all()

        with patch.object(ApiUtil, 'api_call', autospec=True) as mock_send:
            mock_send.return_value = MagicMock(spec=Response,
                                               status_code=200,
                                               text=json.dumps(resp_data))
            some_orca.send_scores(val_subs)

        self.assertEqual(
            len(
                Submission.objects.filter(exam=potions_val_exam,
                                          transmitted=True)), 1)
        updated_subs_qs: QuerySet = Submission.objects.filter(
            exam=potions_val_exam, transmitted_timestamp__gt=current_dt)
        self.assertEqual(len(updated_subs_qs), 1)
        uniqname: str = updated_subs_qs.first().student_uniqname
        self.assertEqual(uniqname, 'rweasley')

        # Ensure un-transmitted submission for another exam (Potions Placement)
        # with the same uniqname (rweasley) was not updated.
        self.assertFalse(
            Submission.objects.get(submission_id=123458).transmitted)
Example #5
0
    def test_constructor_uses_default_filter_when_no_subs(self):
        """
        Constructor assigns the exam's default_time_filter to sub_time_filter when the exam has no previous submissions.
        """
        dada_place_exam: Exam = Exam.objects.get(id=3)

        some_orca: ScoresOrchestration = ScoresOrchestration(
            self.api_handler, dada_place_exam)
        self.assertEqual(some_orca.sub_time_filter,
                         datetime(2020, 7, 1, 0, 0, 0, tzinfo=utc))
Example #6
0
    def setUp(self):
        """
        Initializes report; runs orchestrations, gathering time metadata; and sets up other shared variables.
        """
        api_handler: ApiUtil = ApiUtil(
            os.getenv('API_DIR_URL', ''),
            os.getenv('API_DIR_CLIENT_ID', ''),
            os.getenv('API_DIR_SECRET', ''),
            os.path.join(ROOT_DIR, 'config', 'apis.json')
        )

        with open(os.path.join(API_FIXTURES_DIR, 'canvas_subs.json'), 'r') as test_canvas_subs_file:
            canvas_subs_dict: Dict[str, List[Dict[str, Any]]] = json.loads(test_canvas_subs_file.read())

        canvas_potions_val_subs: List[Dict[str, Any]] = canvas_subs_dict['Potions_Validation_1']

        with open(os.path.join(API_FIXTURES_DIR, 'mpathways_resp_data.json'), 'r') as mpathways_resp_data_file:
            mpathways_resp_data: List[Dict[str, Any]] = json.loads(mpathways_resp_data_file.read())

        self.potions_report = Report.objects.get(id=1)

        with patch('pe.orchestration.api_call_with_retries', autospec=True) as mock_get:
            with patch.object(ApiUtil, 'api_call', autospec=True) as mock_send:
                mock_get.side_effect = [
                    # Potions Placement - no more new submissions
                    MagicMock(spec=Response, status_code=200, text=json.dumps([])),
                    # Potions Validation - two more new submissions
                    MagicMock(spec=Response, status_code=200, text=json.dumps(canvas_potions_val_subs))
                ]
                mock_send.side_effect = [
                    # Potions Placement - Only rweasley sub from test_04.json, fails to send
                    MagicMock(spec=Response, status_code=200, text=json.dumps(mpathways_resp_data[5])),
                    # Potions Validation - Four subs, two from canvas_subs.json, two from test_04.json, all send
                    MagicMock(spec=Response, status_code=200, text=json.dumps(mpathways_resp_data[2]))
                ]

                fake_running_dt: datetime = datetime(2020, 6, 25, 16, 0, 0, tzinfo=utc)
                self.exams_time_metadata: Dict[int, Dict[str, datetime]] = dict()
                for exam in self.potions_report.exams.all():
                    start: datetime = fake_running_dt
                    exam_orca: ScoresOrchestration = ScoresOrchestration(api_handler, exam)
                    exam_orca.main()
                    fake_running_dt += timedelta(seconds=5)
                    end: datetime = fake_running_dt
                    self.exams_time_metadata[exam.id] = {
                        'start_time': start,
                        'end_time': end,
                        'sub_time_filter': exam_orca.sub_time_filter
                    }
                    fake_running_dt += timedelta(seconds=1)

        self.fake_finished_at: datetime = fake_running_dt
        self.expected_subject: str = (
            'Placement Exams Report - Potions - Success: 4, Failure: 1, New: 2 - Run finished at 2020-06-25 12:00 PM'
        )
Example #7
0
    def test_constructor_uses_latest_graded_dt_when_subs(self):
        """
        Constructor assigns last submission graded datetime to sub_time_filter when exam has previous submissions.
        """
        potions_place_exam: Exam = Exam.objects.get(id=1)
        some_orca: ScoresOrchestration = ScoresOrchestration(
            self.api_handler, potions_place_exam)

        # Extra second for standard one-second increment
        self.assertEqual(some_orca.sub_time_filter,
                         datetime(2020, 6, 12, 16, 0, 1, tzinfo=utc))
Example #8
0
    def test_send_scores_when_successful(self):
        """
        send_scores properly transmits data to M-Pathways API and updates all submission records.
        """
        resp_data: Dict[str, Any] = self.mpathways_resp_data[0]
        current_dt: datetime = datetime.now(tz=utc)
        potions_val_exam: Exam = Exam.objects.get(id=2)
        some_orca: ScoresOrchestration = ScoresOrchestration(
            self.api_handler, potions_val_exam)
        val_subs: List[Submission] = list(
            some_orca.exam.submissions.filter(transmitted=False))
        scores_to_send: List[Dict[str, str]] = [
            sub.prepare_score() for sub in val_subs
        ]

        with patch.object(ApiUtil, 'api_call', autospec=True) as mock_api_call:
            mock_api_call.return_value = MagicMock(spec=Response,
                                                   status_code=200,
                                                   text=json.dumps(resp_data))
            some_orca.send_scores(val_subs)

        self.assertEqual(mock_api_call.call_count, 1)
        mock_api_call.assert_called_with(self.api_handler,
                                         MPATHWAYS_URL,
                                         MPATHWAYS_SCOPE,
                                         'PUT',
                                         payload=json.dumps({
                                             'putPlcExamScore': {
                                                 'Student': scores_to_send
                                             }
                                         }),
                                         api_specific_headers=[{
                                             'Content-Type':
                                             'application/json'
                                         }])

        self.assertEqual(
            len(
                Submission.objects.filter(exam=potions_val_exam,
                                          transmitted=True)), 2)
        updated_subs_qs: QuerySet = Submission.objects.filter(
            exam=potions_val_exam, transmitted_timestamp__gt=current_dt)
        self.assertEqual(len(updated_subs_qs), 2)
        uniqnames: List[Tuple[str]] = list(
            updated_subs_qs.order_by('student_uniqname').values_list(
                'student_uniqname'))
        self.assertEqual(uniqnames, [('nlongbottom', ), ('rweasley', )])

        # Ensure un-transmitted submission for another exam (Potions Placement)
        # with the same uniqname (rweasley) was not updated.
        self.assertFalse(
            Submission.objects.get(submission_id=123458).transmitted)
Example #9
0
    def test_main_with_exam_scores_with_duplicate_uniqnames_sent_on_same_run(
            self):
        """
        The main process pulls, stores, and sends scores with duplicate uniqnames on the same run.
        """
        current_dt: datetime = datetime.now(tz=utc)
        potions_place_exam: Exam = Exam.objects.get(id=1)
        some_orca: ScoresOrchestration = ScoresOrchestration(
            self.api_handler, potions_place_exam)

        dup_send_mocks: List[MagicMock] = [
            # Simulate network error which could lead to duplicates sent in one process run
            MagicMock(spec=Response, status_code=504, text=json.dumps({})),
            MagicMock(spec=Response,
                      status_code=200,
                      text=json.dumps(self.mpathways_resp_data[3]))
        ]
        # Though request may be different, the response will look exactly the same
        dup_send_mocks += [
            MagicMock(spec=Response,
                      status_code=200,
                      text=json.dumps(self.mpathways_resp_data[4]))
            for i in range(2)
        ]

        with patch('pe.orchestration.api_call_with_retries',
                   autospec=True) as mock_get:
            with patch.object(ApiUtil, 'api_call', autospec=True) as mock_send:
                mock_get.side_effect = self.dup_get_mocks
                mock_send.side_effect = dup_send_mocks
                # First run gathers data, but fails to send it because of a network error
                some_orca.main()
                # Second run sends all the data pulled from both runs
                some_orca.main()

        self.assertEqual(mock_get.call_count, 2)
        # Once for failure, once for rweasley score, twice for hgranger scores
        self.assertEqual(mock_send.call_count, 4)

        transmitted_qs: QuerySet = some_orca.exam.submissions.filter(
            transmitted=True)
        self.assertEqual(len(transmitted_qs), 5)
        new_transmitted_qs: QuerySet = transmitted_qs.filter(
            transmitted_timestamp__gt=current_dt)
        self.assertEqual(len(new_transmitted_qs), 3)

        dup_subs: List[Submission] = list(
            new_transmitted_qs.filter(
                student_uniqname='hgranger').order_by('id'))
        self.assertEqual(len(dup_subs), 2)
        self.assertTrue(dup_subs[0].transmitted_timestamp <
                        dup_subs[1].transmitted_timestamp)
Example #10
0
    def test_main(self):
        """main process method handles both previously un-transmitted and new submissions."""
        potions_val_exam: Exam = Exam.objects.get(id=2)
        some_orca: ScoresOrchestration = ScoresOrchestration(
            self.api_handler, potions_val_exam)

        # Expected scores from to-be-fetched Canvas submissions (really, canvas_subs.json)
        scores: List[Dict[str, str]] = [{
            'ID': 'hpotter',
            'Form': 'PV',
            'GradePoints': '125.0'
        }, {
            'ID': 'cchang',
            'Form': 'PV',
            'GradePoints': '200.0'
        }]
        # Un-transmitted scores from previous runs (really, test_04.json)
        scores = [
            sub.prepare_score() for sub in some_orca.exam.submissions.all()
        ] + scores

        with patch('pe.orchestration.api_call_with_retries',
                   autospec=True) as mock_get:
            with patch.object(ApiUtil, 'api_call', autospec=True) as mock_send:
                mock_get.return_value = MagicMock(
                    spec=Response,
                    status_code=200,
                    text=json.dumps(self.canvas_potions_val_subs))
                mock_send.return_value = MagicMock(
                    spec=Response,
                    status_code=200,
                    text=json.dumps(self.mpathways_resp_data[2]))
                some_orca.main()

        self.assertEqual(mock_get.call_count, 1)
        self.assertEqual(mock_send.call_count, 1)

        potions_val_sub_qs: QuerySet = some_orca.exam.submissions.filter(
            transmitted=True)
        potions_val_subs: List[Submission] = list(
            potions_val_sub_qs.order_by('student_uniqname').all())
        self.assertEqual(len(potions_val_subs), 4)
        self.assertEqual([sub.student_uniqname for sub in potions_val_subs],
                         ['cchang', 'hpotter', 'nlongbottom', 'rweasley'])
        brand_new_subs: List[Submission] = list(
            potions_val_sub_qs.filter(
                graded_timestamp__gte=some_orca.sub_time_filter).order_by(
                    'student_uniqname'))
        self.assertEqual(len(brand_new_subs), 2)
        self.assertEqual([sub.student_uniqname for sub in brand_new_subs],
                         ['cchang', 'hpotter'])
Example #11
0
def main(api_util: ApiUtil) -> None:
    """
    Runs the highest-level application process, coordinating the use of ScoresOrchestration and Reporter
    classes and the transfer of data between them.

    :param api_util: Instance of ApiUtil for making API calls
    :type api_util: ApiUtil
    :return: None
    :rtype: None
    """
    start_time: datetime = datetime.now(tz=utc)
    LOGGER.info(f'Starting new run at {start_time}')

    reports: list[Report] = list(Report.objects.all())
    LOGGER.debug(reports)

    exams: list[Exam] = list(Exam.objects.all())
    LOGGER.debug(exams)

    for report in reports:
        reporter: Reporter = Reporter(report)
        for exam in report.exams.all():
            LOGGER.info(f'Processing Exam: {exam.name}')
            exam_start_time = datetime.now(tz=utc)
            exam_orca: ScoresOrchestration = ScoresOrchestration(
                api_util, exam)
            exam_orca.main()
            exam_end_time = datetime.now(tz=utc)
            metadata: dict[str, datetime] = {
                'start_time': exam_start_time,
                'end_time': exam_end_time,
                'sub_time_filter': exam_orca.sub_time_filter
            }
            reporter.exams_time_metadata[exam.id] = metadata

        reporter.prepare_context()
        if reporter.total_successes > 0 or reporter.total_failures > 0:
            LOGGER.info(
                f'Sending {report.name} report email to {report.contact}')
            reporter.send_email()
        else:
            LOGGER.info(
                f'No email will be sent for the {report.name} report as there was no transmission activity.'
            )

    end_time: datetime = datetime.now(tz=utc)
    delta: timedelta = end_time - start_time

    LOGGER.info(f'The run ended at {end_time}')
    LOGGER.info(f'Duration of run: {delta}')
Example #12
0
    def test_get_sub_dicts_for_exam_with_null_response(self):
        """
        get_sub_dicts_for_exam stops collecting data and paginating if api_call_with_retries returns None.
        """
        potions_val_exam: Exam = Exam.objects.get(id=2)
        some_orca: ScoresOrchestration = ScoresOrchestration(
            self.api_handler, potions_val_exam)

        with patch('pe.orchestration.api_call_with_retries',
                   autospec=True) as mock_retry_func:
            mock_retry_func.return_value = None
            sub_dicts: List[Dict[str,
                                 Any]] = some_orca.get_sub_dicts_for_exam()

        self.assertEqual(mock_retry_func.call_count, 1)
        self.assertEqual(len(sub_dicts), 0)
Example #13
0
    def test_create_sub_records_strips_whitespace_in_login_id(self):
        """create_sub_records strips leading and trailing whitespace characters from Canvas login_ids."""
        potions_place_exam: Exam = Exam.objects.get(id=1)
        some_orca: ScoresOrchestration = ScoresOrchestration(
            self.api_handler, potions_place_exam)
        some_orca.create_sub_records(self.canvas_potions_place_subs_three)

        latest_two_subs: List[Submission] = list(
            Submission.objects.filter(
                exam=potions_place_exam).order_by('-id'))[:2]
        uniqnames: List[str] = [
            sub.student_uniqname for sub in latest_two_subs
        ]
        self.assertEqual(
            uniqnames,
            ['*****@*****.**', '*****@*****.**'])
Example #14
0
    def test_main_with_exam_scores_with_duplicate_uniqnames_sent_on_different_runs(
            self):
        """
        The main process pulls, stores, and sends scores with duplicate uniqnames on different runs.
        """
        current_dt: datetime = datetime.now(tz=utc)
        potions_place_exam: Exam = Exam.objects.get(id=1)
        some_orca: ScoresOrchestration = ScoresOrchestration(
            self.api_handler, potions_place_exam)

        dup_send_mocks: List[MagicMock] = [
            MagicMock(spec=Response,
                      status_code=200,
                      text=json.dumps(self.mpathways_resp_data[6])),
            MagicMock(spec=Response,
                      status_code=200,
                      text=json.dumps(self.mpathways_resp_data[4]))
        ]

        with patch('pe.orchestration.api_call_with_retries',
                   autospec=True) as mock_get:
            with patch.object(ApiUtil, 'api_call', autospec=True) as mock_send:
                mock_get.side_effect = self.dup_get_mocks
                mock_send.side_effect = dup_send_mocks
                some_orca.main()
                some_orca.main()

        self.assertEqual(mock_get.call_count, 2)
        # Once for rweasley and first hgranger score, once for second hgranger score
        self.assertEqual(mock_send.call_count, 2)

        transmitted_qs: QuerySet = some_orca.exam.submissions.filter(
            transmitted=True)
        self.assertEqual(len(transmitted_qs), 5)
        new_transmitted_qs: QuerySet = transmitted_qs.filter(
            transmitted_timestamp__gt=current_dt)
        self.assertEqual(len(new_transmitted_qs), 3)

        dup_subs: List[Submission] = list(
            new_transmitted_qs.filter(
                student_uniqname='hgranger').order_by('id'))
        self.assertEqual(len(dup_subs), 2)
        self.assertTrue(dup_subs[0].transmitted_timestamp <
                        dup_subs[1].transmitted_timestamp)
Example #15
0
    def test_get_sub_dicts_for_exam_with_one_page(self):
        """get_sub_dicts_for_exam collects one page of submission data and then stops."""
        potions_val_exam: Exam = Exam.objects.get(id=2)
        some_orca: ScoresOrchestration = ScoresOrchestration(
            self.api_handler, potions_val_exam)

        with patch('pe.orchestration.api_call_with_retries',
                   autospec=True) as mock_retry_func:
            mock_retry_func.return_value = MagicMock(
                spec=Response,
                ok=True,
                links={},
                text=json.dumps(self.canvas_potions_val_subs[:1]))
            sub_dicts: List[Dict[str,
                                 Any]] = some_orca.get_sub_dicts_for_exam()

        self.assertEqual(mock_retry_func.call_count, 1)
        self.assertEqual(len(sub_dicts), 1)
        self.assertEqual(sub_dicts[0], self.canvas_potions_val_subs[0])
Example #16
0
    def test_get_sub_dicts_for_exam_with_multiple_pages(self):
        """get_sub_dicts_for_exam collects submission data across two pages."""
        potions_val_exam: Exam = Exam.objects.get(id=2)
        some_orca: ScoresOrchestration = ScoresOrchestration(
            self.api_handler, potions_val_exam)

        first_links: Dict[str, Any] = {
            # This is probably more elaborate than it needs to be, but this way the DEBUG log message of
            # page_info will show parameters that make sense in this context.
            'next': {
                'url':
                (f'{os.getenv("API_DIR_URL", "https://some-api.umich.edu")}/{CANVAS_URL_BEGIN}'
                 +
                 f'/courses/{some_orca.exam.course_id}/students/submissions' +
                 f'?assignment_ids={some_orca.exam.assignment_id}' +
                 f'&graded_since={quote_plus(some_orca.sub_time_filter.strftime(ISO8601_FORMAT))}&include=user'
                 + '&student_ids=all&page=bookmark:SomeBookmark&per_page=1'),
                'rel':
                'next'
            }
        }

        mocks: List[MagicMock] = [
            MagicMock(spec=Response,
                      ok=True,
                      links=first_links,
                      text=json.dumps(self.canvas_potions_val_subs[0:1])),
            MagicMock(spec=Response,
                      ok=True,
                      links={},
                      text=json.dumps(self.canvas_potions_val_subs[1:]))
        ]

        with patch('pe.orchestration.api_call_with_retries',
                   autospec=True) as mock_retry_func:
            mock_retry_func.side_effect = mocks
            sub_dicts: List[Dict[str,
                                 Any]] = some_orca.get_sub_dicts_for_exam(1)

        self.assertEqual(mock_retry_func.call_count, 2)
        self.assertEqual(len(sub_dicts), 2)
        self.assertEqual(sub_dicts, self.canvas_potions_val_subs)
Example #17
0
    def test_create_sub_records(self):
        """
        create_sub_records parses Canvas submission dictionaries and creates records in the database.
        """
        potions_val_exam = Exam.objects.get(id=2)
        some_orca: ScoresOrchestration = ScoresOrchestration(
            self.api_handler, potions_val_exam)
        some_orca.create_sub_records(self.canvas_potions_val_subs)

        new_potions_val_sub_qs: QuerySet = some_orca.exam.submissions.filter(
            submission_id__in=[444444, 444445])
        self.assertEqual(len(new_potions_val_sub_qs), 2)
        self.assertEqual(
            len(
                new_potions_val_sub_qs.filter(transmitted=False,
                                              transmitted_timestamp=None)), 2)

        sub_dicts: List[Dict[str, Any]] = new_potions_val_sub_qs.order_by('student_uniqname')\
            .values(*self.test_sub_fields)
        self.assertEqual(
            sub_dicts[0], {
                'submission_id': 444445,
                'attempt_num': 1,
                'student_uniqname': 'cchang',
                'score': 200.0,
                'submitted_timestamp': datetime(
                    2020, 6, 20, 10, 35, 1, tzinfo=utc),
                'graded_timestamp': datetime(
                    2020, 6, 20, 10, 45, 0, tzinfo=utc)
            })
        self.assertEqual(
            sub_dicts[1], {
                'submission_id': 444444,
                'attempt_num': 1,
                'student_uniqname': 'hpotter',
                'score': 125.0,
                'submitted_timestamp': datetime(
                    2020, 6, 19, 17, 30, 5, tzinfo=utc),
                'graded_timestamp': datetime(
                    2020, 6, 19, 17, 45, 33, tzinfo=utc)
            })