From 708d25e8c7a03a53d78b985bd3090a673c28ff6a Mon Sep 17 00:00:00 2001
From: dzhuang value=${#1} value=${#1}\n'
'example2
value=${#1}example2
') resp = self.get_page_sandbox_preview_response(markdown) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) self.assertResponseContextContains(resp, "body", expected_literal) markdown = TEST_SANDBOX_MARK_DOWN_PATTERN % "{%-endraw-%}" resp = self.get_page_sandbox_preview_response(markdown) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) self.assertResponseContextContains(resp, "body", expected_literal) def test_embedded_raw_block4(self): @@ -468,7 +468,7 @@ class YamlJinjaExpansionTest(SingleCoursePageSandboxTestBaseMixin, TestCase): 'value=${#1}\n' 'example2
') resp = self.get_page_sandbox_preview_response(markdown) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) self.assertResponseContextContains(resp, "body", expected_literal) # }}} diff --git a/tests/test_pages/markdowns.py b/tests/test_pages/markdowns.py index b4d85748..dc2cf741 100644 --- a/tests/test_pages/markdowns.py +++ b/tests/test_pages/markdowns.py @@ -4,11 +4,50 @@ markdowns for page sandbox tests CODE_MARKDWON = """ type: PythonCodeQuestion +access_rules: + add_permissions: + - change_answer id: addition value: 1 timeout: 10 prompt: | + # Adding 1 and 2, and assign it to c + +names_from_user: [c] + +initial_code: | + c = + +test_code: | + if not isinstance(c, float): + feedback.finish(0, "Your computed c is not a float.") + + correct_c = 3 + rel_err = abs(correct_c-c)/abs(correct_c) + + if rel_err < 1e-7: + feedback.finish(1, "Your computed c was correct.") + else: + feedback.finish(0, "Your computed c was incorrect.") + +correct_code: | + + c = 2 + 1 + +correct_code_explanation: This is the [explanation](http://example.com/1). +""" + +CODE_MARKDWON_PATTERN_WITH_DATAFILES = """ +type: PythonCodeQuestion +id: addition +value: 1 +timeout: 10 +data_files: + - question-data/random-data.npy + %(extra_data_file)s +prompt: | + # Adding two numbers in Python setup_code: | @@ -38,14 +77,94 @@ correct_code: | c = a + b """ -CODE_MARKDWON_PATTERN_WITH_DATAFILES = """ +CODE_MARKDWON_WITH_DATAFILES_BAD_FORMAT = """ type: PythonCodeQuestion id: addition value: 1 timeout: 10 data_files: - question-data/random-data.npy - %(extra_data_file)s + - - foo + - bar +prompt: | + + # Adding two numbers in Python + +setup_code: | + import random + + a = random.uniform(-10, 10) + b = random.uniform(-10, 10) + +names_for_user: [a, b] + +names_from_user: [c] + +test_code: | + if not isinstance(c, float): + feedback.finish(0, "Your computed c is not a float.") + + correct_c = a + b + rel_err = abs(correct_c-c)/abs(correct_c) + + if rel_err < 1e-7: + feedback.finish(1, "Your computed c was correct.") + else: + feedback.finish(0, "Your computed c was incorrect.") + +correct_code: | + + c = a + b +""" + + +CODE_MARKDWON_NOT_EXPLICITLY_NOT_ALLOW_MULTI_SUBMIT1 = """ +type: PythonCodeQuestion +access_rules: + add_permissions: + - see_answer_after_submission +id: addition +value: 1 +timeout: 10 +prompt: | + + # Adding two numbers in Python + +setup_code: | + import random + + a = random.uniform(-10, 10) + b = random.uniform(-10, 10) + +names_for_user: [a, b] + +names_from_user: [c] + +test_code: | + if not isinstance(c, float): + feedback.finish(0, "Your computed c is not a float.") + + correct_c = a + b + rel_err = abs(correct_c-c)/abs(correct_c) + + if rel_err < 1e-7: + feedback.finish(1, "Your computed c was correct.") + else: + feedback.finish(0, "Your computed c was incorrect.") + +correct_code: | + + c = a + b +""" + +CODE_MARKDWON_NOT_EXPLICITLY_NOT_ALLOW_MULTI_SUBMIT2 = """ +type: PythonCodeQuestion +access_rules: + remove_permissions: + - see_answer_after_submission +id: addition +value: 1 +timeout: 10 prompt: | # Adding two numbers in Python @@ -206,3 +325,50 @@ correct_code: | c = a + b """ # noqa + +CODE_WITH_HUMAN_FEEDBACK_MARKDWON_PATTERN = """ +type: PythonCodeQuestionWithHumanTextFeedback +id: pymult +access_rules: + add_permissions: + - change_answer +value: %(value)s +%(human_feedback)s +%(extra_attribute)s +timeout: 10 + +prompt: | + + # Adding two numbers in Python + +setup_code: | + import random + + a = random.uniform(-10, 10) + b = random.uniform(-10, 10) + +names_for_user: [a, b] + +names_from_user: [c] + +test_code: | + if not isinstance(c, float): + feedback.finish(0, "Your computed c is not a float.") + + correct_c = a + b + rel_err = abs(correct_c-c)/abs(correct_c) + + if rel_err < 1e-7: + feedback.finish(1, "Your computed c was correct.") + else: + feedback.finish(0, "Your computed c was incorrect.") + +correct_code: | + + c = a + b + +rubric: | + + The code has to be squeaky-clean. + +""" # noqa diff --git a/tests/test_pages/test_choice.py b/tests/test_pages/test_choice.py index 8c3e10c6..6700de7a 100644 --- a/tests/test_pages/test_choice.py +++ b/tests/test_pages/test_choice.py @@ -231,7 +231,7 @@ class ChoicesQuestionTest(SingleCoursePageSandboxTestBaseMixin, TestCase): markdown = CHOICE_MARKDOWN resp = self.get_page_sandbox_preview_response(markdown) self.assertEqual(resp.status_code, 200) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) self.assertEqual(len(self.get_sandbox_page_data()), 3) page_data = self.get_sandbox_page_data()[2] @@ -253,7 +253,7 @@ class ChoicesQuestionTest(SingleCoursePageSandboxTestBaseMixin, TestCase): markdown = CHOICE_MARKDOWN_WITHOUT_CORRECT_ANSWER resp = self.get_page_sandbox_preview_response(markdown) self.assertEqual(resp.status_code, 200) - self.assertSandboxNotHaveValidPage(resp) + self.assertSandboxNotHasValidPage(resp) self.assertResponseContextContains( resp, PAGE_ERRORS, "one or more correct answer(s) " @@ -263,7 +263,7 @@ class ChoicesQuestionTest(SingleCoursePageSandboxTestBaseMixin, TestCase): markdown = CHOICE_MARKDOWN_WITH_DISREGARD resp = self.get_page_sandbox_preview_response(markdown) self.assertEqual(resp.status_code, 200) - self.assertSandboxNotHaveValidPage(resp) + self.assertSandboxNotHasValidPage(resp) self.assertResponseContextContains( resp, PAGE_ERRORS, "ChoiceQuestion does not allow any choices " @@ -273,7 +273,7 @@ class ChoicesQuestionTest(SingleCoursePageSandboxTestBaseMixin, TestCase): markdown = CHOICE_MARKDOWN_WITH_ALWAYS_CORRECT resp = self.get_page_sandbox_preview_response(markdown) self.assertEqual(resp.status_code, 200) - self.assertSandboxNotHaveValidPage(resp) + self.assertSandboxNotHasValidPage(resp) self.assertResponseContextContains( resp, PAGE_ERRORS, "ChoiceQuestion does not allow any choices " @@ -284,7 +284,7 @@ class ChoicesQuestionTest(SingleCoursePageSandboxTestBaseMixin, TestCase): resp = self.get_page_sandbox_preview_response(markdown) self.assertEqual(resp.status_code, 200) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) self.assertResponseContextContains(resp, "correct_answer", "This is the explanation.") @@ -296,7 +296,7 @@ class MultiChoicesQuestionTest(SingleCoursePageSandboxTestBaseMixin, TestCase): resp = self.get_page_sandbox_preview_response( MULTIPLE_CHOICES_MARKDWON_WITH_MULTIPLE_MODE1) self.assertEqual(resp.status_code, 200) - self.assertSandboxNotHaveValidPage(resp) + self.assertSandboxNotHasValidPage(resp) expected_page_error = ("ValidationError: sandbox, choice 1: " "more than one choice modes set: " "'~CORRECT~~CORRECT~'") @@ -306,7 +306,7 @@ class MultiChoicesQuestionTest(SingleCoursePageSandboxTestBaseMixin, TestCase): resp = self.get_page_sandbox_preview_response( MULTIPLE_CHOICES_MARKDWON_WITH_MULTIPLE_MODE2) self.assertEqual(resp.status_code, 200) - self.assertSandboxNotHaveValidPage(resp) + self.assertSandboxNotHasValidPage(resp) expected_page_error = ("ValidationError: sandbox, choice 1: " "more than one choice modes set: " "'~DISREGARD~~CORRECT~'") @@ -321,7 +321,7 @@ class MultiChoicesQuestionTest(SingleCoursePageSandboxTestBaseMixin, TestCase): "extra_attr": ""}) resp = self.get_page_sandbox_preview_response(markdown) self.assertEqual(resp.status_code, 200) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) self.assertEqual(len(self.get_sandbox_page_data()), 3) page_data = self.get_sandbox_page_data()[2] self.assertTrue("permutation" in page_data) @@ -345,7 +345,7 @@ class MultiChoicesQuestionTest(SingleCoursePageSandboxTestBaseMixin, TestCase): "extra_attr": ""}) resp = self.get_page_sandbox_preview_response(markdown) self.assertEqual(resp.status_code, 200) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) self.assertEqual(len(self.get_sandbox_page_data()), 3) # This is to make sure page_data exists and is ordered @@ -373,7 +373,7 @@ class MultiChoicesQuestionTest(SingleCoursePageSandboxTestBaseMixin, TestCase): "extra_attr": ""}) resp = self.get_page_sandbox_preview_response(markdown) self.assertEqual(resp.status_code, 200) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) self.assertEqual(len(self.get_sandbox_page_data()), 3) resp = self.get_page_sandbox_submit_answer_response( @@ -395,7 +395,7 @@ class MultiChoicesQuestionTest(SingleCoursePageSandboxTestBaseMixin, TestCase): % {"credit_mode": "proportional_correct"}) resp = self.get_page_sandbox_preview_response(markdown) self.assertEqual(resp.status_code, 200) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) resp = self.get_page_sandbox_submit_answer_response( markdown, answer_data={"choice": ['2', '5']}) @@ -419,7 +419,7 @@ class MultiChoicesQuestionTest(SingleCoursePageSandboxTestBaseMixin, TestCase): % {"credit_mode": "proportional_correct"}) resp = self.get_page_sandbox_preview_response(markdown) self.assertEqual(resp.status_code, 200) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) resp = self.get_page_sandbox_submit_answer_response( markdown, @@ -461,7 +461,7 @@ class MultiChoicesQuestionTest(SingleCoursePageSandboxTestBaseMixin, TestCase): resp = self.get_page_sandbox_preview_response(markdown) self.assertEqual(resp.status_code, 200) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) self.assertResponseContextContains(resp, "correct_answer", "This is the explanation.") @@ -476,7 +476,7 @@ class MultiChoicesQuestionTest(SingleCoursePageSandboxTestBaseMixin, TestCase): "extra_attr": ""}) resp = self.get_page_sandbox_preview_response(markdown) self.assertEqual(resp.status_code, 200) - self.assertSandboxNotHaveValidPage(resp) + self.assertSandboxNotHasValidPage(resp) self.assertResponseContextContains(resp, PAGE_ERRORS, expected_error) def test_with_both_credit_mode_and_allow_partial_credit(self): @@ -499,12 +499,12 @@ class MultiChoicesQuestionTest(SingleCoursePageSandboxTestBaseMixin, TestCase): resp = self.get_page_sandbox_preview_response(markdown1) self.assertEqual(resp.status_code, 200) - self.assertSandboxNotHaveValidPage(resp) + self.assertSandboxNotHasValidPage(resp) self.assertResponseContextContains(resp, PAGE_ERRORS, expected_error) resp = self.get_page_sandbox_preview_response(markdown2) self.assertEqual(resp.status_code, 200) - self.assertSandboxNotHaveValidPage(resp) + self.assertSandboxNotHasValidPage(resp) self.assertResponseContextContains(resp, PAGE_ERRORS, expected_error) def test_without_credit_mode_but_allow_partial_credit(self): @@ -558,51 +558,51 @@ class MultiChoicesQuestionTest(SingleCoursePageSandboxTestBaseMixin, TestCase): resp = self.get_page_sandbox_preview_response(markdown_exact1) self.assertEqual(resp.status_code, 200) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) self.assertSandboxWarningTextContain( resp, expected_warning_pattern % "exact", loose=True) resp = self.get_page_sandbox_preview_response(markdown_exact2) self.assertEqual(resp.status_code, 200) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) self.assertSandboxWarningTextContain( resp, expected_warning_pattern % "exact", loose=True) resp = self.get_page_sandbox_preview_response(markdown_exact3) self.assertEqual(resp.status_code, 200) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) self.assertSandboxWarningTextContain( resp, expected_warning_pattern % "exact", loose=True) resp = self.get_page_sandbox_preview_response(markdown_exact4) self.assertEqual(resp.status_code, 200) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) self.assertSandboxWarningTextContain( resp, expected_warning_pattern % "exact", loose=True) resp = self.get_page_sandbox_preview_response(markdown_proportional1) self.assertEqual(resp.status_code, 200) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) self.assertSandboxWarningTextContain( resp, expected_warning_pattern % "proportional", loose=True) resp = self.get_page_sandbox_preview_response(markdown_proportional2) self.assertEqual(resp.status_code, 200) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) self.assertSandboxWarningTextContain( resp, expected_warning_pattern % "proportional", loose=True) resp = ( self.get_page_sandbox_preview_response(markdown_proportional_correct1)) self.assertEqual(resp.status_code, 200) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) self.assertSandboxWarningTextContain( resp, expected_warning_pattern % "proportional_correct", loose=True) resp = ( self.get_page_sandbox_preview_response(markdown_proportional_correct2)) self.assertEqual(resp.status_code, 200) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) self.assertSandboxWarningTextContain( resp, expected_warning_pattern % "proportional_correct", loose=True) @@ -622,7 +622,7 @@ class MultiChoicesQuestionTest(SingleCoursePageSandboxTestBaseMixin, TestCase): resp = ( self.get_page_sandbox_preview_response(markdown)) self.assertEqual(resp.status_code, 200) - self.assertSandboxNotHaveValidPage(resp) + self.assertSandboxNotHasValidPage(resp) self.assertResponseContextContains(resp, PAGE_ERRORS, expected_page_error) @@ -706,11 +706,11 @@ class NormalizedAnswerTest(SingleCoursePageTestMixin, TestCase): buf = BytesIO(resp.content) with zipfile.ZipFile(buf, 'r') as zf: self.assertIsNone(zf.testzip()) - self.assertEqual(len(zf.filelist), 1) + # todo: make more assertions in terms of file content + self.assertEqual( + len([f for f in zf.filelist if f.filename.endswith('.json')]), 1) for f in zf.filelist: self.assertGreater(f.file_size, 0) - # todo: make more assertions in terms of file content - self.assertIn('.json', zf.filelist[0].filename) def test_multiple_choice_page_analytics(self): # todo: make more assertions in terms of content diff --git a/tests/test_pages/test_code.py b/tests/test_pages/test_code.py index 35894aa9..7fc9b02c 100644 --- a/tests/test_pages/test_code.py +++ b/tests/test_pages/test_code.py @@ -23,30 +23,30 @@ THE SOFTWARE. """ import six +import zipfile import unittest from unittest import skipIf -from django.test import TestCase, override_settings +from django.test import TestCase, override_settings, RequestFactory from docker.errors import APIError as DockerAPIError from socket import error as socket_error, timeout as sock_timeout import errno +from course.models import FlowSession from course.page.code import ( RUNPY_PORT, request_python_run_with_retries, InvalidPingResponse, - is_nuisance_failure) + is_nuisance_failure, PythonCodeQuestionWithHumanTextFeedback) +from course.utils import FlowPageContext, CoursePageContext + +from tests.test_pages import QUIZ_FLOW_ID +from tests.test_pages.test_generic import MESSAGE_ANSWER_SAVED_TEXT from tests.base_test_mixins import ( - SubprocessRunpyContainerMixin, - SingleCoursePageTestMixin) + SubprocessRunpyContainerMixin, SingleCoursePageTestMixin, + FallBackStorageMessageTestMixin) from tests.test_sandbox import ( SingleCoursePageSandboxTestBaseMixin, PAGE_ERRORS ) -from tests.test_pages import QUIZ_FLOW_ID -from tests.test_pages.utils import ( - skip_real_docker_test, SKIP_REAL_DOCKER_REASON, - REAL_RELATE_DOCKER_URL, REAL_RELATE_DOCKER_RUNPY_IMAGE, - REAL_RELATE_DOCKER_TLS_CONFIG -) from tests.utils import LocmemBackendTestsMixin, mock, mail from . import markdowns @@ -76,85 +76,250 @@ GRADE_CODE_FAILING_MSG = ( RUNPY_WITH_RETRIES_PATH = "course.page.code.request_python_run_with_retries" -class RealDockerTestMixin(object): - @classmethod - def setUpClass(cls): # noqa - from unittest import SkipTest - if skip_real_docker_test: - raise SkipTest(SKIP_REAL_DOCKER_REASON) - - super(RealDockerTestMixin, cls).setUpClass() - cls.override_docker_settings = override_settings( - RELATE_DOCKER_URL=REAL_RELATE_DOCKER_URL, - RELATE_DOCKER_RUNPY_IMAGE=REAL_RELATE_DOCKER_RUNPY_IMAGE, - RELATE_DOCKER_TLS_CONFIG=REAL_RELATE_DOCKER_TLS_CONFIG - ) - cls.override_docker_settings.enable() - cls.make_sure_docker_image_pulled() +def get_flow_page_desc_value_zero_human_full_percentage_side_effect( + flow_id, flow_desc, group_id, page_id): + from course.content import get_flow_page_desc + result = get_flow_page_desc(flow_id, flow_desc, group_id, page_id) + result.value = 0 + result.human_feedback_percentage = 100 + return result - @classmethod - def tearDownClass(cls): # noqa - super(RealDockerTestMixin, cls).tearDownClass() - cls.override_docker_settings.disable() - @classmethod - def make_sure_docker_image_pulled(cls): - import docker - cli = docker.Client( - base_url=REAL_RELATE_DOCKER_URL, - tls=None, - timeout=15, - version="1.19") - - if not bool(cli.images(REAL_RELATE_DOCKER_RUNPY_IMAGE)): - # This should run only once and get cached on Travis-CI - cli.pull(REAL_RELATE_DOCKER_RUNPY_IMAGE) - - -@skipIf(skip_real_docker_test, SKIP_REAL_DOCKER_REASON) -class RealDockerCodePageTest(SingleCoursePageTestMixin, - RealDockerTestMixin, TestCase): +class SingleCourseQuizPageCodeQuestionTest( + SingleCoursePageTestMixin, FallBackStorageMessageTestMixin, + SubprocessRunpyContainerMixin, TestCase): flow_id = QUIZ_FLOW_ID - page_id = "addition" + + @classmethod + def setUpTestData(cls): # noqa + super(SingleCourseQuizPageCodeQuestionTest, cls).setUpTestData() + cls.c.force_login(cls.student_participation.user) + cls.start_flow(cls.flow_id) def setUp(self): # noqa - super(RealDockerCodePageTest, self).setUp() + super(SingleCourseQuizPageCodeQuestionTest, self).setUp() + # This is needed to ensure student is logged in self.c.force_login(self.student_participation.user) - self.start_flow(self.flow_id) - - def test_code_page_correct_answer(self): - answer_data = {"answer": "c = a + b"} - expected_str = ( - "It looks like you submitted code that is identical to " - "the reference solution. This is not allowed.") - resp = self.post_answer_by_page_id(self.page_id, answer_data) - self.assertContains(resp, expected_str, count=1) + + def test_code_page_correct(self): + page_id = "addition" + resp = self.post_answer_by_page_id( + page_id, {"answer": ['c = b + a\r']}) self.assertEqual(resp.status_code, 200) + self.assertResponseMessagesContains(resp, MESSAGE_ANSWER_SAVED_TEXT) self.assertEqual(self.end_flow().status_code, 200) self.assertSessionScoreEqual(1) - def test_code_page_wrong_answer(self): - answer_data = {"answer": "c = a - b"} - resp = self.post_answer_by_page_id(self.page_id, answer_data) + def test_code_page_wrong(self): + page_id = "addition" + resp = self.post_answer_by_page_id( + page_id, {"answer": ['c = a - b\r']}) self.assertEqual(resp.status_code, 200) + self.assertResponseMessagesContains(resp, MESSAGE_ANSWER_SAVED_TEXT) self.assertEqual(self.end_flow().status_code, 200) self.assertSessionScoreEqual(0) - def test_code_page_user_code_exception_raise(self): - answer_data = {"answer": "c = a ^ b"} - from django.utils.html import escape - expected_error_str1 = escape( - "Your code failed with an exception. " - "A traceback is below.") - expected_error_str2 = escape( - "TypeError: unsupported operand type(s) for ^: " - "'float' and 'float'") - resp = self.post_answer_by_page_id(self.page_id, answer_data) + def test_code_page_identical_to_reference(self): + page_id = "addition" + resp = self.post_answer_by_page_id( + page_id, {"answer": ['c = a + b\r']}) self.assertEqual(resp.status_code, 200) - self.assertContains(resp, expected_error_str1, count=1) - self.assertContains(resp, expected_error_str2, count=1) + self.assertResponseMessagesContains(resp, MESSAGE_ANSWER_SAVED_TEXT) + self.assertResponseContextAnswerFeedbackContainsFeedback( + resp, + ("It looks like you submitted code " + "that is identical to the reference " + "solution. This is not allowed.")) self.assertEqual(self.end_flow().status_code, 200) - self.assertSessionScoreEqual(0) + self.assertSessionScoreEqual(1) + + def test_download_code_submissions_no_answer(self): + group_page_id = "quiz_tail/addition" + self.end_flow() + + # no answer + with self.temporarily_switch_to_user(self.instructor_participation.user): + resp = self.post_download_all_submissions_by_group_page_id( + group_page_id=group_page_id, flow_id=self.flow_id) + self.assertEqual(resp.status_code, 200) + prefix, zip_file = resp["Content-Disposition"].split('=') + self.assertEqual(prefix, "attachment; filename") + self.assertEqual(resp.get('Content-Type'), "application/zip") + + buf = six.BytesIO(resp.content) + with zipfile.ZipFile(buf, 'r') as zf: + self.assertIsNone(zf.testzip()) + self.assertEqual( + len([f for f in zf.filelist if f.filename.endswith('.py')]), 0) + for f in zf.filelist: + self.assertGreater(f.file_size, 0) + + def test_download_code_submissions_has_answer(self): + group_page_id = "quiz_tail/addition" + + # create an answer + page_id = "addition" + self.post_answer_by_page_id( + page_id, {"answer": ['c = a - b\r']}) + self.end_flow() + with self.temporarily_switch_to_user(self.instructor_participation.user): + resp = self.post_download_all_submissions_by_group_page_id( + group_page_id=group_page_id, flow_id=self.flow_id) + self.assertEqual(resp.status_code, 200) + prefix, zip_file = resp["Content-Disposition"].split('=') + self.assertEqual(prefix, "attachment; filename") + self.assertEqual(resp.get('Content-Type'), "application/zip") + + buf = six.BytesIO(resp.content) + with zipfile.ZipFile(buf, 'r') as zf: + self.assertIsNone(zf.testzip()) + # todo: make more assertions in terms of file content + self.assertEqual( + len([f for f in zf.filelist if f.filename.endswith('.py')]), 1) + for f in zf.filelist: + self.assertGreater(f.file_size, 0) + + def test_code_page_analytics_no_answer(self): + # analytics with no answer + page_id = "addition" + self.end_flow() + with self.temporarily_switch_to_user(self.instructor_participation.user): + resp = self.get_flow_page_analytics( + flow_id=self.flow_id, group_id="quiz_tail", + page_id=page_id) + self.assertEqual(resp.status_code, 200) + + def test_code_page_analytics_has_answer(self): + # create an answer + page_id = "addition" + self.post_answer_by_page_id( + page_id, {"answer": ['c = a - b\r']}) + self.end_flow() + + # todo: make more assertions in terms of content + with self.temporarily_switch_to_user(self.instructor_participation.user): + resp = self.get_flow_page_analytics( + flow_id=self.flow_id, group_id="quiz_tail", + page_id=page_id) + self.assertEqual(resp.status_code, 200) + + def test_code_human_feedback_page_submit(self): + page_id = "pymult" + resp = self.post_answer_by_page_id( + page_id, {"answer": ['c = a * b\r']}) + self.assertEqual(resp.status_code, 200) + self.assertResponseMessagesContains(resp, MESSAGE_ANSWER_SAVED_TEXT) + self.assertEqual(self.end_flow().status_code, 200) + self.assertSessionScoreEqual(None) + + def test_code_human_feedback_page_grade1(self): + page_id = "pymult" + resp = self.post_answer_by_page_id( + page_id, {"answer": ['c = b * a\r']}) + self.assertResponseContextAnswerFeedbackContainsFeedback( + resp, "'c' looks good") + self.assertEqual(self.end_flow().status_code, 200) + + grade_data = { + "grade_percent": ["100"], + "released": ["on"] + } + + resp = self.post_grade_by_page_id(page_id, grade_data) + self.assertTrue(resp.status_code, 200) + self.assertResponseContextAnswerFeedbackContainsFeedback( + resp, "The human grader assigned 2/2 points.") + + # since the test_code didn't do a feedback.set_points() after + # check_scalar() + self.assertSessionScoreEqual(None) + + def test_code_human_feedback_page_grade2(self): + page_id = "pymult" + resp = self.post_answer_by_page_id( + page_id, {"answer": ['c = a / b\r']}) + self.assertResponseContextAnswerFeedbackContainsFeedback( + resp, "'c' is inaccurate") + self.assertResponseContextAnswerFeedbackContainsFeedback( + resp, "The autograder assigned 0/2 points.") + + self.assertEqual(self.end_flow().status_code, 200) + + feedback_text = "This is the feedback from instructor." + + grade_data = { + "grade_percent": ["100"], + "released": ["on"], + "feedback_text": feedback_text + } + resp = self.post_grade_by_page_id(page_id, grade_data) + self.assertTrue(resp.status_code, 200) + self.assertResponseContextAnswerFeedbackContainsFeedback( + resp, "The human grader assigned 2/2 points.") + self.assertResponseContextAnswerFeedbackContainsFeedback( + resp, feedback_text) + self.assertSessionScoreEqual(2) + + def test_code_human_feedback_page_grade3(self): + page_id = "py_simple_list" + resp = self.post_answer_by_page_id( + page_id, {"answer": ['b = [a + 1] * 50\r']}) + + # this is testing feedback.finish(0.3, feedback_msg) + # 2 * 0.3 = 0.6 + self.assertResponseContextAnswerFeedbackContainsFeedback( + resp, "The autograder assigned 0.90/3 points.") + self.assertResponseContextAnswerFeedbackContainsFeedback( + resp, "The elements in b have wrong values") + self.assertEqual(self.end_flow().status_code, 200) + + # The page is not graded before human grading. + self.assertSessionScoreEqual(None) + + def test_code_human_feedback_page_grade4(self): + page_id = "py_simple_list" + resp = self.post_answer_by_page_id( + page_id, {"answer": ['b = [a] * 50\r']}) + self.assertResponseContextAnswerFeedbackContainsFeedback( + resp, "b looks good") + self.assertEqual(self.end_flow().status_code, 200) + + grade_data = { + "grade_percent": ["100"], + "released": ["on"] + } + + resp = self.post_grade_by_page_id(page_id, grade_data) + self.assertTrue(resp.status_code, 200) + self.assertResponseContextAnswerFeedbackContainsFeedback( + resp, "The human grader assigned 1/1 points.") + + self.assertSessionScoreEqual(4) + + grade_data = { + "released": ["on"] + } + + resp = self.post_grade_by_page_id(page_id, grade_data) + self.assertTrue(resp.status_code, 200) + self.assertFormErrorLoose(resp, None) + self.assertSessionScoreEqual(None) + + # not released + feedback_text = "This is the feedback from instructor." + grade_data = { + "grade_percent": ["100"], + "feedback_text": feedback_text + } + + resp = self.post_grade_by_page_id(page_id, grade_data) + self.assertTrue(resp.status_code, 200) + self.assertResponseContextAnswerFeedbackNotContainsFeedback( + resp, "The human grader assigned 1/1 points.") + self.assertResponseContextAnswerFeedbackNotContainsFeedback( + resp, feedback_text) + + self.assertSessionScoreEqual(None) class CodeQuestionTest(SingleCoursePageSandboxTestBaseMixin, @@ -169,10 +334,18 @@ class CodeQuestionTest(SingleCoursePageSandboxTestBaseMixin, ) resp = self.get_page_sandbox_preview_response(markdown) self.assertEqual(resp.status_code, 200) - self.assertSandboxNotHaveValidPage(resp) + self.assertSandboxNotHasValidPage(resp) self.assertResponseContextContains( resp, PAGE_ERRORS, "data file '%s' not found" % file_name) + def test_data_files_missing_random_question_data_file_bad_format(self): + markdown = markdowns.CODE_MARKDWON_WITH_DATAFILES_BAD_FORMAT + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxNotHasValidPage(resp) + self.assertResponseContextContains( + resp, PAGE_ERRORS, "data file '%s' not found" % "['foo', 'bar']") + def test_not_multiple_submit_warning(self): markdown = ( markdowns.CODE_MARKDWON_PATTERN_WITH_DATAFILES @@ -180,12 +353,39 @@ class CodeQuestionTest(SingleCoursePageSandboxTestBaseMixin, ) resp = self.get_page_sandbox_preview_response(markdown) self.assertEqual(resp.status_code, 200) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) + self.assertSandboxWarningTextContain( + resp, + NOT_ALLOW_MULTIPLE_SUBMISSION_WARNING + ) + + def test_not_multiple_submit_warning2(self): + markdown = markdowns.CODE_MARKDWON_NOT_EXPLICITLY_NOT_ALLOW_MULTI_SUBMIT1 + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxHasValidPage(resp) self.assertSandboxWarningTextContain( resp, NOT_ALLOW_MULTIPLE_SUBMISSION_WARNING ) + def test_not_multiple_submit_warning3(self): + markdown = markdowns.CODE_MARKDWON_NOT_EXPLICITLY_NOT_ALLOW_MULTI_SUBMIT2 + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxHasValidPage(resp) + self.assertSandboxWarningTextContain( + resp, + NOT_ALLOW_MULTIPLE_SUBMISSION_WARNING + ) + + def test_allow_multiple_submit(self): + markdown = markdowns.CODE_MARKDWON + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxHasValidPage(resp) + self.assertSandboxWarningTextContain(resp, None) + def test_explicity_not_allow_multiple_submit(self): markdown = ( markdowns.CODE_MARKDWON_PATTERN_EXPLICITLY_NOT_ALLOW_MULTI_SUBMIT @@ -193,14 +393,14 @@ class CodeQuestionTest(SingleCoursePageSandboxTestBaseMixin, ) resp = self.get_page_sandbox_preview_response(markdown) self.assertEqual(resp.status_code, 200) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) self.assertSandboxWarningTextContain(resp, None) def test_question_without_test_code(self): markdown = markdowns.CODE_MARKDWON_PATTERN_WITHOUT_TEST_CODE resp = self.get_page_sandbox_preview_response(markdown) self.assertEqual(resp.status_code, 200) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) self.assertSandboxWarningTextContain(resp, None) resp = self.get_page_sandbox_submit_answer_response( @@ -215,7 +415,7 @@ class CodeQuestionTest(SingleCoursePageSandboxTestBaseMixin, markdown = markdowns.CODE_MARKDWON_PATTERN_WITHOUT_CORRECT_CODE resp = self.get_page_sandbox_preview_response(markdown) self.assertEqual(resp.status_code, 200) - self.assertSandboxHaveValidPage(resp) + self.assertSandboxHasValidPage(resp) self.assertSandboxWarningTextContain(resp, None) resp = self.get_page_sandbox_submit_answer_response( @@ -224,6 +424,148 @@ class CodeQuestionTest(SingleCoursePageSandboxTestBaseMixin, self.assertEqual(resp.status_code, 200) self.assertResponseContextAnswerFeedbackCorrectnessEquals(resp, 1) + def test_question_with_human_feedback_both_feedback_value_feedback_percentage_present(self): # noqa + markdown = (markdowns.CODE_WITH_HUMAN_FEEDBACK_MARKDWON_PATTERN + % {"value": 3, + "human_feedback": "human_feedback_value: 2", + "extra_attribute": "human_feedback_percentage: 20"}) + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxNotHasValidPage(resp) + self.assertResponseContextContains( + resp, PAGE_ERRORS, "'human_feedback_value' and " + "'human_feedback_percentage' are not " + "allowed to coexist") + + def test_question_with_human_feedback_neither_feedback_value_feedback_percentage_present(self): # noqa + markdown = (markdowns.CODE_WITH_HUMAN_FEEDBACK_MARKDWON_PATTERN + % {"value": 3, + "human_feedback": "", + "extra_attribute": ""}) + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxNotHasValidPage(resp) + self.assertResponseContextContains( + resp, PAGE_ERRORS, "expecting either 'human_feedback_value' " + "or 'human_feedback_percentage', found neither.") + + def test_question_with_human_feedback_used_feedback_value_warning(self): + markdown = (markdowns.CODE_WITH_HUMAN_FEEDBACK_MARKDWON_PATTERN + % {"value": 3, + "human_feedback": "human_feedback_value: 2", + "extra_attribute": ""}) + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxHasValidPage(resp) + self.assertSandboxWarningTextContain( + resp, + "Used deprecated 'human_feedback_value' attribute--" + "use 'human_feedback_percentage' instead." + ) + + def test_question_with_human_feedback_used_feedback_value_bad_value(self): + markdown = (markdowns.CODE_WITH_HUMAN_FEEDBACK_MARKDWON_PATTERN + % {"value": 0, + "human_feedback": "human_feedback_value: 2", + "extra_attribute": ""}) + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxNotHasValidPage(resp) + self.assertResponseContextContains( + resp, PAGE_ERRORS, "'human_feedback_value' attribute is not allowed " + "if value of question is 0, use " + "'human_feedback_percentage' instead") + + def test_question_with_human_feedback_used_feedback_value_invalid(self): + markdown = (markdowns.CODE_WITH_HUMAN_FEEDBACK_MARKDWON_PATTERN + % {"value": 2, + "human_feedback": "human_feedback_value: 3", + "extra_attribute": ""}) + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxNotHasValidPage(resp) + self.assertResponseContextContains( + resp, PAGE_ERRORS, "human_feedback_value greater than overall " + "value of question") + + def test_question_with_human_feedback_feedback_percentage_invalid(self): + markdown = (markdowns.CODE_WITH_HUMAN_FEEDBACK_MARKDWON_PATTERN + % {"value": 2, + "human_feedback": "human_feedback_percentage: 120", + "extra_attribute": ""}) + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxNotHasValidPage(resp) + self.assertResponseContextContains( + resp, PAGE_ERRORS, "the value of human_feedback_percentage " + "must be between 0 and 100") + + def test_question_with_human_feedback_value_0_feedback_full_percentage(self): + markdown = (markdowns.CODE_WITH_HUMAN_FEEDBACK_MARKDWON_PATTERN + % {"value": 0, + "human_feedback": "human_feedback_percentage: 100", + "extra_attribute": ""}) + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxHasValidPage(resp) + self.assertSandboxWarningTextContain(resp, None) + + def test_question_with_human_feedback_value_0_feedback_0_percentage(self): + markdown = (markdowns.CODE_WITH_HUMAN_FEEDBACK_MARKDWON_PATTERN + % {"value": 0, + "human_feedback": "human_feedback_percentage: 0", + "extra_attribute": ""}) + resp = self.get_page_sandbox_preview_response(markdown) + self.assertEqual(resp.status_code, 200) + self.assertSandboxHasValidPage(resp) + self.assertSandboxWarningTextContain(resp, None) + + def test_request_python_run_with_retries_raise_uncaught_error_in_sandbox(self): + with mock.patch( + RUNPY_WITH_RETRIES_PATH, + autospec=True + ) as mock_runpy: + expected_error_str = ("This is an error raised with " + "request_python_run_with_retries") + + # correct_code_explanation and correct_code + expected_feedback = ( + 'This is the explanation' + '.
The following code is a valid answer: ' + '\nc = 2 + 1\n') + mock_runpy.side_effect = RuntimeError(expected_error_str) + + resp = self.get_page_sandbox_submit_answer_response( + markdowns.CODE_MARKDWON, + answer_data={"answer": ['c = 1 + 2\r']}) + self.assertEqual(resp.status_code, 200) + self.assertResponseContextAnswerFeedbackCorrectnessEquals(resp, + None) + + self.assertResponseContextContains(resp, "correct_answer", + expected_feedback) + # No email when in sandbox + self.assertEqual(len(mail.outbox), 0) + + def test_request_python_run_with_retries_raise_uncaught_error_debugging(self): + with mock.patch( + RUNPY_WITH_RETRIES_PATH, + autospec=True + ) as mock_runpy: + expected_error_str = ("This is an error raised with " + "request_python_run_with_retries") + mock_runpy.side_effect = RuntimeError(expected_error_str) + + with override_settings(DEBUG=True): + resp = self.get_page_sandbox_submit_answer_response( + markdowns.CODE_MARKDWON, + answer_data={"answer": ['c = 1 + 2\r']}) + self.assertEqual(resp.status_code, 200) + self.assertResponseContextAnswerFeedbackCorrectnessEquals(resp, + None) + # No email when debugging + self.assertEqual(len(mail.outbox), 0) + def test_request_python_run_with_retries_raise_uncaught_error(self): with mock.patch( RUNPY_WITH_RETRIES_PATH, @@ -243,7 +585,7 @@ class CodeQuestionTest(SingleCoursePageSandboxTestBaseMixin, resp = self.get_page_sandbox_submit_answer_response( markdowns.CODE_MARKDWON, - answer_data={"answer": ['c = b + a\r']}) + answer_data={"answer": ['c = 1 + 2\r']}) self.assertEqual(resp.status_code, 200) self.assertResponseContextAnswerFeedbackCorrectnessEquals(resp, None) @@ -272,15 +614,16 @@ class CodeQuestionTest(SingleCoursePageSandboxTestBaseMixin, resp = self.get_page_sandbox_submit_answer_response( markdowns.CODE_MARKDWON, - answer_data={"answer": ['c = b + a\r']}) + answer_data={"answer": ['c = 1 + 2\r']}) self.assertContains(resp, expected_error_str) self.assertEqual(resp.status_code, 200) self.assertResponseContextAnswerFeedbackCorrectnessEquals(resp, None) self.assertEqual(len(mail.outbox), 0) - def assert_runpy_result_and_response(self, result_type, expected_msg, - correctness=0, mail_count=0, + def assert_runpy_result_and_response(self, result_type, expected_msgs=None, + not_execpted_msgs=None, + correctness=0, mail_count=0, in_html=False, **extra_result): with mock.patch(RUNPY_WITH_RETRIES_PATH, autospec=True) as mock_runpy: result = {"result": result_type} @@ -289,9 +632,22 @@ class CodeQuestionTest(SingleCoursePageSandboxTestBaseMixin, resp = self.get_page_sandbox_submit_answer_response( markdowns.CODE_MARKDWON, - answer_data={"answer": ['c = b + a\r']}) - self.assertResponseContextAnswerFeedbackContainsFeedback(resp, - expected_msg) + answer_data={"answer": ['c = 1 + 2\r']}) + + if expected_msgs is not None: + if isinstance(expected_msgs, six.text_type): + expected_msgs = [expected_msgs] + for msg in expected_msgs: + self.assertResponseContextAnswerFeedbackContainsFeedback( + resp, msg, html=in_html) + + if not_execpted_msgs is not None: + if isinstance(not_execpted_msgs, six.text_type): + not_execpted_msgs = [not_execpted_msgs] + for msg in not_execpted_msgs: + self.assertResponseContextAnswerFeedbackNotContainsFeedback( + resp, msg, html=in_html) + self.assertEqual(resp.status_code, 200) self.assertResponseContextAnswerFeedbackCorrectnessEquals(resp, correctness) @@ -319,20 +675,6 @@ class CodeQuestionTest(SingleCoursePageSandboxTestBaseMixin, "unknown_error", None) self.assertIn("invalid runpy result: unknown_error", str(e.exception)) - def test_html_bleached_in_feedback(self): - self.assert_runpy_result_and_response( - "user_error", - "", - html="
some html
" - ) - - def test_html_non_text_bleached_in_feedback(self): - self.assert_runpy_result_and_response( - "user_error", - "(Non-string in 'HTML' output filtered out)", - html=b"not string" - ) - def test_traceback_in_feedback(self): self.assert_runpy_result_and_response( "user_error", @@ -354,6 +696,168 @@ class CodeQuestionTest(SingleCoursePageSandboxTestBaseMixin, stderr="some stderr" ) + def test_exechost_local(self): + self.assert_runpy_result_and_response( + "user_error", + not_execpted_msgs="Your code ran on", + exec_host="localhost" + ) + + def test_exechost_ip(self): + with mock.patch("socket.gethostbyaddr") as mock_get_host: + ip = "192.168.1.100" + resovled = "example.com" + mock_get_host.side_effect = lambda x: (resovled, [], []) + self.assert_runpy_result_and_response( + "user_error", + execpted_msgs="Your code ran on %s" % resovled, + exec_host=ip + ) + + def test_exechost_ip_resolve_failure(self): + with mock.patch("socket.gethostbyaddr") as mock_get_host: + ip = "192.168.1.100" + mock_get_host.side_effect = socket_error + self.assert_runpy_result_and_response( + "user_error", + execpted_msgs="Your code ran on %s" % ip, + exec_host=ip + ) + + def test_figures(self): + bmp_b64 = ("data:image/bmp;base64,Qk1GAAAAAAAAAD4AAAAoAAAAAgAAAAIA" + "AAABAAEAAAAAAAgAAADEDgAAxA4AAAAAAAAAAAAAAAAAAP///wDAAAA" + "AwAAAAA==") + jpeg_b64 = ("data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/4QBa" + "RXhpZgAATU0AKgAAAAgABQMBAAUAAAABAAAASgMDAAEAAAABAAAAAFE" + "QAAEAAAABAQAAAFERAAQAAAABAAAOwlESAAQAAAABAAAOwgAAAAAAAY" + "agAACxj//bAEMAAgEBAgEBAgICAgICAgIDBQMDAwMDBgQEAwUHBgcHB" + "wYHBwgJCwkICAoIBwcKDQoKCwwMDAwHCQ4PDQwOCwwMDP/bAEMBAgIC" + "AwMDBgMDBgwIBwgMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAw" + "MDAwMDAwMDAwMDAwMDAwMDAwMDP/AABEIAAIAAgMBIgACEQEDEQH/xA" + "AfAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgv/xAC1EAACAQMDA" + "gQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHBFVLR8" + "CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY" + "2RlZmdoaWpzdHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmq" + "srO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4eLj5OXm5+jp6vHy8/T" + "19vf4+fr/xAAfAQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgv/xA" + "C1EQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQp" + "GhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdIS" + "UpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeY" + "mZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+T" + "l5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AP38ooooA//Z") + png_b64 = ( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACAQMAAAB" + "IeJ9nAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAGUExURQAAAP///" + "6XZn90AAAAJcEhZcwAADsIAAA7CARUoSoAAAAAMSURBVBjTYzjAcAAAAwQBgXn" + "6PNcAAAAASUVORK5CYII=") + + self.assert_runpy_result_and_response( + "user_error", + expected_msgs=[png_b64, jpeg_b64, "Figure1", "Figure 1", + "Figure3", "Figure 3", ], + not_execpted_msgs=[bmp_b64, "Figure2", "Figure 2"], + figures=[ + [1, "image/png", png_b64], + [2, "image/bmp", bmp_b64], + [3, "image/jpeg", jpeg_b64] + ] + ) + + def test_html_in_feedback(self): + html = "