edx-proctoring 6.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- edx_proctoring/__init__.py +6 -0
- edx_proctoring/admin.py +502 -0
- edx_proctoring/api.py +3269 -0
- edx_proctoring/apps.py +162 -0
- edx_proctoring/backends/LICENSE.txt +202 -0
- edx_proctoring/backends/README.txt +1 -0
- edx_proctoring/backends/__init__.py +20 -0
- edx_proctoring/backends/backend.py +162 -0
- edx_proctoring/backends/mock.py +105 -0
- edx_proctoring/backends/null.py +61 -0
- edx_proctoring/backends/rest.py +422 -0
- edx_proctoring/backends/software_secure.py +423 -0
- edx_proctoring/backends/tests/__init__.py +3 -0
- edx_proctoring/backends/tests/test_backend.py +363 -0
- edx_proctoring/backends/tests/test_rest.py +624 -0
- edx_proctoring/backends/tests/test_review_payload.py +102 -0
- edx_proctoring/backends/tests/test_software_secure.py +628 -0
- edx_proctoring/callbacks.py +66 -0
- edx_proctoring/constants.py +83 -0
- edx_proctoring/decorators.py +3 -0
- edx_proctoring/exceptions.py +188 -0
- edx_proctoring/handlers.py +229 -0
- edx_proctoring/instructor_dashboard_exam_urls.py +19 -0
- edx_proctoring/locale/ar/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/ar/LC_MESSAGES/django.po +1445 -0
- edx_proctoring/locale/ar/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/ar/LC_MESSAGES/djangojs.po +1443 -0
- edx_proctoring/locale/ca/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/ca/LC_MESSAGES/django.po +1464 -0
- edx_proctoring/locale/ca/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/ca/LC_MESSAGES/djangojs.po +1463 -0
- edx_proctoring/locale/config.yaml +73 -0
- edx_proctoring/locale/cs/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/cs/LC_MESSAGES/django.po +1632 -0
- edx_proctoring/locale/cs/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/cs/LC_MESSAGES/djangojs.po +1634 -0
- edx_proctoring/locale/de_DE/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/de_DE/LC_MESSAGES/django.po +1461 -0
- edx_proctoring/locale/de_DE/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/de_DE/LC_MESSAGES/djangojs.po +1461 -0
- edx_proctoring/locale/el/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/el/LC_MESSAGES/django.po +1389 -0
- edx_proctoring/locale/el/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/el/LC_MESSAGES/djangojs.po +1387 -0
- edx_proctoring/locale/en/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/en/LC_MESSAGES/django.po +1476 -0
- edx_proctoring/locale/en/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/en/LC_MESSAGES/djangojs.po +264 -0
- edx_proctoring/locale/eo/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/eo/LC_MESSAGES/django.po +1880 -0
- edx_proctoring/locale/eo/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/eo/LC_MESSAGES/djangojs.po +304 -0
- edx_proctoring/locale/es_419/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/es_419/LC_MESSAGES/django.po +1783 -0
- edx_proctoring/locale/es_419/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/es_419/LC_MESSAGES/djangojs.po +1785 -0
- edx_proctoring/locale/es_AR/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/es_AR/LC_MESSAGES/django.po +1654 -0
- edx_proctoring/locale/es_AR/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/es_AR/LC_MESSAGES/djangojs.po +1655 -0
- edx_proctoring/locale/es_ES/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/es_ES/LC_MESSAGES/django.po +1655 -0
- edx_proctoring/locale/es_ES/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/es_ES/LC_MESSAGES/djangojs.po +1652 -0
- edx_proctoring/locale/eu_ES/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/eu_ES/LC_MESSAGES/django.po +1378 -0
- edx_proctoring/locale/eu_ES/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/eu_ES/LC_MESSAGES/djangojs.po +1378 -0
- edx_proctoring/locale/fa_IR/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/fa_IR/LC_MESSAGES/django.po +1666 -0
- edx_proctoring/locale/fa_IR/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/fa_IR/LC_MESSAGES/djangojs.po +1665 -0
- edx_proctoring/locale/fr/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/fr/LC_MESSAGES/django.po +1805 -0
- edx_proctoring/locale/fr/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/fr/LC_MESSAGES/djangojs.po +1802 -0
- edx_proctoring/locale/fr_CA/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/fr_CA/LC_MESSAGES/django.po +1790 -0
- edx_proctoring/locale/fr_CA/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/fr_CA/LC_MESSAGES/djangojs.po +1793 -0
- edx_proctoring/locale/he/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/he/LC_MESSAGES/django.po +1444 -0
- edx_proctoring/locale/he/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/he/LC_MESSAGES/djangojs.po +1443 -0
- edx_proctoring/locale/id/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/id/LC_MESSAGES/django.po +1390 -0
- edx_proctoring/locale/id/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/id/LC_MESSAGES/djangojs.po +1389 -0
- edx_proctoring/locale/it_IT/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/it_IT/LC_MESSAGES/django.po +1715 -0
- edx_proctoring/locale/it_IT/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/it_IT/LC_MESSAGES/djangojs.po +1715 -0
- edx_proctoring/locale/ja_JP/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/ja_JP/LC_MESSAGES/djangojs.po +1387 -0
- edx_proctoring/locale/ka/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/ka/LC_MESSAGES/django.po +1380 -0
- edx_proctoring/locale/ka/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/ka/LC_MESSAGES/djangojs.po +1380 -0
- edx_proctoring/locale/lv/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/lv/LC_MESSAGES/django.po +1377 -0
- edx_proctoring/locale/lv/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/lv/LC_MESSAGES/djangojs.po +1377 -0
- edx_proctoring/locale/mn/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/mn/LC_MESSAGES/django.po +1434 -0
- edx_proctoring/locale/mn/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/mn/LC_MESSAGES/djangojs.po +1379 -0
- edx_proctoring/locale/nb/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/nb/LC_MESSAGES/django.po +1393 -0
- edx_proctoring/locale/nb/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/nb/LC_MESSAGES/djangojs.po +1393 -0
- edx_proctoring/locale/pl/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/pl/LC_MESSAGES/django.po +1386 -0
- edx_proctoring/locale/pl/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/pl/LC_MESSAGES/djangojs.po +1386 -0
- edx_proctoring/locale/pt_PT/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/pt_PT/LC_MESSAGES/django.po +1793 -0
- edx_proctoring/locale/pt_PT/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/pt_PT/LC_MESSAGES/djangojs.po +1810 -0
- edx_proctoring/locale/ro/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/ro/LC_MESSAGES/django.po +1382 -0
- edx_proctoring/locale/ro/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/ro/LC_MESSAGES/djangojs.po +1382 -0
- edx_proctoring/locale/ru/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/ru/LC_MESSAGES/django.po +1456 -0
- edx_proctoring/locale/ru/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/ru/LC_MESSAGES/djangojs.po +1459 -0
- edx_proctoring/locale/sw_KE/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/sw_KE/LC_MESSAGES/django.po +1462 -0
- edx_proctoring/locale/sw_KE/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/sw_KE/LC_MESSAGES/djangojs.po +1464 -0
- edx_proctoring/locale/tr_TR/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/tr_TR/LC_MESSAGES/django.po +1527 -0
- edx_proctoring/locale/tr_TR/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/tr_TR/LC_MESSAGES/djangojs.po +1527 -0
- edx_proctoring/locale/uk/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/uk/LC_MESSAGES/django.po +1443 -0
- edx_proctoring/locale/uk/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/uk/LC_MESSAGES/djangojs.po +1443 -0
- edx_proctoring/locale/zh_CN/LC_MESSAGES/django.mo +0 -0
- edx_proctoring/locale/zh_CN/LC_MESSAGES/django.po +1468 -0
- edx_proctoring/locale/zh_CN/LC_MESSAGES/djangojs.mo +0 -0
- edx_proctoring/locale/zh_CN/LC_MESSAGES/djangojs.po +1469 -0
- edx_proctoring/management/__init__.py +3 -0
- edx_proctoring/management/commands/__init__.py +3 -0
- edx_proctoring/management/commands/reset_attempts.py +87 -0
- edx_proctoring/management/commands/set_attempt_status.py +52 -0
- edx_proctoring/management/commands/set_is_attempt_active.py +104 -0
- edx_proctoring/management/commands/tests/__init__.py +3 -0
- edx_proctoring/management/commands/tests/test_reset_attempts.py +153 -0
- edx_proctoring/management/commands/tests/test_set_attempt_status.py +85 -0
- edx_proctoring/management/commands/tests/test_set_is_attempt_active.py +85 -0
- edx_proctoring/management/commands/tests/test_update_attempt_status_from_review.py +113 -0
- edx_proctoring/management/commands/tests/test_update_attempts_for_exam.py +62 -0
- edx_proctoring/management/commands/update_attempt_status_from_review.py +81 -0
- edx_proctoring/management/commands/update_attempts_for_exam.py +78 -0
- edx_proctoring/migrations/0001_initial.py +227 -0
- edx_proctoring/migrations/0002_proctoredexamstudentattempt_is_status_acknowledged.py +18 -0
- edx_proctoring/migrations/0003_auto_20160101_0525.py +24 -0
- edx_proctoring/migrations/0004_auto_20160201_0523.py +19 -0
- edx_proctoring/migrations/0005_proctoredexam_hide_after_due.py +19 -0
- edx_proctoring/migrations/0006_allowed_time_limit_mins.py +24 -0
- edx_proctoring/migrations/0007_proctoredexam_backend.py +20 -0
- edx_proctoring/migrations/0008_auto_20181116_1551.py +32 -0
- edx_proctoring/migrations/0009_proctoredexamreviewpolicy_remove_rules.py +23 -0
- edx_proctoring/migrations/0010_update_backend.py +32 -0
- edx_proctoring/migrations/0011_allow_multiple_attempts.py +17 -0
- edx_proctoring/migrations/0012_proctoredexamstudentattempt_time_remaining_seconds.py +18 -0
- edx_proctoring/migrations/0013_proctoredexamsoftwaresecurereview_is_active_attempt.py +23 -0
- edx_proctoring/migrations/0014_add_is_resumable_to_proctoredexamstudentattempt.py +23 -0
- edx_proctoring/migrations/0015_rm_proctoredexamstudentattempt_ips.py +29 -0
- edx_proctoring/migrations/0016_nullable_proctoredexamstudentattempt_name.py +23 -0
- edx_proctoring/migrations/0017_rm_proctoredexamstudentattempt_name.py +21 -0
- edx_proctoring/migrations/0018_historicalproctoredexamstudentattempt.py +53 -0
- edx_proctoring/migrations/0019_proctoredexamsoftwaresecurereview_encrypted_video_url.py +18 -0
- edx_proctoring/migrations/0020_auto_20211028_1915.py +23 -0
- edx_proctoring/migrations/0021_auto_20211029_1353.py +26 -0
- edx_proctoring/migrations/0022_proctoredexamstudentattempt_add_readytoresume_resumed.py +33 -0
- edx_proctoring/migrations/0023_historicalproctoredexam.py +50 -0
- edx_proctoring/migrations/0024_delete_proctoredexamstudentattempthistory.py +16 -0
- edx_proctoring/migrations/0025_auto_20230727_2112.py +21 -0
- edx_proctoring/migrations/__init__.py +0 -0
- edx_proctoring/models.py +844 -0
- edx_proctoring/rules.py +15 -0
- edx_proctoring/runtime.py +21 -0
- edx_proctoring/scripts/obscure_user.py +108 -0
- edx_proctoring/serializers.py +147 -0
- edx_proctoring/services.py +43 -0
- edx_proctoring/settings/__init__.py +0 -0
- edx_proctoring/settings/common.py +50 -0
- edx_proctoring/settings/production.py +13 -0
- edx_proctoring/settings/test.py +11 -0
- edx_proctoring/signals.py +5 -0
- edx_proctoring/static/index.js +41 -0
- edx_proctoring/static/proctoring/clippy.swf +0 -0
- edx_proctoring/static/proctoring/close-modal-hover.png +0 -0
- edx_proctoring/static/proctoring/close-modal.png +0 -0
- edx_proctoring/static/proctoring/js/collections/proctored_exam_allowance_collection.js +15 -0
- edx_proctoring/static/proctoring/js/collections/proctored_exam_attempt_grouped_collection.js +15 -0
- edx_proctoring/static/proctoring/js/collections/proctored_exam_collection.js +16 -0
- edx_proctoring/static/proctoring/js/collections/proctored_exam_onboarding_collection.js +13 -0
- edx_proctoring/static/proctoring/js/dropdown.js +97 -0
- edx_proctoring/static/proctoring/js/exam_action_handler.js +252 -0
- edx_proctoring/static/proctoring/js/models/learner_onboarding_model.js +9 -0
- edx_proctoring/static/proctoring/js/models/proctored_exam_allowance_model.js +15 -0
- edx_proctoring/static/proctoring/js/models/proctored_exam_attempt_model.js +15 -0
- edx_proctoring/static/proctoring/js/models/proctored_exam_bulk_allowance_model.js +15 -0
- edx_proctoring/static/proctoring/js/models/proctored_exam_model.js +56 -0
- edx_proctoring/static/proctoring/js/proctored_app.js +16 -0
- edx_proctoring/static/proctoring/js/vendor/backbone.js +1608 -0
- edx_proctoring/static/proctoring/js/vendor/date.js +145 -0
- edx_proctoring/static/proctoring/js/vendor/i18n.js +10 -0
- edx_proctoring/static/proctoring/js/vendor/jquery.js +4 -0
- edx_proctoring/static/proctoring/js/vendor/underscore.js +1416 -0
- edx_proctoring/static/proctoring/js/views/Backbone.ModalDialog.js +338 -0
- edx_proctoring/static/proctoring/js/views/proctored_exam_add_bulk_allowance_view.js +300 -0
- edx_proctoring/static/proctoring/js/views/proctored_exam_allowance_view.js +204 -0
- edx_proctoring/static/proctoring/js/views/proctored_exam_attempt_view.js +307 -0
- edx_proctoring/static/proctoring/js/views/proctored_exam_edit_allowance_view.js +165 -0
- edx_proctoring/static/proctoring/js/views/proctored_exam_info.js +232 -0
- edx_proctoring/static/proctoring/js/views/proctored_exam_instructor_launch.js +41 -0
- edx_proctoring/static/proctoring/js/views/proctored_exam_onboarding_view.js +288 -0
- edx_proctoring/static/proctoring/js/views/proctored_exam_view.js +267 -0
- edx_proctoring/static/proctoring/templates/add-new-bulk-allowance.underscore +105 -0
- edx_proctoring/static/proctoring/templates/course_grouped_allowances.underscore +70 -0
- edx_proctoring/static/proctoring/templates/dashboard.underscore +3 -0
- edx_proctoring/static/proctoring/templates/edit-allowance.underscore +52 -0
- edx_proctoring/static/proctoring/templates/proctored-exam-info.underscore +46 -0
- edx_proctoring/static/proctoring/templates/student-onboarding-status.underscore +153 -0
- edx_proctoring/static/proctoring/templates/student-proctored-exam-attempts-grouped.underscore +287 -0
- edx_proctoring/statuses.py +330 -0
- edx_proctoring/templates/common/waiting_banner.html +12 -0
- edx_proctoring/templates/emails/proctoring_attempt_satisfactory_email.html +28 -0
- edx_proctoring/templates/emails/proctoring_attempt_submitted_email.html +20 -0
- edx_proctoring/templates/emails/proctoring_attempt_unsatisfactory_email.html +33 -0
- edx_proctoring/templates/onboarding_exam/entrance.html +65 -0
- edx_proctoring/templates/onboarding_exam/error.html +18 -0
- edx_proctoring/templates/onboarding_exam/ready_to_submit.html +10 -0
- edx_proctoring/templates/onboarding_exam/rejected.html +33 -0
- edx_proctoring/templates/onboarding_exam/submitted.html +60 -0
- edx_proctoring/templates/onboarding_exam/verified.html +23 -0
- edx_proctoring/templates/practice_exam/entrance.html +90 -0
- edx_proctoring/templates/practice_exam/error.html +42 -0
- edx_proctoring/templates/practice_exam/submitted.html +31 -0
- edx_proctoring/templates/proctored_exam/confirm-decline.html +55 -0
- edx_proctoring/templates/proctored_exam/entrance.html +29 -0
- edx_proctoring/templates/proctored_exam/error.html +26 -0
- edx_proctoring/templates/proctored_exam/error_modal.html +26 -0
- edx_proctoring/templates/proctored_exam/error_wrong_browser.html +32 -0
- edx_proctoring/templates/proctored_exam/expired.html +13 -0
- edx_proctoring/templates/proctored_exam/failed-prerequisites.html +46 -0
- edx_proctoring/templates/proctored_exam/footer.html +80 -0
- edx_proctoring/templates/proctored_exam/inactive_account.html +38 -0
- edx_proctoring/templates/proctored_exam/instructions.html +227 -0
- edx_proctoring/templates/proctored_exam/onboarding_error.html +45 -0
- edx_proctoring/templates/proctored_exam/pending-prerequisites.html +40 -0
- edx_proctoring/templates/proctored_exam/proctoring_launch_callback.html +183 -0
- edx_proctoring/templates/proctored_exam/proctoring_opt_out_button.html +8 -0
- edx_proctoring/templates/proctored_exam/ready_to_resume.html +33 -0
- edx_proctoring/templates/proctored_exam/ready_to_start.html +70 -0
- edx_proctoring/templates/proctored_exam/ready_to_submit.html +39 -0
- edx_proctoring/templates/proctored_exam/rejected.html +30 -0
- edx_proctoring/templates/proctored_exam/submitted.html +24 -0
- edx_proctoring/templates/proctored_exam/verified.html +19 -0
- edx_proctoring/templates/proctored_exam/visit_exam_content.html +38 -0
- edx_proctoring/templates/timed_exam/entrance.html +79 -0
- edx_proctoring/templates/timed_exam/expired.html +13 -0
- edx_proctoring/templates/timed_exam/footer.html +10 -0
- edx_proctoring/templates/timed_exam/ready_to_submit.html +56 -0
- edx_proctoring/templates/timed_exam/submitted.html +32 -0
- edx_proctoring/tests/__init__.py +56 -0
- edx_proctoring/tests/test_api.py +3878 -0
- edx_proctoring/tests/test_email.py +325 -0
- edx_proctoring/tests/test_handlers.py +56 -0
- edx_proctoring/tests/test_mfe_views.py +579 -0
- edx_proctoring/tests/test_models.py +483 -0
- edx_proctoring/tests/test_reviews.py +844 -0
- edx_proctoring/tests/test_serializer.py +34 -0
- edx_proctoring/tests/test_services.py +429 -0
- edx_proctoring/tests/test_student_view.py +1676 -0
- edx_proctoring/tests/test_utils/factories.py +35 -0
- edx_proctoring/tests/test_utils/test_utils.py +207 -0
- edx_proctoring/tests/test_utils/utils.py +402 -0
- edx_proctoring/tests/test_views.py +6501 -0
- edx_proctoring/tests/test_workerconfig.py +96 -0
- edx_proctoring/urls.py +149 -0
- edx_proctoring/utils.py +488 -0
- edx_proctoring/views.py +2333 -0
- edx_proctoring-6.2.0.dist-info/METADATA +152 -0
- edx_proctoring-6.2.0.dist-info/RECORD +293 -0
- edx_proctoring-6.2.0.dist-info/WHEEL +5 -0
- edx_proctoring-6.2.0.dist-info/entry_points.txt +11 -0
- edx_proctoring-6.2.0.dist-info/licenses/LICENSE.txt +674 -0
- edx_proctoring-6.2.0.dist-info/top_level.txt +1 -0
edx_proctoring/api.py
ADDED
|
@@ -0,0 +1,3269 @@
|
|
|
1
|
+
# pylint: disable=too-many-branches, too-many-lines, too-many-statements
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
In-Proc API (aka Library) for the edx_proctoring subsystem. This is not to be confused with a HTTP REST
|
|
5
|
+
API which is in the views.py file, per edX coding standards
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
|
|
12
|
+
import pytz
|
|
13
|
+
from opaque_keys import InvalidKeyError
|
|
14
|
+
|
|
15
|
+
from django.conf import settings
|
|
16
|
+
from django.contrib.auth import get_user_model
|
|
17
|
+
from django.core.exceptions import ObjectDoesNotExist
|
|
18
|
+
from django.core.mail.message import EmailMessage
|
|
19
|
+
from django.template import loader
|
|
20
|
+
from django.urls import NoReverseMatch, reverse
|
|
21
|
+
from django.utils.translation import gettext as _
|
|
22
|
+
from django.utils.translation import gettext_noop
|
|
23
|
+
|
|
24
|
+
from edx_proctoring import constants
|
|
25
|
+
from edx_proctoring.backends import get_backend_provider
|
|
26
|
+
from edx_proctoring.exceptions import (
|
|
27
|
+
AllowanceValueNotAllowedException,
|
|
28
|
+
BackendProviderCannotRegisterAttempt,
|
|
29
|
+
BackendProviderCannotRemoveAttempt,
|
|
30
|
+
BackendProviderNotConfigured,
|
|
31
|
+
BackendProviderOnboardingException,
|
|
32
|
+
BackendProviderRemovalNotConfirmed,
|
|
33
|
+
BackendProviderSentNoAttemptID,
|
|
34
|
+
ProctoredBaseException,
|
|
35
|
+
ProctoredExamAlreadyExists,
|
|
36
|
+
ProctoredExamIllegalResumeUpdate,
|
|
37
|
+
ProctoredExamIllegalStatusTransition,
|
|
38
|
+
ProctoredExamNotActiveException,
|
|
39
|
+
ProctoredExamNotFoundException,
|
|
40
|
+
ProctoredExamPermissionDenied,
|
|
41
|
+
ProctoredExamReviewPolicyAlreadyExists,
|
|
42
|
+
ProctoredExamReviewPolicyNotFoundException,
|
|
43
|
+
StudentExamAttemptAlreadyExistsException,
|
|
44
|
+
StudentExamAttemptDoesNotExistsException,
|
|
45
|
+
StudentExamAttemptedAlreadyStarted,
|
|
46
|
+
StudentExamAttemptOnPastDueProctoredExam
|
|
47
|
+
)
|
|
48
|
+
from edx_proctoring.models import (
|
|
49
|
+
ProctoredExam,
|
|
50
|
+
ProctoredExamReviewPolicy,
|
|
51
|
+
ProctoredExamSoftwareSecureReview,
|
|
52
|
+
ProctoredExamStudentAllowance,
|
|
53
|
+
ProctoredExamStudentAttempt
|
|
54
|
+
)
|
|
55
|
+
from edx_proctoring.runtime import get_runtime_service
|
|
56
|
+
from edx_proctoring.serializers import (
|
|
57
|
+
ProctoredExamReviewPolicySerializer,
|
|
58
|
+
ProctoredExamSerializer,
|
|
59
|
+
ProctoredExamStudentAllowanceSerializer,
|
|
60
|
+
ProctoredExamStudentAttemptSerializer
|
|
61
|
+
)
|
|
62
|
+
from edx_proctoring.signals import exam_attempt_status_signal
|
|
63
|
+
from edx_proctoring.statuses import InstructorDashboardOnboardingAttemptStatus, ProctoredExamStudentAttemptStatus
|
|
64
|
+
from edx_proctoring.utils import (
|
|
65
|
+
categorize_inaccessible_exams_by_date,
|
|
66
|
+
emit_event,
|
|
67
|
+
get_course_home_url,
|
|
68
|
+
get_exam_due_date,
|
|
69
|
+
get_exam_type,
|
|
70
|
+
get_exam_url,
|
|
71
|
+
get_time_remaining_for_attempt,
|
|
72
|
+
get_user_course_outline_details,
|
|
73
|
+
has_due_date_passed,
|
|
74
|
+
has_end_date_passed,
|
|
75
|
+
humanized_time,
|
|
76
|
+
is_reattempting_exam,
|
|
77
|
+
obscured_user_id,
|
|
78
|
+
verify_and_add_wait_deadline
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
log = logging.getLogger(__name__)
|
|
82
|
+
|
|
83
|
+
SHOW_EXPIRY_MESSAGE_DURATION = 1 * 60 # duration within which expiry message is shown for a timed-out exam
|
|
84
|
+
|
|
85
|
+
APPROVED_STATUS = 'approved'
|
|
86
|
+
|
|
87
|
+
REJECTED_GRADE_OVERRIDE_EARNED = 0.0
|
|
88
|
+
|
|
89
|
+
USER_MODEL = get_user_model()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_onboarding_exam_link(course_id, user, is_learning_mfe):
|
|
93
|
+
"""
|
|
94
|
+
Get link to the onboarding exam available to the user for given course.
|
|
95
|
+
|
|
96
|
+
Parameters:
|
|
97
|
+
* course_id: id of a course where search for the onboarding exam is performed
|
|
98
|
+
* user: user for whom the link is built
|
|
99
|
+
* is_learning_mfe: Boolean, indicates if the url should be built for the learning mfe application.
|
|
100
|
+
|
|
101
|
+
Returns: url pointing to the available exam, if there is no onboarding exam available
|
|
102
|
+
to the user returns empty string.
|
|
103
|
+
"""
|
|
104
|
+
onboarding_exams = list(ProctoredExam.get_practice_proctored_exams_for_course(course_id).order_by('-created'))
|
|
105
|
+
if not onboarding_exams or not get_backend_provider(name=onboarding_exams[0].backend).supports_onboarding:
|
|
106
|
+
onboarding_link = ''
|
|
107
|
+
else:
|
|
108
|
+
details = get_user_course_outline_details(user, course_id)
|
|
109
|
+
categorized_exams = categorize_inaccessible_exams_by_date(onboarding_exams, details)
|
|
110
|
+
non_date_inaccessible_exams = categorized_exams[0]
|
|
111
|
+
|
|
112
|
+
# remove onboarding exams not accessible to learners
|
|
113
|
+
for onboarding_exam in non_date_inaccessible_exams:
|
|
114
|
+
onboarding_exams.remove(onboarding_exam)
|
|
115
|
+
|
|
116
|
+
if onboarding_exams:
|
|
117
|
+
onboarding_exam = onboarding_exams[0]
|
|
118
|
+
onboarding_link = get_exam_url(course_id, onboarding_exam.content_id, is_learning_mfe)
|
|
119
|
+
else:
|
|
120
|
+
onboarding_link = ''
|
|
121
|
+
return onboarding_link
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_total_allowed_time_for_exam(exam, user_id):
|
|
125
|
+
"""
|
|
126
|
+
Returns total allowed time in humanized form for exam including allowance time.
|
|
127
|
+
"""
|
|
128
|
+
total_allowed_time = _calculate_allowed_mins(exam, user_id)
|
|
129
|
+
return humanized_time(total_allowed_time)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_proctoring_settings_by_exam_id(exam_id):
|
|
133
|
+
"""
|
|
134
|
+
Return proctoring settings and exam proctoring backend for proctored exam by exam_id.
|
|
135
|
+
|
|
136
|
+
Raises an exception if exam_id not found.
|
|
137
|
+
Raises BackendProviderNotConfigured exception if proctoring backend is not configured.
|
|
138
|
+
"""
|
|
139
|
+
exam = get_exam_by_id(exam_id)
|
|
140
|
+
proctoring_settings = getattr(settings, 'PROCTORING_SETTINGS', {})
|
|
141
|
+
proctoring_settings_data = {
|
|
142
|
+
'platform_name': settings.PLATFORM_NAME,
|
|
143
|
+
'contact_us': settings.CONTACT_EMAIL,
|
|
144
|
+
'link_urls': proctoring_settings.get('LINK_URLS'),
|
|
145
|
+
'exam_proctoring_backend': {},
|
|
146
|
+
}
|
|
147
|
+
if exam.get('is_proctored'):
|
|
148
|
+
try:
|
|
149
|
+
provider = get_backend_provider(exam)
|
|
150
|
+
except NotImplementedError as error:
|
|
151
|
+
log.exception(str(error))
|
|
152
|
+
raise BackendProviderNotConfigured(str(error)) from error
|
|
153
|
+
proctoring_settings_data.update({
|
|
154
|
+
'exam_proctoring_backend': provider.get_proctoring_config(),
|
|
155
|
+
'provider_tech_support_email': provider.tech_support_email,
|
|
156
|
+
'provider_tech_support_phone': provider.tech_support_phone,
|
|
157
|
+
'provider_name': provider.verbose_name,
|
|
158
|
+
'learner_notification_from_email': provider.learner_notification_from_email,
|
|
159
|
+
'integration_specific_email': get_integration_specific_email(provider),
|
|
160
|
+
'proctoring_escalation_email': _get_proctoring_escalation_email(exam['course_id']),
|
|
161
|
+
})
|
|
162
|
+
return proctoring_settings_data
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# pylint: disable=too-many-positional-arguments
|
|
166
|
+
def create_exam(course_id, content_id, exam_name, time_limit_mins, due_date=None,
|
|
167
|
+
is_proctored=True, is_practice_exam=False, external_id=None, is_active=True, hide_after_due=False,
|
|
168
|
+
backend=None):
|
|
169
|
+
"""
|
|
170
|
+
Creates a new ProctoredExam entity, if the course_id/content_id pair do not already exist.
|
|
171
|
+
If that pair already exists, then raise exception.
|
|
172
|
+
|
|
173
|
+
Returns: id (PK)
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
if ProctoredExam.get_exam_by_content_id(course_id, content_id) is not None:
|
|
177
|
+
err_msg = (
|
|
178
|
+
f'Attempted to create proctored exam with course_id={course_id} and content_id={content_id}, '
|
|
179
|
+
'but this exam already exists.'
|
|
180
|
+
)
|
|
181
|
+
raise ProctoredExamAlreadyExists(err_msg)
|
|
182
|
+
|
|
183
|
+
proctored_exam = ProctoredExam.objects.create(
|
|
184
|
+
course_id=course_id,
|
|
185
|
+
content_id=content_id,
|
|
186
|
+
external_id=external_id,
|
|
187
|
+
exam_name=exam_name,
|
|
188
|
+
time_limit_mins=time_limit_mins,
|
|
189
|
+
due_date=due_date,
|
|
190
|
+
is_proctored=is_proctored,
|
|
191
|
+
is_practice_exam=is_practice_exam,
|
|
192
|
+
is_active=is_active,
|
|
193
|
+
hide_after_due=hide_after_due,
|
|
194
|
+
backend=backend or settings.PROCTORING_BACKENDS.get('DEFAULT', None),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
log.info(
|
|
198
|
+
('Created exam (exam_id=%(exam_id)s) with parameters: course_id=%(course_id)s, '
|
|
199
|
+
'content_id=%(content_id)s, exam_name=%(exam_name)s, time_limit_mins=%(time_limit_mins)s, '
|
|
200
|
+
'is_proctored=%(is_proctored)s, is_practice_exam=%(is_practice_exam)s, '
|
|
201
|
+
'external_id=%(external_id)s, is_active=%(is_active)s, hide_after_due=%(hide_after_due)s'),
|
|
202
|
+
{
|
|
203
|
+
'exam_id': proctored_exam.id,
|
|
204
|
+
'course_id': course_id,
|
|
205
|
+
'content_id': content_id,
|
|
206
|
+
'exam_name': exam_name,
|
|
207
|
+
'time_limit_mins': time_limit_mins,
|
|
208
|
+
'is_proctored': is_proctored,
|
|
209
|
+
'is_practice_exam': is_practice_exam,
|
|
210
|
+
'external_id': external_id,
|
|
211
|
+
'hide_after_due': hide_after_due,
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# read back exam so we can emit an event on it
|
|
216
|
+
exam = get_exam_by_id(proctored_exam.id)
|
|
217
|
+
emit_event(exam, 'created')
|
|
218
|
+
return proctored_exam.id
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def create_exam_review_policy(exam_id, set_by_user_id, review_policy):
|
|
222
|
+
"""
|
|
223
|
+
Creates a new exam_review_policy entity, if the review_policy
|
|
224
|
+
for exam_id does not already exist. If it exists, then raise exception.
|
|
225
|
+
|
|
226
|
+
Arguments:
|
|
227
|
+
exam_id: the ID of the exam with which the review policy is to be associated
|
|
228
|
+
set_by_user_id: the ID of the user setting this review policy
|
|
229
|
+
review_policy: the review policy for the exam
|
|
230
|
+
|
|
231
|
+
Returns: id (PK)
|
|
232
|
+
"""
|
|
233
|
+
exam_review_policy = ProctoredExamReviewPolicy.get_review_policy_for_exam(exam_id)
|
|
234
|
+
if exam_review_policy is not None:
|
|
235
|
+
err_msg = (
|
|
236
|
+
f'user_id={set_by_user_id} attempted to create an exam review policy for '
|
|
237
|
+
f'exam_id={exam_id}, but a review policy for this exam already exists.'
|
|
238
|
+
)
|
|
239
|
+
raise ProctoredExamReviewPolicyAlreadyExists(err_msg)
|
|
240
|
+
|
|
241
|
+
exam_review_policy = ProctoredExamReviewPolicy.objects.create(
|
|
242
|
+
proctored_exam_id=exam_id,
|
|
243
|
+
set_by_user_id=set_by_user_id,
|
|
244
|
+
review_policy=review_policy,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
log.info(
|
|
248
|
+
('Created ProctoredExamReviewPolicy (review_policy=%(review_policy)s) '
|
|
249
|
+
'with parameters: exam_id=%(exam_id)s, set_by_user_id=%(set_by_user_id)s'),
|
|
250
|
+
{
|
|
251
|
+
'exam_id': exam_id,
|
|
252
|
+
'review_policy': review_policy,
|
|
253
|
+
'set_by_user_id': set_by_user_id,
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
return exam_review_policy.id
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def update_review_policy(exam_id, set_by_user_id, review_policy):
|
|
261
|
+
"""
|
|
262
|
+
Given a exam id, update/remove the existing record, otherwise raise exception if not found.
|
|
263
|
+
|
|
264
|
+
Arguments:
|
|
265
|
+
exam_id: the ID of the exam whose review policy is being updated
|
|
266
|
+
set_by_user_id: the ID of the user updating this review policy
|
|
267
|
+
review_policy: the review policy for the exam
|
|
268
|
+
|
|
269
|
+
Returns: review_policy_id
|
|
270
|
+
"""
|
|
271
|
+
log.info(
|
|
272
|
+
('Updating exam review policy with exam_id=%(exam_id)s '
|
|
273
|
+
'set_by_user_id=%(set_by_user_id)s, review_policy=%(review_policy)s '),
|
|
274
|
+
{
|
|
275
|
+
'exam_id': exam_id,
|
|
276
|
+
'set_by_user_id': set_by_user_id,
|
|
277
|
+
'review_policy': review_policy,
|
|
278
|
+
}
|
|
279
|
+
)
|
|
280
|
+
exam_review_policy = ProctoredExamReviewPolicy.get_review_policy_for_exam(exam_id)
|
|
281
|
+
if exam_review_policy is None:
|
|
282
|
+
err_msg = (
|
|
283
|
+
f'user_id={set_by_user_id} attempted to update exam review policy for '
|
|
284
|
+
f'exam_id={exam_id}, but this exam does not have a review policy.'
|
|
285
|
+
)
|
|
286
|
+
raise ProctoredExamReviewPolicyNotFoundException(err_msg)
|
|
287
|
+
|
|
288
|
+
if review_policy:
|
|
289
|
+
exam_review_policy.set_by_user_id = set_by_user_id
|
|
290
|
+
exam_review_policy.review_policy = review_policy
|
|
291
|
+
exam_review_policy.save()
|
|
292
|
+
log.info(
|
|
293
|
+
'Updated exam review policy for exam_id=%(exam_id)s, set_by_user_id=%(set_by_user_id)s',
|
|
294
|
+
{
|
|
295
|
+
'set_by_user_id': set_by_user_id,
|
|
296
|
+
'exam_id': exam_id,
|
|
297
|
+
}
|
|
298
|
+
)
|
|
299
|
+
else:
|
|
300
|
+
exam_review_policy.delete()
|
|
301
|
+
log.info(
|
|
302
|
+
'Removed exam review policy for exam_id=%(exam_id)s, set_by_user_id=%(set_by_user_id)s',
|
|
303
|
+
{
|
|
304
|
+
'set_by_user_id': set_by_user_id,
|
|
305
|
+
'exam_id': exam_id,
|
|
306
|
+
}
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def remove_review_policy(exam_id):
|
|
311
|
+
"""
|
|
312
|
+
Given a exam id, remove the existing record, otherwise raise exception if not found.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
log.info(
|
|
316
|
+
'Removing exam review policy for exam_id=%(exam_id)s',
|
|
317
|
+
{
|
|
318
|
+
'exam_id': exam_id,
|
|
319
|
+
}
|
|
320
|
+
)
|
|
321
|
+
exam_review_policy = ProctoredExamReviewPolicy.get_review_policy_for_exam(exam_id)
|
|
322
|
+
if exam_review_policy is None:
|
|
323
|
+
err_msg = (
|
|
324
|
+
f'Attempted to remove exam remove policy for exam_id={exam_id}, but this '
|
|
325
|
+
'exam does not have a review policy.'
|
|
326
|
+
)
|
|
327
|
+
raise ProctoredExamReviewPolicyNotFoundException(err_msg)
|
|
328
|
+
|
|
329
|
+
exam_review_policy.delete()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def get_review_policy_by_exam_id(exam_id):
|
|
333
|
+
"""
|
|
334
|
+
Looks up exam by the Primary Key. Raises exception if not found.
|
|
335
|
+
|
|
336
|
+
Returns dictionary version of the Django ORM object
|
|
337
|
+
e.g.
|
|
338
|
+
{
|
|
339
|
+
"id": 1
|
|
340
|
+
"proctored_exam": "{object}",
|
|
341
|
+
"set_by_user": "{object}",
|
|
342
|
+
"exam_review_rules": "review rules value"
|
|
343
|
+
"created": "datetime",
|
|
344
|
+
"modified": "datetime"
|
|
345
|
+
}
|
|
346
|
+
"""
|
|
347
|
+
exam_review_policy = ProctoredExamReviewPolicy.get_review_policy_for_exam(exam_id)
|
|
348
|
+
if exam_review_policy is None:
|
|
349
|
+
err_msg = (
|
|
350
|
+
f'Attempted to get exam review policy for exam_id={exam_id}, but this review '
|
|
351
|
+
'policy does not exist.'
|
|
352
|
+
)
|
|
353
|
+
raise ProctoredExamReviewPolicyNotFoundException(err_msg)
|
|
354
|
+
|
|
355
|
+
return ProctoredExamReviewPolicySerializer(exam_review_policy).data
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _get_review_policy_by_exam_id(exam_id):
|
|
359
|
+
"""
|
|
360
|
+
Looks up exam by the primary key. Returns None if not found
|
|
361
|
+
|
|
362
|
+
Returns review_policy field of the Django ORM object
|
|
363
|
+
"""
|
|
364
|
+
try:
|
|
365
|
+
exam_review_policy = get_review_policy_by_exam_id(exam_id)
|
|
366
|
+
return ProctoredExamReviewPolicySerializer(exam_review_policy).data['review_policy']
|
|
367
|
+
except ProctoredExamReviewPolicyNotFoundException:
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# pylint: disable=too-many-positional-arguments
|
|
372
|
+
def update_exam(exam_id, exam_name=None, time_limit_mins=None, due_date=constants.MINIMUM_TIME,
|
|
373
|
+
is_proctored=None, is_practice_exam=None, external_id=None, is_active=None,
|
|
374
|
+
hide_after_due=None, backend=None):
|
|
375
|
+
"""
|
|
376
|
+
Given a Django ORM id, update the existing record, otherwise raise exception if not found.
|
|
377
|
+
If an argument is not passed in, then do not change it's current value.
|
|
378
|
+
|
|
379
|
+
Returns: id
|
|
380
|
+
"""
|
|
381
|
+
|
|
382
|
+
log.info(
|
|
383
|
+
('Updating exam_id=%(exam_id)s with parameters '
|
|
384
|
+
'exam_name=%(exam_name)s, time_limit_mins=%(time_limit_mins)s, due_date=%(due_date)s'
|
|
385
|
+
'is_proctored=%(is_proctored)s, is_practice_exam=%(is_practice_exam)s, '
|
|
386
|
+
'external_id=%(external_id)s, is_active=%(is_active)s, hide_after_due=%(hide_after_due)s, '
|
|
387
|
+
'backend=%(backend)s'),
|
|
388
|
+
{
|
|
389
|
+
'exam_id': exam_id,
|
|
390
|
+
'exam_name': exam_name,
|
|
391
|
+
'time_limit_mins': time_limit_mins,
|
|
392
|
+
'due_date': due_date,
|
|
393
|
+
'is_proctored': is_proctored,
|
|
394
|
+
'is_practice_exam': is_practice_exam,
|
|
395
|
+
'external_id': external_id,
|
|
396
|
+
'is_active': is_active,
|
|
397
|
+
'hide_after_due': hide_after_due,
|
|
398
|
+
'backend': backend,
|
|
399
|
+
}
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
proctored_exam = ProctoredExam.get_exam_by_id(exam_id)
|
|
403
|
+
if proctored_exam is None:
|
|
404
|
+
err_msg = (
|
|
405
|
+
f'Attempted to update exam_id={exam_id}, but this exam does not exist.'
|
|
406
|
+
)
|
|
407
|
+
raise ProctoredExamNotFoundException(err_msg)
|
|
408
|
+
|
|
409
|
+
if exam_name is not None:
|
|
410
|
+
proctored_exam.exam_name = exam_name
|
|
411
|
+
if time_limit_mins is not None:
|
|
412
|
+
proctored_exam.time_limit_mins = time_limit_mins
|
|
413
|
+
if due_date is not constants.MINIMUM_TIME:
|
|
414
|
+
proctored_exam.due_date = due_date
|
|
415
|
+
if is_proctored is not None:
|
|
416
|
+
proctored_exam.is_proctored = is_proctored
|
|
417
|
+
if is_practice_exam is not None:
|
|
418
|
+
proctored_exam.is_practice_exam = is_practice_exam
|
|
419
|
+
if external_id is not None:
|
|
420
|
+
proctored_exam.external_id = external_id
|
|
421
|
+
if is_active is not None:
|
|
422
|
+
proctored_exam.is_active = is_active
|
|
423
|
+
if hide_after_due is not None:
|
|
424
|
+
proctored_exam.hide_after_due = hide_after_due
|
|
425
|
+
if backend is not None:
|
|
426
|
+
proctored_exam.backend = backend
|
|
427
|
+
proctored_exam.save()
|
|
428
|
+
|
|
429
|
+
# read back exam so we can emit an event on it
|
|
430
|
+
exam = get_exam_by_id(proctored_exam.id)
|
|
431
|
+
emit_event(exam, 'updated')
|
|
432
|
+
|
|
433
|
+
return proctored_exam.id
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def get_exam_by_id(exam_id):
|
|
437
|
+
"""
|
|
438
|
+
Looks up exam by the Primary Key. Raises exception if not found.
|
|
439
|
+
|
|
440
|
+
Returns dictionary version of the Django ORM object
|
|
441
|
+
e.g.
|
|
442
|
+
{
|
|
443
|
+
"course_id": "edX/DemoX/Demo_Course",
|
|
444
|
+
"content_id": "123",
|
|
445
|
+
"external_id": "",
|
|
446
|
+
"exam_name": "Midterm",
|
|
447
|
+
"time_limit_mins": 90,
|
|
448
|
+
"is_proctored": true,
|
|
449
|
+
"is_active": true
|
|
450
|
+
}
|
|
451
|
+
"""
|
|
452
|
+
proctored_exam = ProctoredExam.get_exam_by_id(exam_id)
|
|
453
|
+
if proctored_exam is None:
|
|
454
|
+
err_msg = (
|
|
455
|
+
f'Attempted to get exam_id={exam_id}, but this exam does not exist.'
|
|
456
|
+
)
|
|
457
|
+
raise ProctoredExamNotFoundException(err_msg)
|
|
458
|
+
|
|
459
|
+
serialized_exam_object = ProctoredExamSerializer(proctored_exam)
|
|
460
|
+
return serialized_exam_object.data
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def get_exam_by_content_id(course_id, content_id):
|
|
464
|
+
"""
|
|
465
|
+
Looks up exam by the course_id/content_id pair. Raises exception if not found.
|
|
466
|
+
|
|
467
|
+
Returns dictionary version of the Django ORM object
|
|
468
|
+
e.g.
|
|
469
|
+
{
|
|
470
|
+
"course_id": "edX/DemoX/Demo_Course",
|
|
471
|
+
"content_id": "123",
|
|
472
|
+
"external_id": "",
|
|
473
|
+
"exam_name": "Midterm",
|
|
474
|
+
"time_limit_mins": 90,
|
|
475
|
+
"is_proctored": true,
|
|
476
|
+
"is_active": true
|
|
477
|
+
}
|
|
478
|
+
"""
|
|
479
|
+
proctored_exam = ProctoredExam.get_exam_by_content_id(course_id, content_id)
|
|
480
|
+
if proctored_exam is None:
|
|
481
|
+
err_msg = (
|
|
482
|
+
f'Cannot find proctored exam in course_id={course_id} with content_id={content_id}'
|
|
483
|
+
)
|
|
484
|
+
raise ProctoredExamNotFoundException(err_msg)
|
|
485
|
+
|
|
486
|
+
serialized_exam_object = ProctoredExamSerializer(proctored_exam)
|
|
487
|
+
return serialized_exam_object.data
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def add_allowance_for_user(exam_id, user_info, key, value):
|
|
491
|
+
"""
|
|
492
|
+
Adds (or updates) an allowance for a user within a given exam
|
|
493
|
+
"""
|
|
494
|
+
|
|
495
|
+
log.info(
|
|
496
|
+
('Adding allowance key=%(key)s with value=%(value)s for exam_id=%(exam_id)s '
|
|
497
|
+
'for user=%(user_info)s '),
|
|
498
|
+
{
|
|
499
|
+
'key': key,
|
|
500
|
+
'value': value,
|
|
501
|
+
'exam_id': exam_id,
|
|
502
|
+
'user_info': user_info,
|
|
503
|
+
}
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
try:
|
|
507
|
+
student_allowance, action = ProctoredExamStudentAllowance.add_allowance_for_user(exam_id, user_info, key, value)
|
|
508
|
+
except ProctoredExamNotActiveException as error:
|
|
509
|
+
# let this exception raised so that we get 400 in case of inactive exam
|
|
510
|
+
raise ProctoredExamNotActiveException from error
|
|
511
|
+
|
|
512
|
+
if student_allowance is not None:
|
|
513
|
+
# emit an event for 'allowance.created|updated'
|
|
514
|
+
data = {
|
|
515
|
+
'allowance_user_id': student_allowance.user.id,
|
|
516
|
+
'allowance_key': student_allowance.key,
|
|
517
|
+
'allowance_value': student_allowance.value
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
exam = get_exam_by_id(exam_id)
|
|
521
|
+
emit_event(exam, f'allowance.{action}', override_data=data)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def get_allowances_for_course(course_id):
|
|
525
|
+
"""
|
|
526
|
+
Get all the allowances for the course.
|
|
527
|
+
"""
|
|
528
|
+
student_allowances = ProctoredExamStudentAllowance.get_allowances_for_course(
|
|
529
|
+
course_id
|
|
530
|
+
)
|
|
531
|
+
return [ProctoredExamStudentAllowanceSerializer(allowance).data for allowance in student_allowances]
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def remove_allowance_for_user(exam_id, user_id, key):
|
|
535
|
+
"""
|
|
536
|
+
Deletes an allowance for a user within a given exam.
|
|
537
|
+
"""
|
|
538
|
+
log.info(
|
|
539
|
+
'Removing allowance key=%(key)s for exam_id=%(exam_id)s for user_id=%(user_id)s',
|
|
540
|
+
{
|
|
541
|
+
'key': key,
|
|
542
|
+
'exam_id': exam_id,
|
|
543
|
+
'user_id': user_id,
|
|
544
|
+
}
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
student_allowance = ProctoredExamStudentAllowance.get_allowance_for_user(exam_id, user_id, key)
|
|
548
|
+
if student_allowance is not None:
|
|
549
|
+
student_allowance.delete()
|
|
550
|
+
|
|
551
|
+
# emit an event for 'allowance.deleted'
|
|
552
|
+
data = {
|
|
553
|
+
'allowance_user_id': student_allowance.user.id,
|
|
554
|
+
'allowance_key': student_allowance.key,
|
|
555
|
+
'allowance_value': student_allowance.value
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
exam = get_exam_by_id(exam_id)
|
|
559
|
+
emit_event(exam, 'allowance.deleted', override_data=data)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def add_bulk_allowances(exam_ids, user_ids, allowance_type, value):
|
|
563
|
+
"""
|
|
564
|
+
Adds (or updates) an allowance for multiple users and exams
|
|
565
|
+
"""
|
|
566
|
+
|
|
567
|
+
# Input pasrsing
|
|
568
|
+
exam_ids = set(exam_ids)
|
|
569
|
+
user_ids = set(user_ids)
|
|
570
|
+
|
|
571
|
+
# Input validation logic to verify input is acceptable
|
|
572
|
+
log.info(
|
|
573
|
+
('Adding allowances of type allowance_type=%(allowance_type)s with value=%(value)s '
|
|
574
|
+
'for exams exam_ids=%(exam_ids)s and users user_ids=%(user_ids)s'),
|
|
575
|
+
{
|
|
576
|
+
'allowance_type': allowance_type,
|
|
577
|
+
'value': value,
|
|
578
|
+
'exam_ids': exam_ids,
|
|
579
|
+
'user_ids': user_ids,
|
|
580
|
+
}
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
multiplier = 0
|
|
584
|
+
if allowance_type == constants.TIME_MULTIPLIER:
|
|
585
|
+
err_msg = (
|
|
586
|
+
f'allowance_value "{value}" should be a float value greater than 1.'
|
|
587
|
+
)
|
|
588
|
+
try:
|
|
589
|
+
multiplier = float(value) - 1
|
|
590
|
+
except ValueError as error:
|
|
591
|
+
raise AllowanceValueNotAllowedException(err_msg) from error
|
|
592
|
+
if multiplier < 0:
|
|
593
|
+
raise AllowanceValueNotAllowedException(err_msg)
|
|
594
|
+
|
|
595
|
+
if (allowance_type in ProctoredExamStudentAllowance.ADDITIONAL_TIME_GRANTED
|
|
596
|
+
and not value.isdigit()):
|
|
597
|
+
err_msg = (
|
|
598
|
+
f'allowance_value "{value}" should be a non-negative integer value'
|
|
599
|
+
)
|
|
600
|
+
raise AllowanceValueNotAllowedException(err_msg)
|
|
601
|
+
|
|
602
|
+
# Data processing logic to add allowances to the database
|
|
603
|
+
successes = 0
|
|
604
|
+
failures = 0
|
|
605
|
+
data = []
|
|
606
|
+
for exam_id in exam_ids:
|
|
607
|
+
try:
|
|
608
|
+
target_exam = get_exam_by_id(exam_id)
|
|
609
|
+
except ProctoredExamNotFoundException:
|
|
610
|
+
log.error(
|
|
611
|
+
'Attempted to get exam_id=%(exam_id)s, but this exam does not exist.',
|
|
612
|
+
{
|
|
613
|
+
'exam_id': exam_id,
|
|
614
|
+
}
|
|
615
|
+
)
|
|
616
|
+
for user_id in user_ids:
|
|
617
|
+
failures += 1
|
|
618
|
+
data.append({
|
|
619
|
+
|
|
620
|
+
'exam_id': exam_id,
|
|
621
|
+
'user_id': user_id,
|
|
622
|
+
})
|
|
623
|
+
continue
|
|
624
|
+
if allowance_type == constants.TIME_MULTIPLIER:
|
|
625
|
+
exam_time = target_exam["time_limit_mins"]
|
|
626
|
+
added_time = round(exam_time * multiplier)
|
|
627
|
+
time_allowed = str(added_time)
|
|
628
|
+
for user_id in user_ids:
|
|
629
|
+
try:
|
|
630
|
+
add_allowance_for_user(exam_id, user_id,
|
|
631
|
+
ProctoredExamStudentAllowance.ADDITIONAL_TIME_GRANTED,
|
|
632
|
+
time_allowed)
|
|
633
|
+
successes += 1
|
|
634
|
+
data.append({
|
|
635
|
+
|
|
636
|
+
'exam_id': exam_id,
|
|
637
|
+
'user_id': user_id,
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
except ProctoredBaseException:
|
|
641
|
+
log.error(
|
|
642
|
+
('Failed to add allowance for user_id=%(user_id)s '
|
|
643
|
+
'for exam_id=%(exam_id)s'),
|
|
644
|
+
{
|
|
645
|
+
'user_id': user_id,
|
|
646
|
+
'exam_id': exam_id,
|
|
647
|
+
}
|
|
648
|
+
)
|
|
649
|
+
failures += 1
|
|
650
|
+
data.append({
|
|
651
|
+
|
|
652
|
+
'exam_id': exam_id,
|
|
653
|
+
'user_id': user_id,
|
|
654
|
+
})
|
|
655
|
+
else:
|
|
656
|
+
for user_id in user_ids:
|
|
657
|
+
try:
|
|
658
|
+
add_allowance_for_user(exam_id, user_id,
|
|
659
|
+
allowance_type,
|
|
660
|
+
value)
|
|
661
|
+
successes += 1
|
|
662
|
+
data.append({
|
|
663
|
+
|
|
664
|
+
'exam_id': exam_id,
|
|
665
|
+
'user_id': user_id,
|
|
666
|
+
})
|
|
667
|
+
except ProctoredBaseException:
|
|
668
|
+
log.error(
|
|
669
|
+
('Failed to add allowance for user_id=%(user_id)s '
|
|
670
|
+
'for exam_id=%(exam_id)s'),
|
|
671
|
+
{
|
|
672
|
+
'user_id': user_id,
|
|
673
|
+
'exam_id': exam_id,
|
|
674
|
+
}
|
|
675
|
+
)
|
|
676
|
+
failures += 1
|
|
677
|
+
data.append({
|
|
678
|
+
|
|
679
|
+
'exam_id': exam_id,
|
|
680
|
+
'user_id': user_id,
|
|
681
|
+
})
|
|
682
|
+
return data, successes, failures
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _check_for_attempt_timeout(attempt):
|
|
686
|
+
"""
|
|
687
|
+
Helper method to see if the status of an
|
|
688
|
+
exam needs to be updated, e.g. timeout
|
|
689
|
+
"""
|
|
690
|
+
|
|
691
|
+
if not attempt:
|
|
692
|
+
return attempt
|
|
693
|
+
|
|
694
|
+
# right now the only adjustment to
|
|
695
|
+
# status is transitioning to timeout
|
|
696
|
+
has_started_exam = (
|
|
697
|
+
attempt and
|
|
698
|
+
attempt.get('started_at') and
|
|
699
|
+
ProctoredExamStudentAttemptStatus.is_incomplete_status(attempt.get('status'))
|
|
700
|
+
)
|
|
701
|
+
if has_started_exam:
|
|
702
|
+
now_utc = datetime.now(pytz.UTC)
|
|
703
|
+
expires_at = attempt['started_at'] + timedelta(minutes=attempt['allowed_time_limit_mins'])
|
|
704
|
+
has_time_expired = now_utc > expires_at
|
|
705
|
+
|
|
706
|
+
if has_time_expired:
|
|
707
|
+
update_attempt_status(
|
|
708
|
+
attempt['id'],
|
|
709
|
+
ProctoredExamStudentAttemptStatus.timed_out,
|
|
710
|
+
timeout_timestamp=expires_at
|
|
711
|
+
)
|
|
712
|
+
attempt = get_exam_attempt_by_id(attempt['id'])
|
|
713
|
+
|
|
714
|
+
return attempt
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _get_exam_attempt(exam_attempt_obj):
|
|
718
|
+
"""
|
|
719
|
+
Helper method to commonalize all query patterns
|
|
720
|
+
"""
|
|
721
|
+
|
|
722
|
+
if not exam_attempt_obj:
|
|
723
|
+
return None
|
|
724
|
+
|
|
725
|
+
serialized_attempt_obj = ProctoredExamStudentAttemptSerializer(exam_attempt_obj)
|
|
726
|
+
attempt = serialized_attempt_obj.data
|
|
727
|
+
attempt = _check_for_attempt_timeout(attempt)
|
|
728
|
+
|
|
729
|
+
return attempt
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def get_current_exam_attempt(exam_id, user_id):
|
|
733
|
+
"""
|
|
734
|
+
Args:
|
|
735
|
+
int: exam id
|
|
736
|
+
int: user_id
|
|
737
|
+
Returns:
|
|
738
|
+
dict: our exam attempt
|
|
739
|
+
"""
|
|
740
|
+
exam_attempt_obj = ProctoredExamStudentAttempt.objects.get_current_exam_attempt(exam_id, user_id)
|
|
741
|
+
return _get_exam_attempt(exam_attempt_obj)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def get_exam_attempt_by_id(attempt_id):
|
|
745
|
+
"""
|
|
746
|
+
Args:
|
|
747
|
+
int: exam attempt id
|
|
748
|
+
Returns:
|
|
749
|
+
dict: our exam attempt
|
|
750
|
+
"""
|
|
751
|
+
exam_attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id(attempt_id)
|
|
752
|
+
return _get_exam_attempt(exam_attempt_obj)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def get_exam_attempt_by_external_id(external_id):
|
|
756
|
+
"""
|
|
757
|
+
Args:
|
|
758
|
+
str: exam attempt external_id
|
|
759
|
+
Returns:
|
|
760
|
+
dict: our exam attempt
|
|
761
|
+
"""
|
|
762
|
+
exam_attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_external_id(external_id)
|
|
763
|
+
return _get_exam_attempt(exam_attempt_obj)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def get_exam_attempt_by_code(attempt_code):
|
|
767
|
+
"""
|
|
768
|
+
Args:
|
|
769
|
+
str: exam attempt attempt_code
|
|
770
|
+
Returns:
|
|
771
|
+
dict: our exam attempt
|
|
772
|
+
"""
|
|
773
|
+
exam_attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_code(attempt_code)
|
|
774
|
+
return _get_exam_attempt(exam_attempt_obj)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def get_exam_attempt_data(exam_id, attempt_id, is_learning_mfe=False):
|
|
778
|
+
"""
|
|
779
|
+
Args:
|
|
780
|
+
int: exam id
|
|
781
|
+
int: exam attempt id
|
|
782
|
+
bool: indicates if exam_url_path should be built for the MFE
|
|
783
|
+
Returns:
|
|
784
|
+
dict: our exam attempt
|
|
785
|
+
"""
|
|
786
|
+
|
|
787
|
+
exam = get_exam_by_id(exam_id)
|
|
788
|
+
attempt = get_exam_attempt_by_id(attempt_id)
|
|
789
|
+
provider = get_backend_provider(exam)
|
|
790
|
+
|
|
791
|
+
time_remaining_seconds = get_time_remaining_for_attempt(attempt)
|
|
792
|
+
|
|
793
|
+
proctoring_settings = getattr(settings, 'PROCTORING_SETTINGS', {})
|
|
794
|
+
low_threshold_pct = proctoring_settings.get('low_threshold_pct', .2)
|
|
795
|
+
critically_low_threshold_pct = proctoring_settings.get('critically_low_threshold_pct', .05)
|
|
796
|
+
|
|
797
|
+
allowed_time_limit_mins = attempt.get('allowed_time_limit_mins') or 0
|
|
798
|
+
|
|
799
|
+
low_threshold = int(low_threshold_pct * float(allowed_time_limit_mins) * 60)
|
|
800
|
+
critically_low_threshold = int(
|
|
801
|
+
critically_low_threshold_pct * float(allowed_time_limit_mins) * 60
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
if not allowed_time_limit_mins or (attempt and is_attempt_ready_to_resume(attempt)):
|
|
805
|
+
allowed_time_limit_mins = _calculate_allowed_mins(exam, attempt['user']['id'])
|
|
806
|
+
|
|
807
|
+
total_time = humanized_time(allowed_time_limit_mins)
|
|
808
|
+
|
|
809
|
+
# resolve the LMS url, note we can't assume we're running in
|
|
810
|
+
# a same process as the LMS
|
|
811
|
+
exam_url_path = get_exam_url(exam['course_id'], exam['content_id'], is_learning_mfe)
|
|
812
|
+
|
|
813
|
+
attempt_data = {
|
|
814
|
+
'in_timed_exam': True,
|
|
815
|
+
'taking_as_proctored': attempt['taking_as_proctored'],
|
|
816
|
+
'exam_type': get_exam_type(exam, provider)['humanized_type'],
|
|
817
|
+
'exam_display_name': exam['exam_name'],
|
|
818
|
+
'exam_url_path': exam_url_path,
|
|
819
|
+
'time_remaining_seconds': time_remaining_seconds,
|
|
820
|
+
'total_time': total_time,
|
|
821
|
+
'low_threshold_sec': low_threshold,
|
|
822
|
+
'critically_low_threshold_sec': critically_low_threshold,
|
|
823
|
+
'course_id': exam['course_id'],
|
|
824
|
+
'attempt_id': attempt['id'],
|
|
825
|
+
'external_id': attempt['external_id'],
|
|
826
|
+
'accessibility_time_string': _('you have {remaining_time} remaining').format(
|
|
827
|
+
remaining_time=humanized_time(int(round(time_remaining_seconds / 60.0, 0)))
|
|
828
|
+
),
|
|
829
|
+
'attempt_status': attempt['status'],
|
|
830
|
+
'exam_started_poll_url': reverse(
|
|
831
|
+
'edx_proctoring:proctored_exam.attempt',
|
|
832
|
+
args=[attempt['id']]
|
|
833
|
+
),
|
|
834
|
+
'attempt_ready_to_resume': is_attempt_ready_to_resume(attempt),
|
|
835
|
+
# used by the frontend to determine if attempt is managed by edx-proctoring
|
|
836
|
+
# instead of the newer edx-exams service
|
|
837
|
+
'use_legacy_attempt_api': True,
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if provider:
|
|
841
|
+
attempt_data.update({
|
|
842
|
+
'desktop_application_js_url': provider.get_javascript(),
|
|
843
|
+
'ping_interval': provider.ping_interval,
|
|
844
|
+
'attempt_code': attempt['attempt_code']
|
|
845
|
+
})
|
|
846
|
+
if attempt['status'] in (
|
|
847
|
+
ProctoredExamStudentAttemptStatus.created,
|
|
848
|
+
ProctoredExamStudentAttemptStatus.download_software_clicked
|
|
849
|
+
):
|
|
850
|
+
provider_attempt = provider.get_attempt(attempt)
|
|
851
|
+
download_url = provider_attempt.get('download_url', None) or provider.get_software_download_url()
|
|
852
|
+
attempt_data['software_download_url'] = download_url
|
|
853
|
+
else:
|
|
854
|
+
attempt_data['desktop_application_js_url'] = ''
|
|
855
|
+
|
|
856
|
+
return attempt_data
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def update_exam_attempt(attempt_id, **kwargs):
|
|
860
|
+
"""
|
|
861
|
+
Update exam_attempt
|
|
862
|
+
"""
|
|
863
|
+
exam_attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id(attempt_id)
|
|
864
|
+
if not exam_attempt_obj:
|
|
865
|
+
err_msg = (
|
|
866
|
+
f'Attempted to update exam attempt with attempt_id={attempt_id} but '
|
|
867
|
+
'this attempt does not exist.'
|
|
868
|
+
)
|
|
869
|
+
raise StudentExamAttemptDoesNotExistsException(err_msg)
|
|
870
|
+
|
|
871
|
+
for key, value in kwargs.items():
|
|
872
|
+
# only allow a limit set of fields to update
|
|
873
|
+
# namely because status transitions can trigger workflow
|
|
874
|
+
if key not in [
|
|
875
|
+
'is_status_acknowledged',
|
|
876
|
+
'time_remaining_seconds',
|
|
877
|
+
]:
|
|
878
|
+
err_msg = (
|
|
879
|
+
'You cannot call into update_exam_attempt to change '
|
|
880
|
+
f'field={key}. (attempt_id={attempt_id})'
|
|
881
|
+
)
|
|
882
|
+
raise ProctoredExamPermissionDenied(err_msg)
|
|
883
|
+
setattr(exam_attempt_obj, key, value)
|
|
884
|
+
exam_attempt_obj.save()
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def is_exam_passed_due(exam, user=None):
|
|
888
|
+
"""
|
|
889
|
+
Return whether the due date has passed.
|
|
890
|
+
Uses edx_when to lookup the date for the subsection.
|
|
891
|
+
"""
|
|
892
|
+
due_date = get_exam_due_date(exam, user=user)
|
|
893
|
+
return (
|
|
894
|
+
has_due_date_passed(due_date)
|
|
895
|
+
# If the exam is timed and passed the course end date, it should also be considered passed due, unless
|
|
896
|
+
# it has a due date set. The exam's due date has a higher priority than the course's end date.
|
|
897
|
+
or (
|
|
898
|
+
not due_date
|
|
899
|
+
and not exam['is_proctored']
|
|
900
|
+
and not exam['is_practice_exam']
|
|
901
|
+
and has_end_date_passed(exam['course_id'])
|
|
902
|
+
)
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def _was_review_status_acknowledged(is_status_acknowledged, exam):
|
|
907
|
+
"""
|
|
908
|
+
Return True if review status has been acknowledged and due date has been passed
|
|
909
|
+
"""
|
|
910
|
+
return is_status_acknowledged and is_exam_passed_due(exam)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _create_and_decline_attempt(exam_id, user_id):
|
|
914
|
+
"""
|
|
915
|
+
It will create the exam attempt and change the attempt's status to decline.
|
|
916
|
+
it will auto-decline further exams too
|
|
917
|
+
"""
|
|
918
|
+
|
|
919
|
+
attempt_id = create_exam_attempt(exam_id, user_id)
|
|
920
|
+
update_attempt_status(
|
|
921
|
+
attempt_id,
|
|
922
|
+
ProctoredExamStudentAttemptStatus.declined,
|
|
923
|
+
raise_if_not_found=False
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
# pylint: disable=too-many-positional-arguments
|
|
928
|
+
def _register_proctored_exam_attempt(user_id, exam_id, exam, attempt_code, review_policy, verified_name=None):
|
|
929
|
+
"""
|
|
930
|
+
Call the proctoring backend to register the exam attempt. If there are exceptions
|
|
931
|
+
the external_id returned might be None. If the backend have onboarding status errors,
|
|
932
|
+
it will be returned with force_status
|
|
933
|
+
"""
|
|
934
|
+
scheme = 'https' if getattr(settings, 'HTTPS', 'on') == 'on' else 'http'
|
|
935
|
+
lms_host = f'{scheme}://{settings.SITE_NAME}'
|
|
936
|
+
|
|
937
|
+
obs_user_id = obscured_user_id(user_id, exam['backend'])
|
|
938
|
+
allowed_time_limit_mins = _calculate_allowed_mins(exam, user_id)
|
|
939
|
+
review_policy_exception = ProctoredExamStudentAllowance.get_review_policy_exception(exam_id, user_id)
|
|
940
|
+
|
|
941
|
+
# get the name of the user, if the service is available
|
|
942
|
+
email = None
|
|
943
|
+
external_id = None
|
|
944
|
+
force_status = None
|
|
945
|
+
full_name = verified_name or ''
|
|
946
|
+
profile_name = ''
|
|
947
|
+
|
|
948
|
+
credit_service = get_runtime_service('credit')
|
|
949
|
+
if credit_service:
|
|
950
|
+
credit_state = credit_service.get_credit_state(user_id, exam['course_id'])
|
|
951
|
+
if credit_state:
|
|
952
|
+
profile_name = credit_state['profile_fullname']
|
|
953
|
+
if not verified_name:
|
|
954
|
+
full_name = profile_name
|
|
955
|
+
email = credit_state['student_email']
|
|
956
|
+
|
|
957
|
+
context = {
|
|
958
|
+
'lms_host': lms_host,
|
|
959
|
+
'time_limit_mins': allowed_time_limit_mins,
|
|
960
|
+
'attempt_code': attempt_code,
|
|
961
|
+
'is_sample_attempt': exam['is_practice_exam'],
|
|
962
|
+
'user_id': obs_user_id,
|
|
963
|
+
'full_name': full_name,
|
|
964
|
+
'email': email
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
# see if there is an exam review policy for this exam
|
|
968
|
+
# if so, then pass it into the provider
|
|
969
|
+
if review_policy:
|
|
970
|
+
context.update({
|
|
971
|
+
'review_policy': review_policy.review_policy
|
|
972
|
+
})
|
|
973
|
+
|
|
974
|
+
# see if there is a review policy exception for this *user*
|
|
975
|
+
# exceptions are granted on a individual basis as an
|
|
976
|
+
# allowance
|
|
977
|
+
if review_policy_exception:
|
|
978
|
+
context.update({
|
|
979
|
+
'review_policy_exception': review_policy_exception
|
|
980
|
+
})
|
|
981
|
+
|
|
982
|
+
# now call into the backend provider to register exam attempt
|
|
983
|
+
try:
|
|
984
|
+
external_id = get_backend_provider(exam).register_exam_attempt(
|
|
985
|
+
exam,
|
|
986
|
+
context=context,
|
|
987
|
+
)
|
|
988
|
+
except BackendProviderSentNoAttemptID as ex:
|
|
989
|
+
log.error(
|
|
990
|
+
('Failed to get the attempt ID for user_id=%(user_id)s '
|
|
991
|
+
'for exam_id=%(exam_id)s in course_id=%(course_id)s from the '
|
|
992
|
+
'backend because the backend did not provide the id in API '
|
|
993
|
+
'response, even when the HTTP response status is %(status)s, '
|
|
994
|
+
'Response: %(response)s'),
|
|
995
|
+
{
|
|
996
|
+
'user_id': user_id,
|
|
997
|
+
'exam_id': exam_id,
|
|
998
|
+
'course_id': exam['course_id'],
|
|
999
|
+
'response': str(ex),
|
|
1000
|
+
'status': ex.http_status,
|
|
1001
|
+
}
|
|
1002
|
+
)
|
|
1003
|
+
raise ex
|
|
1004
|
+
except BackendProviderCannotRegisterAttempt as ex:
|
|
1005
|
+
log.error(
|
|
1006
|
+
('Failed to create attempt for user_id=%(user_id)s '
|
|
1007
|
+
'for exam_id=%(exam_id)s in course_id=%(course_id)s '
|
|
1008
|
+
'because backend was unable to register the attempt. '
|
|
1009
|
+
'Status: %(status)s, Response: %(response)s'),
|
|
1010
|
+
{
|
|
1011
|
+
'user_id': user_id,
|
|
1012
|
+
'exam_id': exam_id,
|
|
1013
|
+
'course_id': exam['course_id'],
|
|
1014
|
+
'response': str(ex),
|
|
1015
|
+
'status': ex.http_status,
|
|
1016
|
+
}
|
|
1017
|
+
)
|
|
1018
|
+
raise ex
|
|
1019
|
+
except BackendProviderOnboardingException as ex:
|
|
1020
|
+
force_status = ex.status
|
|
1021
|
+
log.error(
|
|
1022
|
+
('Failed to create attempt for user_id=%(user_id)s '
|
|
1023
|
+
'for exam_id=%(exam_id)s in course_id=%(course_id)s '
|
|
1024
|
+
'because of onboarding failure: %(force_status)s'),
|
|
1025
|
+
{
|
|
1026
|
+
'user_id': user_id,
|
|
1027
|
+
'exam_id': exam_id,
|
|
1028
|
+
'course_id': exam['course_id'],
|
|
1029
|
+
'force_status': force_status,
|
|
1030
|
+
}
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
return external_id, force_status, full_name, profile_name
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _get_verified_name(user_id, name_affirmation_service):
|
|
1037
|
+
"""
|
|
1038
|
+
Get the user's verified name if one exists. This will ignore verified names
|
|
1039
|
+
which have a "denied" status.
|
|
1040
|
+
|
|
1041
|
+
Returns a verified name object (or None)
|
|
1042
|
+
"""
|
|
1043
|
+
verified_name = None
|
|
1044
|
+
|
|
1045
|
+
user = USER_MODEL.objects.get(id=user_id)
|
|
1046
|
+
|
|
1047
|
+
# Only use verified name if it is approved or pending approval
|
|
1048
|
+
verified_name_obj = name_affirmation_service.get_verified_name(
|
|
1049
|
+
user, is_verified=False, statuses_to_exclude=['denied']
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
if verified_name_obj:
|
|
1053
|
+
verified_name = verified_name_obj.verified_name
|
|
1054
|
+
|
|
1055
|
+
return verified_name
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def create_exam_attempt(exam_id, user_id, taking_as_proctored=False):
|
|
1059
|
+
"""
|
|
1060
|
+
Creates an exam attempt for user_id against exam_id. There should only
|
|
1061
|
+
be one exam_attempt per user per exam, with one exception described below.
|
|
1062
|
+
Multiple attempts by user will be archived in a separate table.
|
|
1063
|
+
|
|
1064
|
+
If the attempt enters an error state, it must be marked as ready to resume,
|
|
1065
|
+
and another attempt must be made in order to resume the exam. This is the
|
|
1066
|
+
only case where multiple attempts for the same exam should exist.
|
|
1067
|
+
"""
|
|
1068
|
+
# for now the student is allowed the exam default
|
|
1069
|
+
|
|
1070
|
+
exam = get_exam_by_id(exam_id)
|
|
1071
|
+
|
|
1072
|
+
log.info(
|
|
1073
|
+
('Creating exam attempt for exam_id=%(exam_id)s for user_id=%(user_id)s '
|
|
1074
|
+
'in course_id=%(course_id)s with taking_as_proctored=%(taking_as_proctored)s'),
|
|
1075
|
+
{
|
|
1076
|
+
'exam_id': exam_id,
|
|
1077
|
+
'user_id': user_id,
|
|
1078
|
+
'course_id': exam['course_id'],
|
|
1079
|
+
'taking_as_proctored': taking_as_proctored,
|
|
1080
|
+
}
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1083
|
+
existing_attempt = ProctoredExamStudentAttempt.objects.get_current_exam_attempt(exam_id, user_id)
|
|
1084
|
+
time_remaining_seconds = None
|
|
1085
|
+
# only practice exams and exams with resume states may have multiple attempts
|
|
1086
|
+
if existing_attempt:
|
|
1087
|
+
if is_attempt_in_resume_process(existing_attempt):
|
|
1088
|
+
# save remaining time and mark the most recent attempt as resumed, if it isn't already
|
|
1089
|
+
time_remaining_seconds = existing_attempt.time_remaining_seconds
|
|
1090
|
+
mark_exam_attempt_as_resumed(existing_attempt.id)
|
|
1091
|
+
elif not existing_attempt.is_sample_attempt:
|
|
1092
|
+
err_msg = (
|
|
1093
|
+
f'Cannot create new exam attempt for exam_id={exam_id} and '
|
|
1094
|
+
f"user_id={user_id} in course_id={exam['course_id']} because it already exists!"
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1097
|
+
raise StudentExamAttemptAlreadyExistsException(err_msg)
|
|
1098
|
+
|
|
1099
|
+
attempt_code = str(uuid.uuid4()).upper()
|
|
1100
|
+
|
|
1101
|
+
review_policy = ProctoredExamReviewPolicy.get_review_policy_for_exam(exam_id)
|
|
1102
|
+
external_id = None
|
|
1103
|
+
force_status = None
|
|
1104
|
+
|
|
1105
|
+
if is_exam_passed_due(exam, user=user_id) and taking_as_proctored:
|
|
1106
|
+
err_msg = (
|
|
1107
|
+
f'user_id={user_id} trying to create exam attempt for past due proctored '
|
|
1108
|
+
f"exam_id={exam_id} in course_id={exam['course_id']}. Do not register an exam attempt!"
|
|
1109
|
+
)
|
|
1110
|
+
raise StudentExamAttemptOnPastDueProctoredExam(err_msg)
|
|
1111
|
+
|
|
1112
|
+
name_affirmation_service = get_runtime_service('name_affirmation')
|
|
1113
|
+
full_name = ''
|
|
1114
|
+
profile_name = ''
|
|
1115
|
+
|
|
1116
|
+
if taking_as_proctored:
|
|
1117
|
+
verified_name = None
|
|
1118
|
+
if name_affirmation_service:
|
|
1119
|
+
verified_name = _get_verified_name(user_id, name_affirmation_service)
|
|
1120
|
+
external_id, force_status, full_name, profile_name = _register_proctored_exam_attempt(
|
|
1121
|
+
user_id, exam_id, exam, attempt_code, review_policy, verified_name,
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
attempt = ProctoredExamStudentAttempt.create_exam_attempt(
|
|
1125
|
+
exam_id,
|
|
1126
|
+
user_id,
|
|
1127
|
+
attempt_code,
|
|
1128
|
+
taking_as_proctored,
|
|
1129
|
+
exam['is_practice_exam'],
|
|
1130
|
+
external_id,
|
|
1131
|
+
review_policy_id=review_policy.id if review_policy else None,
|
|
1132
|
+
status=force_status,
|
|
1133
|
+
time_remaining_seconds=time_remaining_seconds,
|
|
1134
|
+
)
|
|
1135
|
+
|
|
1136
|
+
exam = get_exam_by_id(exam_id)
|
|
1137
|
+
backend = get_backend_provider(exam)
|
|
1138
|
+
supports_onboarding = backend.supports_onboarding if backend else None
|
|
1139
|
+
|
|
1140
|
+
# Emit signal for attempt creation
|
|
1141
|
+
exam_attempt_status_signal.send(
|
|
1142
|
+
sender='edx_proctoring',
|
|
1143
|
+
attempt_id=attempt.id,
|
|
1144
|
+
user_id=user_id,
|
|
1145
|
+
status=attempt.status,
|
|
1146
|
+
full_name=full_name,
|
|
1147
|
+
profile_name=profile_name,
|
|
1148
|
+
is_practice_exam=exam['is_practice_exam'],
|
|
1149
|
+
is_proctored=taking_as_proctored,
|
|
1150
|
+
backend_supports_onboarding=supports_onboarding
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
# Emit event when exam attempt created
|
|
1154
|
+
emit_event(exam, attempt.status, attempt=_get_exam_attempt(attempt))
|
|
1155
|
+
|
|
1156
|
+
log.info(
|
|
1157
|
+
('Created exam attempt_id=%(attempt_id)s for exam_id=%(exam_id)s for '
|
|
1158
|
+
'user_id=%(user_id)s with taking as proctored=%(taking_as_proctored)s. '
|
|
1159
|
+
'attempt_code=%(attempt_code)s was generated which has an '
|
|
1160
|
+
'external_id=%(external_id)s'),
|
|
1161
|
+
{
|
|
1162
|
+
'attempt_id': attempt.id,
|
|
1163
|
+
'exam_id': exam_id,
|
|
1164
|
+
'user_id': user_id,
|
|
1165
|
+
'taking_as_proctored': taking_as_proctored,
|
|
1166
|
+
'attempt_code': attempt_code,
|
|
1167
|
+
'external_id': external_id,
|
|
1168
|
+
}
|
|
1169
|
+
)
|
|
1170
|
+
|
|
1171
|
+
return attempt.id
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def start_exam_attempt(exam_id, user_id):
|
|
1175
|
+
"""
|
|
1176
|
+
Signals the beginning of an exam attempt for a given
|
|
1177
|
+
exam_id. If one already exists, then an exception should be thrown.
|
|
1178
|
+
|
|
1179
|
+
Returns: exam_attempt_id (PK)
|
|
1180
|
+
"""
|
|
1181
|
+
|
|
1182
|
+
existing_attempt = ProctoredExamStudentAttempt.objects.get_current_exam_attempt(exam_id, user_id)
|
|
1183
|
+
|
|
1184
|
+
if not existing_attempt:
|
|
1185
|
+
err_msg = (
|
|
1186
|
+
f'Cannot start exam attempt for exam_id={exam_id} '
|
|
1187
|
+
f'and user_id={user_id} because it does not exist!'
|
|
1188
|
+
)
|
|
1189
|
+
|
|
1190
|
+
raise StudentExamAttemptDoesNotExistsException(err_msg)
|
|
1191
|
+
|
|
1192
|
+
return _start_exam_attempt(existing_attempt)
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def start_exam_attempt_by_code(attempt_code):
|
|
1196
|
+
"""
|
|
1197
|
+
Signals the beginning of an exam attempt when we only have
|
|
1198
|
+
an attempt code
|
|
1199
|
+
"""
|
|
1200
|
+
|
|
1201
|
+
existing_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_code(attempt_code)
|
|
1202
|
+
|
|
1203
|
+
if not existing_attempt:
|
|
1204
|
+
err_msg = (
|
|
1205
|
+
f'Cannot start exam attempt for attempt_code={attempt_code} '
|
|
1206
|
+
'because it does not exist!'
|
|
1207
|
+
)
|
|
1208
|
+
|
|
1209
|
+
raise StudentExamAttemptDoesNotExistsException(err_msg)
|
|
1210
|
+
|
|
1211
|
+
return _start_exam_attempt(existing_attempt)
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _start_exam_attempt(existing_attempt):
|
|
1215
|
+
"""
|
|
1216
|
+
Helper method
|
|
1217
|
+
"""
|
|
1218
|
+
|
|
1219
|
+
if existing_attempt.started_at and existing_attempt.status == ProctoredExamStudentAttemptStatus.started:
|
|
1220
|
+
# cannot restart an attempt
|
|
1221
|
+
err_msg = (
|
|
1222
|
+
f'Cannot start exam attempt for exam_id={existing_attempt.proctored_exam.id} '
|
|
1223
|
+
f'and user_id={existing_attempt.user_id} because it has already started!'
|
|
1224
|
+
)
|
|
1225
|
+
|
|
1226
|
+
raise StudentExamAttemptedAlreadyStarted(err_msg)
|
|
1227
|
+
|
|
1228
|
+
update_attempt_status(
|
|
1229
|
+
existing_attempt.id,
|
|
1230
|
+
ProctoredExamStudentAttemptStatus.started
|
|
1231
|
+
)
|
|
1232
|
+
|
|
1233
|
+
return existing_attempt.id
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def stop_exam_attempt(attempt_id):
|
|
1237
|
+
"""
|
|
1238
|
+
Marks the exam attempt as completed (sets the completed_at field and updates the record)
|
|
1239
|
+
"""
|
|
1240
|
+
return update_attempt_status(attempt_id, ProctoredExamStudentAttemptStatus.ready_to_submit)
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def mark_exam_attempt_timeout(attempt_id):
|
|
1244
|
+
"""
|
|
1245
|
+
Marks the exam attempt as timed_out
|
|
1246
|
+
"""
|
|
1247
|
+
return update_attempt_status(attempt_id, ProctoredExamStudentAttemptStatus.timed_out)
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def mark_exam_attempt_as_ready(attempt_id):
|
|
1251
|
+
"""
|
|
1252
|
+
Marks the exam attempt as ready to start
|
|
1253
|
+
"""
|
|
1254
|
+
return update_attempt_status(attempt_id, ProctoredExamStudentAttemptStatus.ready_to_start)
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def mark_exam_attempt_as_resumed(attempt_id):
|
|
1258
|
+
"""
|
|
1259
|
+
Marks the current exam attempt as resumed
|
|
1260
|
+
"""
|
|
1261
|
+
exam_attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id(attempt_id)
|
|
1262
|
+
ready_to_resume = is_attempt_in_resume_process(exam_attempt_obj)
|
|
1263
|
+
if ready_to_resume:
|
|
1264
|
+
exam_attempt_obj.resumed = True
|
|
1265
|
+
exam_attempt_obj.is_resumable = False
|
|
1266
|
+
exam_attempt_obj.save()
|
|
1267
|
+
return exam_attempt_obj.id
|
|
1268
|
+
|
|
1269
|
+
raise ProctoredExamIllegalResumeUpdate(
|
|
1270
|
+
f'Attempted to mark attempt_id={attempt_id} as resumed, but '
|
|
1271
|
+
f'attempt was never marked as ready_to_resume. This is not allowed.'
|
|
1272
|
+
)
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def mark_exam_attempt_as_ready_to_resume(attempt_id):
|
|
1276
|
+
"""
|
|
1277
|
+
Marks the current exam attempt as ready to resume
|
|
1278
|
+
"""
|
|
1279
|
+
exam_attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id(attempt_id)
|
|
1280
|
+
is_resumable = exam_attempt_obj.is_resumable
|
|
1281
|
+
if is_resumable:
|
|
1282
|
+
exam_attempt_obj.ready_to_resume = True
|
|
1283
|
+
exam_attempt_obj.is_resumable = False
|
|
1284
|
+
exam_attempt_obj.save()
|
|
1285
|
+
return exam_attempt_obj.id
|
|
1286
|
+
|
|
1287
|
+
raise ProctoredExamIllegalResumeUpdate(
|
|
1288
|
+
f'Attempted to mark attempt_id={attempt_id} as ready_to_resume, but '
|
|
1289
|
+
f'attempt is not resumable. This is not allowed.'
|
|
1290
|
+
)
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
def is_attempt_ready_to_resume(attempt):
|
|
1294
|
+
"""
|
|
1295
|
+
Determine if an exam attempt is ready to resume
|
|
1296
|
+
|
|
1297
|
+
Arguments:
|
|
1298
|
+
attempt: serialized attempt obj
|
|
1299
|
+
"""
|
|
1300
|
+
|
|
1301
|
+
# if the attempt has been marked as ready to resume, check to see that it has not been resumed yet
|
|
1302
|
+
return attempt['ready_to_resume'] and not attempt['resumed']
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def is_attempt_in_resume_process(attempt_obj):
|
|
1306
|
+
"""
|
|
1307
|
+
Determine if an exam attempt is in the resume process. This should check if either of the resume
|
|
1308
|
+
related fields are set to true.
|
|
1309
|
+
|
|
1310
|
+
Arguments:
|
|
1311
|
+
attempt_obj: attempt object
|
|
1312
|
+
"""
|
|
1313
|
+
return attempt_obj.ready_to_resume or attempt_obj.resumed
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
def is_state_transition_legal(from_status, to_status):
|
|
1317
|
+
"""
|
|
1318
|
+
Determine and return as a boolean whether a proctored exam attempt state transition
|
|
1319
|
+
from from_status to to_status is an allowed state transition.
|
|
1320
|
+
|
|
1321
|
+
Arguments:
|
|
1322
|
+
from_status: original status of a proctored exam attempt
|
|
1323
|
+
to_status: future status of a proctored exam attempt
|
|
1324
|
+
"""
|
|
1325
|
+
in_completed_status = ProctoredExamStudentAttemptStatus.is_completed_status(from_status)
|
|
1326
|
+
to_incompleted_status = ProctoredExamStudentAttemptStatus.is_incomplete_status(to_status)
|
|
1327
|
+
|
|
1328
|
+
# don't allow state transitions from a completed state to an incomplete state
|
|
1329
|
+
# if a re-attempt is desired then the current attempt must be deleted
|
|
1330
|
+
if in_completed_status and to_incompleted_status:
|
|
1331
|
+
return False
|
|
1332
|
+
return True
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
def can_update_credit_grades_and_email(attempts, to_status, exam):
|
|
1336
|
+
"""
|
|
1337
|
+
Determine and return as a boolean whether an attempt should trigger an update to credit and grades
|
|
1338
|
+
|
|
1339
|
+
Arguments:
|
|
1340
|
+
attempts: a list of all currently active attempts for a given user_id and exam_id
|
|
1341
|
+
to_status: future status of a proctored exam attempt
|
|
1342
|
+
exam: dict representation of exam object
|
|
1343
|
+
"""
|
|
1344
|
+
# if the exam is a practice exam, always allow updates
|
|
1345
|
+
if exam['is_practice_exam']:
|
|
1346
|
+
return True
|
|
1347
|
+
|
|
1348
|
+
statuses = [attempt['status'] for attempt in attempts]
|
|
1349
|
+
if len(statuses) == 1:
|
|
1350
|
+
# if there is only one attempt for a user in an exam, it can be responsible for updates to credits and grades
|
|
1351
|
+
return True
|
|
1352
|
+
if to_status in [ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.error]:
|
|
1353
|
+
# can only decline/error the most recent attempt, so return true
|
|
1354
|
+
# these should not send emails but will update credits and grades
|
|
1355
|
+
return True
|
|
1356
|
+
if to_status == ProctoredExamStudentAttemptStatus.submitted:
|
|
1357
|
+
# we only want to update a submitted status if there are no rejected past attempts
|
|
1358
|
+
for status in statuses:
|
|
1359
|
+
if status == ProctoredExamStudentAttemptStatus.rejected:
|
|
1360
|
+
return False
|
|
1361
|
+
return True
|
|
1362
|
+
if to_status == ProctoredExamStudentAttemptStatus.verified:
|
|
1363
|
+
# if we are updating an attempt to verified, we can only return true if all other attempts are verified
|
|
1364
|
+
for status in statuses:
|
|
1365
|
+
if status != ProctoredExamStudentAttemptStatus.verified:
|
|
1366
|
+
return False
|
|
1367
|
+
return True
|
|
1368
|
+
if to_status == ProctoredExamStudentAttemptStatus.rejected:
|
|
1369
|
+
if (
|
|
1370
|
+
statuses.count(ProctoredExamStudentAttemptStatus.rejected) > 1 or
|
|
1371
|
+
ProctoredExamStudentAttemptStatus.declined in statuses
|
|
1372
|
+
):
|
|
1373
|
+
# if there is more than one rejection, do not update grades/certificate/email again
|
|
1374
|
+
# or if there is a declined status, do not update grades/certificate/email again
|
|
1375
|
+
return False
|
|
1376
|
+
return True
|
|
1377
|
+
|
|
1378
|
+
return False
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def _is_attempt_resumable(attempt_obj, to_status):
|
|
1382
|
+
"""
|
|
1383
|
+
Based on the attempt object and the status it's transitioning to,
|
|
1384
|
+
return whether the attempt should be resumable, or not
|
|
1385
|
+
"""
|
|
1386
|
+
if to_status == ProctoredExamStudentAttemptStatus.submitted or is_attempt_in_resume_process(attempt_obj):
|
|
1387
|
+
# Make sure we have resumable to be false in conditions where the
|
|
1388
|
+
# attempt is either in the resume process, or it's successfully submitted
|
|
1389
|
+
return False
|
|
1390
|
+
if to_status == ProctoredExamStudentAttemptStatus.error:
|
|
1391
|
+
# Only when the transition to status is "Error", the attempt is resumable
|
|
1392
|
+
return True
|
|
1393
|
+
|
|
1394
|
+
# Otherwise, maintain resumability on the attempt
|
|
1395
|
+
return attempt_obj.is_resumable
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
# pylint: disable=inconsistent-return-statements,too-many-positional-arguments
|
|
1399
|
+
def update_attempt_status(attempt_id, to_status,
|
|
1400
|
+
raise_if_not_found=True, cascade_effects=True, timeout_timestamp=None,
|
|
1401
|
+
update_attributable_to=None):
|
|
1402
|
+
"""
|
|
1403
|
+
Internal helper to handle state transitions of attempt status
|
|
1404
|
+
"""
|
|
1405
|
+
exam_attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id(attempt_id)
|
|
1406
|
+
|
|
1407
|
+
if exam_attempt_obj is None:
|
|
1408
|
+
if raise_if_not_found:
|
|
1409
|
+
raise StudentExamAttemptDoesNotExistsException(
|
|
1410
|
+
f'Tried to look up exam by attempt_id={attempt_id}, '
|
|
1411
|
+
'but this attempt does not exist.'
|
|
1412
|
+
)
|
|
1413
|
+
return
|
|
1414
|
+
|
|
1415
|
+
exam_id = exam_attempt_obj.proctored_exam.id
|
|
1416
|
+
user_id = exam_attempt_obj.user.id
|
|
1417
|
+
|
|
1418
|
+
from_status = exam_attempt_obj.status
|
|
1419
|
+
|
|
1420
|
+
log.info(
|
|
1421
|
+
('Updating attempt status for exam_id=%(exam_id)s '
|
|
1422
|
+
'for user_id=%(user_id)s from status "%(from_status)s" to "%(to_status)s"'),
|
|
1423
|
+
{
|
|
1424
|
+
'exam_id': exam_id,
|
|
1425
|
+
'user_id': user_id,
|
|
1426
|
+
'from_status': from_status,
|
|
1427
|
+
'to_status': to_status,
|
|
1428
|
+
}
|
|
1429
|
+
)
|
|
1430
|
+
# In some configuration we may treat timeouts the same
|
|
1431
|
+
# as the user saying he/she wishes to submit the exam
|
|
1432
|
+
allow_timeout_state = settings.PROCTORING_SETTINGS.get('ALLOW_TIMED_OUT_STATE', False)
|
|
1433
|
+
treat_timeout_as_submitted = to_status == ProctoredExamStudentAttemptStatus.timed_out and not allow_timeout_state
|
|
1434
|
+
|
|
1435
|
+
user_trying_to_reattempt = is_reattempting_exam(from_status, to_status)
|
|
1436
|
+
if treat_timeout_as_submitted or user_trying_to_reattempt:
|
|
1437
|
+
detail = 'the attempt was timed out' if treat_timeout_as_submitted else 'the user reattempted the exam'
|
|
1438
|
+
log.info(
|
|
1439
|
+
('Attempt status for exam_id=%(exam_id)s for user_id=%(user_id)s will not be updated to '
|
|
1440
|
+
'"%(to_status)s" because %(submitted_message)s. Instead the attempt '
|
|
1441
|
+
'status will be updated to "submitted"'),
|
|
1442
|
+
{
|
|
1443
|
+
'exam_id': exam_id,
|
|
1444
|
+
'user_id': user_id,
|
|
1445
|
+
'to_status': to_status,
|
|
1446
|
+
'submitted_message': detail,
|
|
1447
|
+
}
|
|
1448
|
+
)
|
|
1449
|
+
to_status = ProctoredExamStudentAttemptStatus.submitted
|
|
1450
|
+
|
|
1451
|
+
exam = get_exam_by_id(exam_id)
|
|
1452
|
+
backend = get_backend_provider(exam)
|
|
1453
|
+
|
|
1454
|
+
if not is_state_transition_legal(from_status, to_status):
|
|
1455
|
+
illegal_status_transition_msg = (
|
|
1456
|
+
f'A status transition from "{from_status}" to "{to_status}" was attempted '
|
|
1457
|
+
f'on exam_id={exam_id} for user_id={user_id}. This is not '
|
|
1458
|
+
f"allowed! (course_id={exam['course_id']})"
|
|
1459
|
+
)
|
|
1460
|
+
|
|
1461
|
+
raise ProctoredExamIllegalStatusTransition(illegal_status_transition_msg)
|
|
1462
|
+
|
|
1463
|
+
# OK, state transition is fine, we can proceed
|
|
1464
|
+
exam_attempt_obj.status = to_status
|
|
1465
|
+
|
|
1466
|
+
# if we have transitioned to started and haven't set our
|
|
1467
|
+
# started_at timestamp and calculate allowed minutes, do so now
|
|
1468
|
+
add_start_time = (
|
|
1469
|
+
to_status == ProctoredExamStudentAttemptStatus.started and
|
|
1470
|
+
not exam_attempt_obj.started_at
|
|
1471
|
+
)
|
|
1472
|
+
if add_start_time:
|
|
1473
|
+
exam_attempt_obj.started_at = datetime.now(pytz.UTC)
|
|
1474
|
+
exam_attempt_obj.allowed_time_limit_mins = _calculate_allowed_mins(exam, exam_attempt_obj.user_id)
|
|
1475
|
+
|
|
1476
|
+
elif treat_timeout_as_submitted:
|
|
1477
|
+
exam_attempt_obj.completed_at = timeout_timestamp or datetime.now(pytz.UTC)
|
|
1478
|
+
elif to_status == ProctoredExamStudentAttemptStatus.submitted:
|
|
1479
|
+
# likewise, when we transition to submitted mark
|
|
1480
|
+
# when the exam has been completed
|
|
1481
|
+
exam_attempt_obj.completed_at = datetime.now(pytz.UTC)
|
|
1482
|
+
|
|
1483
|
+
# Update the is_resumable flag of the attempt based on the status transition
|
|
1484
|
+
exam_attempt_obj.is_resumable = _is_attempt_resumable(exam_attempt_obj, to_status)
|
|
1485
|
+
|
|
1486
|
+
exam_attempt_obj.save()
|
|
1487
|
+
|
|
1488
|
+
all_attempts = get_user_attempts_by_exam_id(user_id, exam_id)
|
|
1489
|
+
|
|
1490
|
+
if can_update_credit_grades_and_email(all_attempts, to_status, exam):
|
|
1491
|
+
|
|
1492
|
+
# see if the status transition this changes credit requirement status
|
|
1493
|
+
if ProctoredExamStudentAttemptStatus.needs_credit_status_update(to_status):
|
|
1494
|
+
|
|
1495
|
+
# trigger credit workflow, as needed
|
|
1496
|
+
credit_service = get_runtime_service('credit')
|
|
1497
|
+
|
|
1498
|
+
if to_status == ProctoredExamStudentAttemptStatus.verified:
|
|
1499
|
+
credit_requirement_status = 'satisfied'
|
|
1500
|
+
elif to_status == ProctoredExamStudentAttemptStatus.submitted:
|
|
1501
|
+
credit_requirement_status = 'submitted'
|
|
1502
|
+
elif to_status == ProctoredExamStudentAttemptStatus.declined:
|
|
1503
|
+
credit_requirement_status = 'declined'
|
|
1504
|
+
else:
|
|
1505
|
+
credit_requirement_status = 'failed'
|
|
1506
|
+
|
|
1507
|
+
log.info(
|
|
1508
|
+
('Calling set_credit_requirement_status for '
|
|
1509
|
+
'user_id=%(user_id)s on course_id=%(course_id)s for '
|
|
1510
|
+
'content_id=%(content_id)s. status=%(status)s'),
|
|
1511
|
+
{
|
|
1512
|
+
'user_id': exam_attempt_obj.user_id,
|
|
1513
|
+
'course_id': exam['course_id'],
|
|
1514
|
+
'content_id': exam_attempt_obj.proctored_exam.content_id,
|
|
1515
|
+
'status': credit_requirement_status,
|
|
1516
|
+
}
|
|
1517
|
+
)
|
|
1518
|
+
|
|
1519
|
+
credit_service.set_credit_requirement_status(
|
|
1520
|
+
user_id=exam_attempt_obj.user_id,
|
|
1521
|
+
course_key_or_id=exam['course_id'],
|
|
1522
|
+
req_namespace='proctored_exam',
|
|
1523
|
+
req_name=exam_attempt_obj.proctored_exam.content_id,
|
|
1524
|
+
status=credit_requirement_status
|
|
1525
|
+
)
|
|
1526
|
+
|
|
1527
|
+
if cascade_effects and ProctoredExamStudentAttemptStatus.is_a_cascadable_failure(to_status):
|
|
1528
|
+
# if user declines attempt, make sure we clear out the external_id and
|
|
1529
|
+
# taking_as_proctored fields
|
|
1530
|
+
exam_attempt_obj.taking_as_proctored = False
|
|
1531
|
+
exam_attempt_obj.external_id = None
|
|
1532
|
+
exam_attempt_obj.save()
|
|
1533
|
+
|
|
1534
|
+
# some state transitions (namely to a rejected or declined status)
|
|
1535
|
+
# will mark other exams as declined because once we fail or decline
|
|
1536
|
+
# one exam all other (un-completed) proctored exams will be likewise
|
|
1537
|
+
# updated to reflect a declined status
|
|
1538
|
+
# get all other unattempted exams and mark also as declined
|
|
1539
|
+
all_other_exams = ProctoredExam.get_all_exams_for_course(
|
|
1540
|
+
exam_attempt_obj.proctored_exam.course_id,
|
|
1541
|
+
active_only=True
|
|
1542
|
+
)
|
|
1543
|
+
|
|
1544
|
+
# we just want other exams which are proctored and are not practice
|
|
1545
|
+
other_exams = [
|
|
1546
|
+
other_exam
|
|
1547
|
+
for other_exam in all_other_exams
|
|
1548
|
+
if (
|
|
1549
|
+
other_exam.content_id != exam_attempt_obj.proctored_exam.content_id and
|
|
1550
|
+
other_exam.is_proctored and not other_exam.is_practice_exam
|
|
1551
|
+
)
|
|
1552
|
+
]
|
|
1553
|
+
|
|
1554
|
+
for other_exam in other_exams:
|
|
1555
|
+
# see if there was an attempt on those other exams already
|
|
1556
|
+
attempt = get_current_exam_attempt(other_exam.id, user_id)
|
|
1557
|
+
if attempt and ProctoredExamStudentAttemptStatus.is_completed_status(attempt['status']):
|
|
1558
|
+
# don't touch any completed statuses
|
|
1559
|
+
# we won't revoke those
|
|
1560
|
+
continue
|
|
1561
|
+
|
|
1562
|
+
if not attempt:
|
|
1563
|
+
current_attempt_id = create_exam_attempt(other_exam.id, user_id, taking_as_proctored=False)
|
|
1564
|
+
else:
|
|
1565
|
+
current_attempt_id = attempt['id']
|
|
1566
|
+
|
|
1567
|
+
# update any new or existing status to declined
|
|
1568
|
+
update_attempt_status(
|
|
1569
|
+
current_attempt_id,
|
|
1570
|
+
ProctoredExamStudentAttemptStatus.declined,
|
|
1571
|
+
cascade_effects=False
|
|
1572
|
+
)
|
|
1573
|
+
|
|
1574
|
+
if ProctoredExamStudentAttemptStatus.needs_grade_override(to_status):
|
|
1575
|
+
grades_service = get_runtime_service('grades')
|
|
1576
|
+
|
|
1577
|
+
if grades_service.should_override_grade_on_rejected_exam(exam['course_id']):
|
|
1578
|
+
log.info(
|
|
1579
|
+
('Overriding exam subsection grade for '
|
|
1580
|
+
'user_id=%(user_id)s on course_id=%(course_id)s for '
|
|
1581
|
+
'content_id=%(content_id)s. Override '
|
|
1582
|
+
'earned_all=%(earned_all)s, '
|
|
1583
|
+
'earned_graded=%(earned_graded)s.'),
|
|
1584
|
+
{
|
|
1585
|
+
'user_id': exam_attempt_obj.user_id,
|
|
1586
|
+
'course_id': exam['course_id'],
|
|
1587
|
+
'content_id': exam_attempt_obj.proctored_exam.content_id,
|
|
1588
|
+
'earned_all': REJECTED_GRADE_OVERRIDE_EARNED,
|
|
1589
|
+
'earned_graded': REJECTED_GRADE_OVERRIDE_EARNED,
|
|
1590
|
+
}
|
|
1591
|
+
)
|
|
1592
|
+
|
|
1593
|
+
grades_service.override_subsection_grade(
|
|
1594
|
+
user_id=exam_attempt_obj.user_id,
|
|
1595
|
+
course_key_or_id=exam['course_id'],
|
|
1596
|
+
usage_key_or_id=exam_attempt_obj.proctored_exam.content_id,
|
|
1597
|
+
earned_all=REJECTED_GRADE_OVERRIDE_EARNED,
|
|
1598
|
+
earned_graded=REJECTED_GRADE_OVERRIDE_EARNED,
|
|
1599
|
+
overrider=update_attributable_to,
|
|
1600
|
+
comment=(f'Failed {backend.verbose_name} proctoring'
|
|
1601
|
+
if backend
|
|
1602
|
+
else 'Failed Proctoring')
|
|
1603
|
+
)
|
|
1604
|
+
|
|
1605
|
+
certificates_service = get_runtime_service('certificates')
|
|
1606
|
+
|
|
1607
|
+
log.info(
|
|
1608
|
+
('Invalidating certificate for user_id=%(user_id)s in course_id=%(course_id)s whose '
|
|
1609
|
+
'grade dropped below passing threshold due to suspicious proctored exam'),
|
|
1610
|
+
{
|
|
1611
|
+
'user_id': exam_attempt_obj.user_id,
|
|
1612
|
+
'course_id': exam['course_id'],
|
|
1613
|
+
}
|
|
1614
|
+
)
|
|
1615
|
+
|
|
1616
|
+
# invalidate certificate after overriding subsection grade
|
|
1617
|
+
certificates_service.invalidate_certificate(
|
|
1618
|
+
user_id=exam_attempt_obj.user_id,
|
|
1619
|
+
course_key_or_id=exam['course_id']
|
|
1620
|
+
)
|
|
1621
|
+
|
|
1622
|
+
if (to_status == ProctoredExamStudentAttemptStatus.verified and
|
|
1623
|
+
ProctoredExamStudentAttemptStatus.needs_grade_override(from_status)):
|
|
1624
|
+
grades_service = get_runtime_service('grades')
|
|
1625
|
+
|
|
1626
|
+
if grades_service.should_override_grade_on_rejected_exam(exam['course_id']):
|
|
1627
|
+
log.info(
|
|
1628
|
+
('Deleting override of exam subsection grade for '
|
|
1629
|
+
'user_id=%(user_id)s on course_id=%(course_id)s for '
|
|
1630
|
+
'content_id=%(content_id)s. Override '
|
|
1631
|
+
'earned_all=%(earned_all)s, '
|
|
1632
|
+
'earned_graded=%(earned_graded)s.'),
|
|
1633
|
+
{
|
|
1634
|
+
'user_id': exam_attempt_obj.user_id,
|
|
1635
|
+
'course_id': exam['course_id'],
|
|
1636
|
+
'content_id': exam_attempt_obj.proctored_exam.content_id,
|
|
1637
|
+
'earned_all': REJECTED_GRADE_OVERRIDE_EARNED,
|
|
1638
|
+
'earned_graded': REJECTED_GRADE_OVERRIDE_EARNED,
|
|
1639
|
+
}
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
grades_service.undo_override_subsection_grade(
|
|
1643
|
+
user_id=exam_attempt_obj.user_id,
|
|
1644
|
+
course_key_or_id=exam['course_id'],
|
|
1645
|
+
usage_key_or_id=exam_attempt_obj.proctored_exam.content_id,
|
|
1646
|
+
)
|
|
1647
|
+
|
|
1648
|
+
# call service to get course name.
|
|
1649
|
+
credit_service = get_runtime_service('credit')
|
|
1650
|
+
credit_state = credit_service.get_credit_state(
|
|
1651
|
+
exam_attempt_obj.user_id,
|
|
1652
|
+
exam_attempt_obj.proctored_exam.course_id,
|
|
1653
|
+
return_course_info=True
|
|
1654
|
+
)
|
|
1655
|
+
|
|
1656
|
+
default_name = _('your course')
|
|
1657
|
+
if credit_state:
|
|
1658
|
+
course_name = credit_state.get('course_name', default_name)
|
|
1659
|
+
else:
|
|
1660
|
+
course_name = default_name
|
|
1661
|
+
log.info(
|
|
1662
|
+
('While updating attempt status, could not find credit_state for '
|
|
1663
|
+
'user_id=%(user_id)s in course_id=%(course_id)s'),
|
|
1664
|
+
{
|
|
1665
|
+
'user_id': exam_attempt_obj.user_id,
|
|
1666
|
+
'course_id': exam_attempt_obj.proctored_exam.course_id,
|
|
1667
|
+
}
|
|
1668
|
+
)
|
|
1669
|
+
email = create_proctoring_attempt_status_email(
|
|
1670
|
+
exam_attempt_obj,
|
|
1671
|
+
course_name,
|
|
1672
|
+
exam['course_id']
|
|
1673
|
+
)
|
|
1674
|
+
if email:
|
|
1675
|
+
try:
|
|
1676
|
+
email.send()
|
|
1677
|
+
# pylint: disable=broad-exception-caught
|
|
1678
|
+
except Exception as err:
|
|
1679
|
+
log.exception(
|
|
1680
|
+
('Exception occurred while trying to send proctoring attempt '
|
|
1681
|
+
'status email for user_id=%(user_id)s in course_id=%(course_id)s -- %(err)s'),
|
|
1682
|
+
{
|
|
1683
|
+
'user_id': exam_attempt_obj.user_id,
|
|
1684
|
+
'course_id': exam_attempt_obj.proctored_exam.course_id,
|
|
1685
|
+
'err': err,
|
|
1686
|
+
}
|
|
1687
|
+
)
|
|
1688
|
+
|
|
1689
|
+
# emit an anlytics event based on the state transition
|
|
1690
|
+
# we re-read this from the database in case fields got updated
|
|
1691
|
+
# via workflow
|
|
1692
|
+
attempt = get_exam_attempt_by_id(attempt_id)
|
|
1693
|
+
|
|
1694
|
+
# call back to the backend to register the end of the exam, if necessary
|
|
1695
|
+
if backend:
|
|
1696
|
+
# When onboarding exams change state to a completed status,
|
|
1697
|
+
# look up any exams in the onboarding error states, and delete them.
|
|
1698
|
+
# This will allow learners to return to the proctored exam and continue
|
|
1699
|
+
# through the workflow.
|
|
1700
|
+
if exam['is_practice_exam'] and backend.supports_onboarding and \
|
|
1701
|
+
ProctoredExamStudentAttemptStatus.is_completed_status(to_status):
|
|
1702
|
+
# find and delete any pending attempts
|
|
1703
|
+
ProctoredExamStudentAttempt.objects.clear_onboarding_errors(user_id)
|
|
1704
|
+
|
|
1705
|
+
# only proctored/practice exams have a backend
|
|
1706
|
+
# timed exams have no backend
|
|
1707
|
+
backend_method = None
|
|
1708
|
+
|
|
1709
|
+
# add_start_time will only have a value of true if this is the first time an
|
|
1710
|
+
# attempt is transitioning to started. We only want to notify the backend
|
|
1711
|
+
# for the first time an attempt is transitioned to a started status (MST-862).
|
|
1712
|
+
if to_status == ProctoredExamStudentAttemptStatus.started and add_start_time:
|
|
1713
|
+
backend_method = backend.start_exam_attempt
|
|
1714
|
+
elif to_status == ProctoredExamStudentAttemptStatus.submitted:
|
|
1715
|
+
backend_method = backend.stop_exam_attempt
|
|
1716
|
+
elif to_status == ProctoredExamStudentAttemptStatus.error:
|
|
1717
|
+
backend_method = backend.mark_erroneous_exam_attempt
|
|
1718
|
+
if backend_method:
|
|
1719
|
+
backend_method(exam['external_id'], attempt['external_id'])
|
|
1720
|
+
# we use the 'status' field as the name of the event 'verb'
|
|
1721
|
+
emit_event(exam, attempt['status'], attempt=attempt)
|
|
1722
|
+
|
|
1723
|
+
exam_attempt_status_signal.send(
|
|
1724
|
+
sender='edx_proctoring',
|
|
1725
|
+
attempt_id=attempt['id'],
|
|
1726
|
+
user_id=user_id,
|
|
1727
|
+
status=attempt['status'],
|
|
1728
|
+
full_name=None,
|
|
1729
|
+
profile_name=None,
|
|
1730
|
+
is_practice_exam=exam['is_practice_exam'],
|
|
1731
|
+
is_proctored=attempt['taking_as_proctored'],
|
|
1732
|
+
backend_supports_onboarding=backend.supports_onboarding if backend else False
|
|
1733
|
+
)
|
|
1734
|
+
|
|
1735
|
+
return attempt['id']
|
|
1736
|
+
|
|
1737
|
+
|
|
1738
|
+
def create_proctoring_attempt_status_email(exam_attempt_obj, course_name, course_id):
|
|
1739
|
+
"""
|
|
1740
|
+
Creates an email about change in proctoring attempt status.
|
|
1741
|
+
"""
|
|
1742
|
+
# Don't send an email unless this is a non-practice proctored exam
|
|
1743
|
+
if not exam_attempt_obj.taking_as_proctored or exam_attempt_obj.is_sample_attempt:
|
|
1744
|
+
return None
|
|
1745
|
+
|
|
1746
|
+
email_subject = (
|
|
1747
|
+
_('Proctoring Results For {course_name} {exam_name}').format(
|
|
1748
|
+
course_name=course_name,
|
|
1749
|
+
exam_name=exam_attempt_obj.proctored_exam.exam_name
|
|
1750
|
+
)
|
|
1751
|
+
)
|
|
1752
|
+
status = exam_attempt_obj.status
|
|
1753
|
+
if status == ProctoredExamStudentAttemptStatus.submitted:
|
|
1754
|
+
template_name = 'proctoring_attempt_submitted_email.html'
|
|
1755
|
+
email_subject = 'Proctoring attempt submitted'
|
|
1756
|
+
elif status == ProctoredExamStudentAttemptStatus.verified:
|
|
1757
|
+
template_name = 'proctoring_attempt_satisfactory_email.html'
|
|
1758
|
+
email_subject = 'Proctoring attempt verified'
|
|
1759
|
+
elif status == ProctoredExamStudentAttemptStatus.rejected:
|
|
1760
|
+
template_name = 'proctoring_attempt_unsatisfactory_email.html'
|
|
1761
|
+
email_subject = 'Proctoring attempt rejected'
|
|
1762
|
+
else:
|
|
1763
|
+
# Don't send an email for any other attempt status codes
|
|
1764
|
+
return None
|
|
1765
|
+
|
|
1766
|
+
backend = exam_attempt_obj.proctored_exam.backend
|
|
1767
|
+
email_template = loader.select_template(_get_email_template_paths(template_name, backend))
|
|
1768
|
+
course_home_url = get_course_home_url(exam_attempt_obj.proctored_exam.course_id)
|
|
1769
|
+
|
|
1770
|
+
exam_name = exam_attempt_obj.proctored_exam.exam_name
|
|
1771
|
+
|
|
1772
|
+
scheme = 'https' if getattr(settings, 'HTTPS', 'on') == 'on' else 'http'
|
|
1773
|
+
default_contact_url = f'{scheme}://{constants.SITE_NAME}/support/contact_us'
|
|
1774
|
+
|
|
1775
|
+
# If the course has a proctoring escalation email set, use that rather than edX Support.
|
|
1776
|
+
# This will be set if 'requires_escalation_email' is 'True' in proctoring backend.
|
|
1777
|
+
proctoring_escalation_email = _get_proctoring_escalation_email(course_id)
|
|
1778
|
+
if proctoring_escalation_email:
|
|
1779
|
+
contact_url = f'mailto:{proctoring_escalation_email}'
|
|
1780
|
+
contact_url_text = proctoring_escalation_email
|
|
1781
|
+
else:
|
|
1782
|
+
contact_url = getattr(settings, 'PROCTORING_BACKENDS', {}).get(backend, {}).get(
|
|
1783
|
+
'LINK_URLS', {}).get('contact', default_contact_url)
|
|
1784
|
+
contact_url_text = contact_url
|
|
1785
|
+
|
|
1786
|
+
body = email_template.render({
|
|
1787
|
+
'course_url': course_home_url,
|
|
1788
|
+
'course_name': course_name,
|
|
1789
|
+
'exam_name': exam_name,
|
|
1790
|
+
'status': status,
|
|
1791
|
+
'platform': constants.PLATFORM_NAME,
|
|
1792
|
+
'contact_url': contact_url,
|
|
1793
|
+
'contact_url_text': contact_url_text,
|
|
1794
|
+
})
|
|
1795
|
+
|
|
1796
|
+
email = EmailMessage(
|
|
1797
|
+
body=body,
|
|
1798
|
+
from_email=constants.FROM_EMAIL,
|
|
1799
|
+
to=[exam_attempt_obj.user.email],
|
|
1800
|
+
subject=email_subject,
|
|
1801
|
+
)
|
|
1802
|
+
email.content_subtype = 'html'
|
|
1803
|
+
return email
|
|
1804
|
+
|
|
1805
|
+
|
|
1806
|
+
def _get_email_template_paths(template_name, backend):
|
|
1807
|
+
"""
|
|
1808
|
+
Get a list of email template paths to search for, depending on the name of the desired template and the
|
|
1809
|
+
exam attempt's exam's backend.
|
|
1810
|
+
|
|
1811
|
+
Arguments:
|
|
1812
|
+
template_name: the filename of the template
|
|
1813
|
+
backend: the name of the backend being used for the exam
|
|
1814
|
+
"""
|
|
1815
|
+
base_template = f'emails/{template_name}'
|
|
1816
|
+
|
|
1817
|
+
if backend:
|
|
1818
|
+
return [
|
|
1819
|
+
f'emails/proctoring/{backend}/{template_name}',
|
|
1820
|
+
base_template,
|
|
1821
|
+
]
|
|
1822
|
+
return [base_template]
|
|
1823
|
+
|
|
1824
|
+
|
|
1825
|
+
def _get_proctoring_escalation_email(course_id):
|
|
1826
|
+
"""
|
|
1827
|
+
Returns the proctoring escalation email for a course as a string, if it exists,
|
|
1828
|
+
otherwise returns None.
|
|
1829
|
+
"""
|
|
1830
|
+
proctoring_escalation_email = None
|
|
1831
|
+
instructor_service = get_runtime_service('instructor')
|
|
1832
|
+
if instructor_service:
|
|
1833
|
+
try:
|
|
1834
|
+
proctoring_escalation_email = instructor_service.get_proctoring_escalation_email(course_id)
|
|
1835
|
+
except (InvalidKeyError, ObjectDoesNotExist):
|
|
1836
|
+
log.warning(
|
|
1837
|
+
('Could not retrieve proctoring escalation email for course_id=%(course_id)s. '
|
|
1838
|
+
'Using default support contact URL instead.'),
|
|
1839
|
+
{
|
|
1840
|
+
'course_id': course_id,
|
|
1841
|
+
}
|
|
1842
|
+
)
|
|
1843
|
+
return proctoring_escalation_email
|
|
1844
|
+
|
|
1845
|
+
|
|
1846
|
+
def reset_practice_exam(exam_id, user_id, requesting_user):
|
|
1847
|
+
"""
|
|
1848
|
+
Resets a completed practice exam attempt back to the created state.
|
|
1849
|
+
"""
|
|
1850
|
+
log.info(
|
|
1851
|
+
'Resetting practice exam_id=%(exam_id)s for user_id=%(user_id)s',
|
|
1852
|
+
{
|
|
1853
|
+
'exam_id': exam_id,
|
|
1854
|
+
'user_id': user_id,
|
|
1855
|
+
}
|
|
1856
|
+
)
|
|
1857
|
+
|
|
1858
|
+
exam_attempt_obj = ProctoredExamStudentAttempt.objects.get_current_exam_attempt(exam_id, user_id)
|
|
1859
|
+
if exam_attempt_obj is None:
|
|
1860
|
+
err_msg = (
|
|
1861
|
+
f'Tried to reset attempt in exam_id={exam_id} for user_id={user_id}, '
|
|
1862
|
+
'but this attempt does not exist.'
|
|
1863
|
+
)
|
|
1864
|
+
raise StudentExamAttemptDoesNotExistsException(err_msg)
|
|
1865
|
+
|
|
1866
|
+
exam = get_exam_by_id(exam_id)
|
|
1867
|
+
if not exam['is_practice_exam']:
|
|
1868
|
+
err_msg = (
|
|
1869
|
+
f'Failed to reset attempt status on exam_id={exam_id} for user_id={user_id}. '
|
|
1870
|
+
f"Only practice exams may be reset! (course_id={exam['course_id']})"
|
|
1871
|
+
)
|
|
1872
|
+
raise ProctoredExamIllegalStatusTransition(err_msg)
|
|
1873
|
+
|
|
1874
|
+
# prevent a reset if the exam is currently in progress or has already been verified
|
|
1875
|
+
attempt_in_progress = ProctoredExamStudentAttemptStatus.is_incomplete_status(exam_attempt_obj.status)
|
|
1876
|
+
if attempt_in_progress or exam_attempt_obj.status == ProctoredExamStudentAttemptStatus.verified:
|
|
1877
|
+
err_msg = (
|
|
1878
|
+
f'Failed to reset attempt status on exam_id={exam_id} for user_id={user_id}. '
|
|
1879
|
+
f"Attempt with status={exam_attempt_obj.status} cannot be reset! (course_id={exam['course_id']})"
|
|
1880
|
+
)
|
|
1881
|
+
raise ProctoredExamIllegalStatusTransition(err_msg)
|
|
1882
|
+
|
|
1883
|
+
# resetting a submitted attempt that has not been reviewed will entirely remove that submission.
|
|
1884
|
+
if exam_attempt_obj.status == ProctoredExamStudentAttemptStatus.submitted:
|
|
1885
|
+
# This is the learner retrying their own onboarding exam, so a provider problem
|
|
1886
|
+
# must not block them: remove what we can upstream and reset locally regardless.
|
|
1887
|
+
remove_exam_attempt(exam_attempt_obj.id, requesting_user, require_backend_removal=False)
|
|
1888
|
+
else:
|
|
1889
|
+
exam_attempt_obj.status = ProctoredExamStudentAttemptStatus.onboarding_reset
|
|
1890
|
+
exam_attempt_obj.save()
|
|
1891
|
+
|
|
1892
|
+
emit_event(exam, 'reset_practice_exam', attempt=_get_exam_attempt(exam_attempt_obj))
|
|
1893
|
+
|
|
1894
|
+
return create_exam_attempt(exam_id, user_id, taking_as_proctored=exam_attempt_obj.taking_as_proctored)
|
|
1895
|
+
|
|
1896
|
+
|
|
1897
|
+
def _remove_exam_attempt_from_backend(attempt):
|
|
1898
|
+
"""
|
|
1899
|
+
Ask the proctoring backend to remove ``attempt`` on the provider's side, raising
|
|
1900
|
+
``BackendProviderCannotRemoveAttempt`` (HTTP 502) if it cannot.
|
|
1901
|
+
|
|
1902
|
+
This is called BEFORE the local delete (see ``remove_exam_attempt``). Doing the
|
|
1903
|
+
provider call outside the ``pre_delete`` signal means a provider outage can raise a
|
|
1904
|
+
descriptive error without the local delete being half-applied: raising from inside
|
|
1905
|
+
``delete()``'s ``pre_delete`` marks the DB connection needs-rollback, which then makes
|
|
1906
|
+
the surrounding request (and any retry) fail with ``TransactionManagementError``.
|
|
1907
|
+
|
|
1908
|
+
An attempt already gone upstream (provider 404) counts as success, so the local delete
|
|
1909
|
+
proceeds and a retry converges. Bulk, best-effort callers (onboarding cleanup, the
|
|
1910
|
+
reset_attempts command) should use ``_remove_exam_attempts_from_backend`` instead, which
|
|
1911
|
+
swallows these errors and skips a failing backend for the rest of the pass.
|
|
1912
|
+
"""
|
|
1913
|
+
exam = attempt.proctored_exam
|
|
1914
|
+
if not exam.is_proctored:
|
|
1915
|
+
return
|
|
1916
|
+
if not attempt.external_id:
|
|
1917
|
+
# The attempt was never registered with the provider; nothing to remove upstream.
|
|
1918
|
+
return
|
|
1919
|
+
# Single instructor-facing message for any provider-side removal failure. The specific
|
|
1920
|
+
# cause is logged (below), never surfaced to the user.
|
|
1921
|
+
# Translators: shown to an instructor when an exam attempt could not be reset because
|
|
1922
|
+
# the external proctoring provider is temporarily unavailable.
|
|
1923
|
+
unavailable_message = _(
|
|
1924
|
+
'The proctoring provider is temporarily unavailable, so this attempt could not '
|
|
1925
|
+
'be fully reset. Please try again in a few minutes.'
|
|
1926
|
+
)
|
|
1927
|
+
try:
|
|
1928
|
+
# Resolving the backend can itself raise (e.g. NotImplementedError for a
|
|
1929
|
+
# stale/unconfigured backend), so keep it inside the error-handling block.
|
|
1930
|
+
backend = get_backend_provider(name=exam.backend)
|
|
1931
|
+
if not backend:
|
|
1932
|
+
return
|
|
1933
|
+
result = backend.remove_exam_attempt(exam.external_id, attempt.external_id)
|
|
1934
|
+
except Exception as exc:
|
|
1935
|
+
log.exception(
|
|
1936
|
+
'Failed to remove attempt_id=%(attempt_id)s (external_id=%(external_id)s) for '
|
|
1937
|
+
'exam_id=%(exam_id)s user_id=%(user_id)s from backend=%(backend)s due to a provider error.',
|
|
1938
|
+
{
|
|
1939
|
+
'attempt_id': attempt.id,
|
|
1940
|
+
'external_id': attempt.external_id,
|
|
1941
|
+
'exam_id': exam.id,
|
|
1942
|
+
'user_id': attempt.user_id,
|
|
1943
|
+
'backend': exam.backend,
|
|
1944
|
+
}
|
|
1945
|
+
)
|
|
1946
|
+
raise BackendProviderCannotRemoveAttempt(unavailable_message) from exc
|
|
1947
|
+
if not result:
|
|
1948
|
+
# The provider responded but did not confirm removal, so keep the local attempt: a
|
|
1949
|
+
# retry can converge once the provider is healthy instead of silently dropping it.
|
|
1950
|
+
# This is attempt-specific -- the provider answered -- so it is raised as the more
|
|
1951
|
+
# specific subclass, letting bulk callers keep using the same backend.
|
|
1952
|
+
log.warning('Backend %s did not confirm removal of attempt_id=%s.', exam.backend, attempt.id)
|
|
1953
|
+
raise BackendProviderRemovalNotConfirmed(unavailable_message)
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
def _remove_exam_attempts_from_backend(attempts, failed_backends=frozenset()):
|
|
1957
|
+
"""
|
|
1958
|
+
Best-effort provider-side removal for a collection of attempts.
|
|
1959
|
+
|
|
1960
|
+
Provider failures are swallowed (the caller is expected to delete the attempts locally
|
|
1961
|
+
regardless). Once a backend proves *unreachable* we stop calling it for the rest of this
|
|
1962
|
+
pass -- while still trying other, independently configured backends -- so a provider
|
|
1963
|
+
outage does not cost a full request timeout for every attempt. A reachable provider that
|
|
1964
|
+
merely fails to confirm one removal is not skipped, since the remaining attempts on it
|
|
1965
|
+
are still worth attempting.
|
|
1966
|
+
|
|
1967
|
+
Returns the set of backends that failed, so a caller working through batches can thread
|
|
1968
|
+
it back in and keep skipping them across calls.
|
|
1969
|
+
|
|
1970
|
+
Pass a queryset with ``select_related('proctored_exam')`` to avoid a query per attempt.
|
|
1971
|
+
"""
|
|
1972
|
+
# Fresh mutable set per call; the shared default is only ever copied from, never mutated.
|
|
1973
|
+
failed = set(failed_backends)
|
|
1974
|
+
for attempt in attempts:
|
|
1975
|
+
backend = attempt.proctored_exam.backend
|
|
1976
|
+
if backend in failed:
|
|
1977
|
+
continue
|
|
1978
|
+
try:
|
|
1979
|
+
_remove_exam_attempt_from_backend(attempt)
|
|
1980
|
+
except BackendProviderRemovalNotConfirmed:
|
|
1981
|
+
# The provider answered, it just did not confirm this one. It is still healthy,
|
|
1982
|
+
# so keep going -- skipping the rest would orphan them upstream for nothing.
|
|
1983
|
+
log.warning(
|
|
1984
|
+
'Backend %r did not confirm removal of attempt_id=%s; removing locally only.',
|
|
1985
|
+
backend, attempt.id,
|
|
1986
|
+
)
|
|
1987
|
+
except BackendProviderCannotRemoveAttempt:
|
|
1988
|
+
# The provider could not be reached at all, so stop paying its timeout for
|
|
1989
|
+
# every remaining attempt on that backend.
|
|
1990
|
+
log.warning(
|
|
1991
|
+
'Provider removal failed for backend %r; skipping further provider calls '
|
|
1992
|
+
'for it during this cleanup and removing locally only.', backend
|
|
1993
|
+
)
|
|
1994
|
+
failed.add(backend)
|
|
1995
|
+
return failed
|
|
1996
|
+
|
|
1997
|
+
|
|
1998
|
+
def remove_exam_attempt(attempt_id, requesting_user, require_backend_removal=True):
|
|
1999
|
+
"""
|
|
2000
|
+
Removes an exam attempt given the attempt id. requesting_user is passed through to the instructor_service.
|
|
2001
|
+
|
|
2002
|
+
The attempt is removed on the proctoring provider first. By default a provider failure
|
|
2003
|
+
raises ``BackendProviderCannotRemoveAttempt`` and the local attempt is left untouched so
|
|
2004
|
+
it can be retried. Pass ``require_backend_removal=False`` for learner-initiated resets,
|
|
2005
|
+
where the provider removal is best-effort and must not block the learner.
|
|
2006
|
+
"""
|
|
2007
|
+
|
|
2008
|
+
log.info(
|
|
2009
|
+
'Removing exam attempt_id=%(attempt_id)s',
|
|
2010
|
+
{
|
|
2011
|
+
'attempt_id': attempt_id,
|
|
2012
|
+
}
|
|
2013
|
+
)
|
|
2014
|
+
|
|
2015
|
+
existing_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id(attempt_id)
|
|
2016
|
+
if not existing_attempt:
|
|
2017
|
+
err_msg = (
|
|
2018
|
+
f'Cannot remove attempt for attempt_id={attempt_id} because it does not exist!'
|
|
2019
|
+
)
|
|
2020
|
+
|
|
2021
|
+
raise StudentExamAttemptDoesNotExistsException(err_msg)
|
|
2022
|
+
|
|
2023
|
+
username = existing_attempt.user.username
|
|
2024
|
+
user_id = existing_attempt.user.id
|
|
2025
|
+
course_id = existing_attempt.proctored_exam.course_id
|
|
2026
|
+
content_id = existing_attempt.proctored_exam.content_id
|
|
2027
|
+
to_status = existing_attempt.status
|
|
2028
|
+
|
|
2029
|
+
# Remove the attempt on the proctoring provider BEFORE deleting it locally, so a
|
|
2030
|
+
# provider outage surfaces a descriptive error instead of a rolled-back, half-applied
|
|
2031
|
+
# delete. See _remove_exam_attempt_from_backend for why this must not happen in the
|
|
2032
|
+
# pre_delete signal.
|
|
2033
|
+
if require_backend_removal:
|
|
2034
|
+
_remove_exam_attempt_from_backend(existing_attempt)
|
|
2035
|
+
else:
|
|
2036
|
+
_remove_exam_attempts_from_backend([existing_attempt])
|
|
2037
|
+
|
|
2038
|
+
existing_attempt.delete_exam_attempt()
|
|
2039
|
+
instructor_service = get_runtime_service('instructor')
|
|
2040
|
+
grades_service = get_runtime_service('grades')
|
|
2041
|
+
|
|
2042
|
+
if instructor_service:
|
|
2043
|
+
instructor_service.delete_student_attempt(username, course_id, content_id, requesting_user=requesting_user)
|
|
2044
|
+
if grades_service:
|
|
2045
|
+
# EDUCATOR-2141: Also remove any grade overrides that may exist
|
|
2046
|
+
grades_service.undo_override_subsection_grade(
|
|
2047
|
+
user_id=user_id,
|
|
2048
|
+
course_key_or_id=course_id,
|
|
2049
|
+
usage_key_or_id=content_id,
|
|
2050
|
+
)
|
|
2051
|
+
|
|
2052
|
+
# see if the status transition this changes credit requirement status
|
|
2053
|
+
if ProctoredExamStudentAttemptStatus.needs_credit_status_update(to_status):
|
|
2054
|
+
# trigger credit workflow, as needed
|
|
2055
|
+
credit_service = get_runtime_service('credit')
|
|
2056
|
+
credit_service.remove_credit_requirement_status(
|
|
2057
|
+
user_id=user_id,
|
|
2058
|
+
course_key_or_id=course_id,
|
|
2059
|
+
req_namespace='proctored_exam',
|
|
2060
|
+
req_name=content_id
|
|
2061
|
+
)
|
|
2062
|
+
|
|
2063
|
+
# emit an event for 'deleted'
|
|
2064
|
+
exam = get_exam_by_content_id(course_id, content_id)
|
|
2065
|
+
serialized_attempt_obj = ProctoredExamStudentAttemptSerializer(existing_attempt)
|
|
2066
|
+
attempt = serialized_attempt_obj.data
|
|
2067
|
+
emit_event(exam, 'deleted', attempt=attempt)
|
|
2068
|
+
|
|
2069
|
+
|
|
2070
|
+
def get_all_exams_for_course(course_id, active_only=False):
|
|
2071
|
+
"""
|
|
2072
|
+
This method will return all exams for a course. This will return a list
|
|
2073
|
+
of dictionaries, whose schema is the same as what is returned in
|
|
2074
|
+
get_exam_by_id
|
|
2075
|
+
Returns a list containing dictionary version of the Django ORM object
|
|
2076
|
+
e.g.
|
|
2077
|
+
[{
|
|
2078
|
+
"course_id": "edX/DemoX/Demo_Course",
|
|
2079
|
+
"content_id": "123",
|
|
2080
|
+
"external_id": "",
|
|
2081
|
+
"exam_name": "Midterm",
|
|
2082
|
+
"time_limit_mins": 90,
|
|
2083
|
+
"is_proctored": true,
|
|
2084
|
+
"is_active": true
|
|
2085
|
+
},
|
|
2086
|
+
{
|
|
2087
|
+
...: ...,
|
|
2088
|
+
...: ...
|
|
2089
|
+
|
|
2090
|
+
},
|
|
2091
|
+
..
|
|
2092
|
+
]
|
|
2093
|
+
"""
|
|
2094
|
+
exams = ProctoredExam.get_all_exams_for_course(
|
|
2095
|
+
course_id,
|
|
2096
|
+
active_only=active_only
|
|
2097
|
+
)
|
|
2098
|
+
|
|
2099
|
+
return [ProctoredExamSerializer(proctored_exam).data for proctored_exam in exams]
|
|
2100
|
+
|
|
2101
|
+
|
|
2102
|
+
def get_all_exam_attempts(course_id):
|
|
2103
|
+
"""
|
|
2104
|
+
Returns all the exam attempts for the course id.
|
|
2105
|
+
"""
|
|
2106
|
+
exam_attempts = ProctoredExamStudentAttempt.objects.get_all_exam_attempts(course_id)
|
|
2107
|
+
return [ProctoredExamStudentAttemptSerializer(active_exam).data for active_exam in exam_attempts]
|
|
2108
|
+
|
|
2109
|
+
|
|
2110
|
+
def get_filtered_exam_attempts(course_id, search_by):
|
|
2111
|
+
"""
|
|
2112
|
+
Returns all exam attempts for a course id filtered by the search_by string in user names and emails.
|
|
2113
|
+
"""
|
|
2114
|
+
exam_attempts = ProctoredExamStudentAttempt.objects.get_filtered_exam_attempts(course_id, search_by)
|
|
2115
|
+
return [ProctoredExamStudentAttemptSerializer(active_exam).data for active_exam in exam_attempts]
|
|
2116
|
+
|
|
2117
|
+
|
|
2118
|
+
def get_user_attempts_by_exam_id(user_id, exam_id):
|
|
2119
|
+
"""
|
|
2120
|
+
Returns all exam attempts for a given user and exam
|
|
2121
|
+
"""
|
|
2122
|
+
exam_attempts = ProctoredExamStudentAttempt.objects.get_user_attempts_by_exam_id(user_id, exam_id)
|
|
2123
|
+
return [ProctoredExamStudentAttemptSerializer(active_attempt).data for active_attempt in exam_attempts]
|
|
2124
|
+
|
|
2125
|
+
|
|
2126
|
+
def get_last_exam_completion_date(course_id, username):
|
|
2127
|
+
"""
|
|
2128
|
+
Return the completion date of last proctoring exam for the given course and username if
|
|
2129
|
+
all the proctored exams are attempted and completed otherwise None
|
|
2130
|
+
"""
|
|
2131
|
+
exam_attempts = ProctoredExamStudentAttempt.objects.get_proctored_exam_attempts(course_id, username)
|
|
2132
|
+
proctored_exams_count = ProctoredExam.get_all_exams_for_course(course_id, proctored_exams_only=True).count()
|
|
2133
|
+
are_all_exams_attempted = len(exam_attempts) == proctored_exams_count
|
|
2134
|
+
if are_all_exams_attempted:
|
|
2135
|
+
for attempt in exam_attempts:
|
|
2136
|
+
if not attempt.completed_at:
|
|
2137
|
+
return None
|
|
2138
|
+
|
|
2139
|
+
# Last proctored exam will be at first index, because attempts are sorted descending on completed_at
|
|
2140
|
+
return exam_attempts[0].completed_at if exam_attempts and are_all_exams_attempted else None
|
|
2141
|
+
|
|
2142
|
+
|
|
2143
|
+
def get_active_exams_for_user(user_id, course_id=None):
|
|
2144
|
+
"""
|
|
2145
|
+
This method will return a list of active exams for the user,
|
|
2146
|
+
i.e. started_at != None and completed_at == None. Theoretically there
|
|
2147
|
+
could be more than one, but in practice it will be one active exam.
|
|
2148
|
+
|
|
2149
|
+
If course_id is set, then attempts only for an exam in that course_id
|
|
2150
|
+
should be returned.
|
|
2151
|
+
|
|
2152
|
+
The return set should be a list of dictionary objects which are nested
|
|
2153
|
+
|
|
2154
|
+
|
|
2155
|
+
[{
|
|
2156
|
+
'exam': <exam fields as dict>,
|
|
2157
|
+
'attempt': <student attempt fields as dict>,
|
|
2158
|
+
'allowances': <student allowances as dict of key/value pairs
|
|
2159
|
+
}, {}, ...]
|
|
2160
|
+
|
|
2161
|
+
"""
|
|
2162
|
+
result = []
|
|
2163
|
+
|
|
2164
|
+
student_active_exams = ProctoredExamStudentAttempt.objects.get_active_student_attempts(user_id, course_id)
|
|
2165
|
+
for active_exam in student_active_exams:
|
|
2166
|
+
# convert the django orm objects
|
|
2167
|
+
# into the serialized form.
|
|
2168
|
+
exam_serialized_data = ProctoredExamSerializer(active_exam.proctored_exam).data
|
|
2169
|
+
active_exam_serialized_data = ProctoredExamStudentAttemptSerializer(active_exam).data
|
|
2170
|
+
student_allowances = ProctoredExamStudentAllowance.get_allowances_for_user(
|
|
2171
|
+
active_exam.proctored_exam.id, user_id
|
|
2172
|
+
)
|
|
2173
|
+
allowance_serialized_data = [ProctoredExamStudentAllowanceSerializer(allowance).data for allowance in
|
|
2174
|
+
student_allowances]
|
|
2175
|
+
result.append({
|
|
2176
|
+
'exam': exam_serialized_data,
|
|
2177
|
+
'attempt': active_exam_serialized_data,
|
|
2178
|
+
'allowances': allowance_serialized_data
|
|
2179
|
+
})
|
|
2180
|
+
|
|
2181
|
+
return result
|
|
2182
|
+
|
|
2183
|
+
|
|
2184
|
+
def check_prerequisites(exam, user_id):
|
|
2185
|
+
"""
|
|
2186
|
+
Check if prerequisites are satisfied for user to take the exam
|
|
2187
|
+
"""
|
|
2188
|
+
credit_service = get_runtime_service('credit')
|
|
2189
|
+
credit_state = credit_service.get_credit_state(user_id, exam['course_id'])
|
|
2190
|
+
if not credit_state:
|
|
2191
|
+
return exam
|
|
2192
|
+
credit_requirement_status = credit_state.get('credit_requirement_status', [])
|
|
2193
|
+
|
|
2194
|
+
prerequisite_status = _are_prerequirements_satisfied(
|
|
2195
|
+
credit_requirement_status,
|
|
2196
|
+
evaluate_for_requirement_name=exam['content_id'],
|
|
2197
|
+
filter_out_namespaces=['grade']
|
|
2198
|
+
)
|
|
2199
|
+
|
|
2200
|
+
exam.update({
|
|
2201
|
+
'prerequisite_status': prerequisite_status
|
|
2202
|
+
})
|
|
2203
|
+
|
|
2204
|
+
if not prerequisite_status['are_prerequisites_satisifed']:
|
|
2205
|
+
# do we have any declined prerequisites, if so, then we
|
|
2206
|
+
# will auto-decline this proctored exam
|
|
2207
|
+
if prerequisite_status['declined_prerequisites']:
|
|
2208
|
+
_create_and_decline_attempt(exam['id'], user_id)
|
|
2209
|
+
return exam
|
|
2210
|
+
|
|
2211
|
+
if prerequisite_status['failed_prerequisites']:
|
|
2212
|
+
prerequisite_status['failed_prerequisites'] = _resolve_prerequisite_links(
|
|
2213
|
+
exam,
|
|
2214
|
+
prerequisite_status['failed_prerequisites']
|
|
2215
|
+
)
|
|
2216
|
+
else:
|
|
2217
|
+
prerequisite_status['pending_prerequisites'] = _resolve_prerequisite_links(
|
|
2218
|
+
exam,
|
|
2219
|
+
prerequisite_status['pending_prerequisites']
|
|
2220
|
+
)
|
|
2221
|
+
return exam
|
|
2222
|
+
|
|
2223
|
+
|
|
2224
|
+
def _get_ordered_prerequisites(prerequisites_statuses, filter_out_namespaces=None):
|
|
2225
|
+
"""
|
|
2226
|
+
Apply filter and ordering of requirements status in our credit_state dictionary. This will
|
|
2227
|
+
return a list of statuses, filtered according to the filter_lambda (if non-None). We do this to ensure
|
|
2228
|
+
that we check for satisfactory fulfillment of prerequistes that we do so IN THE RIGHT ORDER
|
|
2229
|
+
"""
|
|
2230
|
+
|
|
2231
|
+
_filter_out_namespaces = filter_out_namespaces if filter_out_namespaces else []
|
|
2232
|
+
|
|
2233
|
+
filtered_list = [
|
|
2234
|
+
status
|
|
2235
|
+
for status in prerequisites_statuses
|
|
2236
|
+
if status['namespace'] not in _filter_out_namespaces
|
|
2237
|
+
]
|
|
2238
|
+
sorted_list = sorted(filtered_list, key=lambda status: status['order'])
|
|
2239
|
+
|
|
2240
|
+
return sorted_list
|
|
2241
|
+
|
|
2242
|
+
|
|
2243
|
+
def _are_prerequirements_satisfied(
|
|
2244
|
+
prerequisites_statuses,
|
|
2245
|
+
evaluate_for_requirement_name=None,
|
|
2246
|
+
filter_out_namespaces=None
|
|
2247
|
+
):
|
|
2248
|
+
"""
|
|
2249
|
+
Returns a dict about the fulfillment of any pre-requisites in order to this exam
|
|
2250
|
+
as proctored. The pre-requisites are taken from the credit requirements table. So if ordering
|
|
2251
|
+
of requirements are - say - ICRV1, Proctoring1, ICRV2, and Proctoring2, then the user cannot take
|
|
2252
|
+
Proctoring2 until there is an explicit pass on ICRV1, Proctoring1, ICRV2...
|
|
2253
|
+
|
|
2254
|
+
NOTE: If evaluate_for_requirement_name=None that means check all requirements
|
|
2255
|
+
|
|
2256
|
+
Return (dict):
|
|
2257
|
+
|
|
2258
|
+
{
|
|
2259
|
+
# If all prerequisites are satisfied
|
|
2260
|
+
'are_prerequisites_satisifed': True/False,
|
|
2261
|
+
|
|
2262
|
+
# A list of prerequisites that have been satisfied
|
|
2263
|
+
'satisfied_prerequisites': [...]
|
|
2264
|
+
|
|
2265
|
+
# A list of prerequisites that have failed
|
|
2266
|
+
'failed_prerequisites': [....],
|
|
2267
|
+
|
|
2268
|
+
# A list of prerequisites that are still pending
|
|
2269
|
+
'pending_prerequisites': [...],
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
NOTE: We filter out any 'grade' requirement since the student will most likely not have fulfilled
|
|
2273
|
+
those requirements while he/she is in the course
|
|
2274
|
+
"""
|
|
2275
|
+
|
|
2276
|
+
satisfied_prerequisites = []
|
|
2277
|
+
failed_prerequisites = []
|
|
2278
|
+
pending_prerequisites = []
|
|
2279
|
+
declined_prerequisites = []
|
|
2280
|
+
|
|
2281
|
+
# insure an ordered and filtered list
|
|
2282
|
+
# we remove 'grade' requirements since those cannot be
|
|
2283
|
+
# satisfied while student is in the middle of a course
|
|
2284
|
+
requirement_statuses = _get_ordered_prerequisites(
|
|
2285
|
+
prerequisites_statuses,
|
|
2286
|
+
filter_out_namespaces=filter_out_namespaces
|
|
2287
|
+
)
|
|
2288
|
+
|
|
2289
|
+
# find ourselves in the list
|
|
2290
|
+
ourself = None
|
|
2291
|
+
if evaluate_for_requirement_name:
|
|
2292
|
+
for idx, requirement in enumerate(requirement_statuses):
|
|
2293
|
+
if requirement['name'] == evaluate_for_requirement_name:
|
|
2294
|
+
ourself = requirement
|
|
2295
|
+
break
|
|
2296
|
+
|
|
2297
|
+
# we are not in the list of requirements, look at all requirements
|
|
2298
|
+
if not ourself:
|
|
2299
|
+
idx = len(requirement_statuses)
|
|
2300
|
+
|
|
2301
|
+
# now that we have the index of ourselves in the ordered list, we can walk backwards
|
|
2302
|
+
# and inspect all prerequisites
|
|
2303
|
+
|
|
2304
|
+
# we don't look at ourselves
|
|
2305
|
+
idx = idx - 1
|
|
2306
|
+
while idx >= 0:
|
|
2307
|
+
requirement = requirement_statuses[idx]
|
|
2308
|
+
status = requirement['status']
|
|
2309
|
+
if status == 'satisfied':
|
|
2310
|
+
satisfied_prerequisites.append(requirement)
|
|
2311
|
+
elif status == 'failed':
|
|
2312
|
+
failed_prerequisites.append(requirement)
|
|
2313
|
+
elif status == 'declined':
|
|
2314
|
+
declined_prerequisites.append(requirement)
|
|
2315
|
+
else:
|
|
2316
|
+
pending_prerequisites.append(requirement)
|
|
2317
|
+
|
|
2318
|
+
idx = idx - 1
|
|
2319
|
+
|
|
2320
|
+
return {
|
|
2321
|
+
# all prequisites are satisfied if there are no failed or pending requirement
|
|
2322
|
+
# statuses
|
|
2323
|
+
'are_prerequisites_satisifed': (
|
|
2324
|
+
not failed_prerequisites and not pending_prerequisites and not declined_prerequisites
|
|
2325
|
+
),
|
|
2326
|
+
# note that we reverse the list here, because we assempled it by walking backwards
|
|
2327
|
+
'satisfied_prerequisites': list(reversed(satisfied_prerequisites)),
|
|
2328
|
+
'failed_prerequisites': list(reversed(failed_prerequisites)),
|
|
2329
|
+
'pending_prerequisites': list(reversed(pending_prerequisites)),
|
|
2330
|
+
'declined_prerequisites': list(reversed(declined_prerequisites))
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
|
|
2334
|
+
JUMPTO_SUPPORTED_NAMESPACES = [
|
|
2335
|
+
'proctored_exam',
|
|
2336
|
+
]
|
|
2337
|
+
|
|
2338
|
+
|
|
2339
|
+
def _resolve_prerequisite_links(exam, prerequisites):
|
|
2340
|
+
"""
|
|
2341
|
+
This will inject a jumpto URL into the list of prerequisites so that a user
|
|
2342
|
+
can click through
|
|
2343
|
+
"""
|
|
2344
|
+
|
|
2345
|
+
for prerequisite in prerequisites:
|
|
2346
|
+
jumpto_url = None
|
|
2347
|
+
if prerequisite['namespace'] in JUMPTO_SUPPORTED_NAMESPACES and prerequisite['name']:
|
|
2348
|
+
jumpto_url = reverse('jump_to', args=[exam['course_id'], prerequisite['name']])
|
|
2349
|
+
|
|
2350
|
+
prerequisite['jumpto_url'] = jumpto_url
|
|
2351
|
+
|
|
2352
|
+
return prerequisites
|
|
2353
|
+
|
|
2354
|
+
|
|
2355
|
+
STATUS_SUMMARY_MAP = {
|
|
2356
|
+
'_default': {
|
|
2357
|
+
'short_description': gettext_noop('Taking As Proctored Exam'),
|
|
2358
|
+
'suggested_icon': 'fa-pencil-square-o',
|
|
2359
|
+
'in_completed_state': False
|
|
2360
|
+
},
|
|
2361
|
+
ProctoredExamStudentAttemptStatus.eligible: {
|
|
2362
|
+
'short_description': gettext_noop('Proctored Option Available'),
|
|
2363
|
+
'suggested_icon': 'fa-pencil-square-o',
|
|
2364
|
+
'in_completed_state': False
|
|
2365
|
+
},
|
|
2366
|
+
ProctoredExamStudentAttemptStatus.declined: {
|
|
2367
|
+
'short_description': gettext_noop('Taking As Open Exam'),
|
|
2368
|
+
'suggested_icon': 'fa-pencil-square-o',
|
|
2369
|
+
'in_completed_state': False
|
|
2370
|
+
},
|
|
2371
|
+
ProctoredExamStudentAttemptStatus.submitted: {
|
|
2372
|
+
'short_description': gettext_noop('Pending Session Review'),
|
|
2373
|
+
'suggested_icon': 'fa-spinner fa-spin',
|
|
2374
|
+
'in_completed_state': True
|
|
2375
|
+
},
|
|
2376
|
+
ProctoredExamStudentAttemptStatus.second_review_required: {
|
|
2377
|
+
'short_description': gettext_noop('Pending Session Review'),
|
|
2378
|
+
'suggested_icon': 'fa-spinner fa-spin',
|
|
2379
|
+
'in_completed_state': True
|
|
2380
|
+
},
|
|
2381
|
+
ProctoredExamStudentAttemptStatus.verified: {
|
|
2382
|
+
'short_description': gettext_noop('Passed Proctoring'),
|
|
2383
|
+
'suggested_icon': 'fa-check',
|
|
2384
|
+
'in_completed_state': True
|
|
2385
|
+
},
|
|
2386
|
+
ProctoredExamStudentAttemptStatus.rejected: {
|
|
2387
|
+
'short_description': gettext_noop('Failed Proctoring'),
|
|
2388
|
+
'suggested_icon': 'fa-exclamation-triangle',
|
|
2389
|
+
'in_completed_state': True
|
|
2390
|
+
},
|
|
2391
|
+
ProctoredExamStudentAttemptStatus.error: {
|
|
2392
|
+
'short_description': gettext_noop('Failed Proctoring'),
|
|
2393
|
+
'suggested_icon': 'fa-exclamation-triangle',
|
|
2394
|
+
'in_completed_state': True
|
|
2395
|
+
},
|
|
2396
|
+
ProctoredExamStudentAttemptStatus.expired: {
|
|
2397
|
+
'short_description': gettext_noop('Proctored Option No Longer Available'),
|
|
2398
|
+
'suggested_icon': 'fa-times-circle',
|
|
2399
|
+
'in_completed_state': False
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
|
|
2404
|
+
PRACTICE_STATUS_SUMMARY_MAP = {
|
|
2405
|
+
'_default': {
|
|
2406
|
+
'short_description': gettext_noop('Ungraded Practice Exam'),
|
|
2407
|
+
'suggested_icon': '',
|
|
2408
|
+
'in_completed_state': False
|
|
2409
|
+
},
|
|
2410
|
+
ProctoredExamStudentAttemptStatus.submitted: {
|
|
2411
|
+
'short_description': gettext_noop('Practice Exam Completed'),
|
|
2412
|
+
'suggested_icon': 'fa-check',
|
|
2413
|
+
'in_completed_state': True
|
|
2414
|
+
},
|
|
2415
|
+
ProctoredExamStudentAttemptStatus.error: {
|
|
2416
|
+
'short_description': gettext_noop('Practice Exam Failed'),
|
|
2417
|
+
'suggested_icon': 'fa-exclamation-triangle',
|
|
2418
|
+
'in_completed_state': True
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
TIMED_EXAM_STATUS_SUMMARY_MAP = {
|
|
2423
|
+
'_default': {
|
|
2424
|
+
'short_description': gettext_noop('Timed Exam'),
|
|
2425
|
+
'suggested_icon': 'fa-clock-o',
|
|
2426
|
+
'in_completed_state': False
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
|
|
2431
|
+
def get_attempt_status_summary(user_id, course_id, content_id):
|
|
2432
|
+
"""
|
|
2433
|
+
Collects a summary about the status of the attempt.
|
|
2434
|
+
|
|
2435
|
+
Summary is collected for the user in the course_id and content_id.
|
|
2436
|
+
|
|
2437
|
+
If the exam is timed exam only then we simply
|
|
2438
|
+
return the dictionary with timed exam default summary
|
|
2439
|
+
|
|
2440
|
+
Return will be:
|
|
2441
|
+
None: Not applicable
|
|
2442
|
+
- or -
|
|
2443
|
+
{
|
|
2444
|
+
'status': ['eligible', 'declined', 'submitted', 'verified', 'rejected'],
|
|
2445
|
+
'short_description': <short description of status>,
|
|
2446
|
+
'suggested_icon': <recommended font-awesome icon to use>,
|
|
2447
|
+
'in_completed_state': <if the status is considered in a 'completed' state>
|
|
2448
|
+
}
|
|
2449
|
+
"""
|
|
2450
|
+
|
|
2451
|
+
try:
|
|
2452
|
+
exam = get_exam_by_content_id(course_id, content_id)
|
|
2453
|
+
except ProctoredExamNotFoundException:
|
|
2454
|
+
# this really shouldn't happen, but log it at least
|
|
2455
|
+
log.exception(
|
|
2456
|
+
('Requested attempt status summary for user_id=%(user_id)s, but could not find exam '
|
|
2457
|
+
'in course_id=%(course_id)s with content_id=%(content_id)s'),
|
|
2458
|
+
{
|
|
2459
|
+
'user_id': user_id,
|
|
2460
|
+
'course_id': course_id,
|
|
2461
|
+
'content_id': content_id,
|
|
2462
|
+
}
|
|
2463
|
+
)
|
|
2464
|
+
return None
|
|
2465
|
+
|
|
2466
|
+
# check if the exam is not proctored
|
|
2467
|
+
if not exam['is_proctored']:
|
|
2468
|
+
summary = {}
|
|
2469
|
+
summary.update(TIMED_EXAM_STATUS_SUMMARY_MAP['_default'])
|
|
2470
|
+
# Note: translate the short description as it was stored unlocalized
|
|
2471
|
+
summary.update({
|
|
2472
|
+
'short_description': _(summary['short_description']) # pylint: disable=translation-of-non-string
|
|
2473
|
+
})
|
|
2474
|
+
return summary
|
|
2475
|
+
|
|
2476
|
+
# let's check credit eligibility
|
|
2477
|
+
credit_service = get_runtime_service('credit')
|
|
2478
|
+
credit_state = None # explicit assignment
|
|
2479
|
+
not_practice_exam = not exam.get('is_practice_exam')
|
|
2480
|
+
|
|
2481
|
+
# practice exams always has an attempt status regardless of
|
|
2482
|
+
# eligibility
|
|
2483
|
+
if credit_service and not_practice_exam:
|
|
2484
|
+
credit_state = credit_service.get_credit_state(user_id, str(course_id), return_course_info=True)
|
|
2485
|
+
user = USER_MODEL.objects.get(id=user_id)
|
|
2486
|
+
if not user.has_perm('edx_proctoring.can_take_proctored_exam', exam):
|
|
2487
|
+
return None
|
|
2488
|
+
|
|
2489
|
+
attempt = get_current_exam_attempt(exam['id'], user_id)
|
|
2490
|
+
due_date_is_passed = has_due_date_passed(credit_state.get('course_end_date')) if credit_state else False
|
|
2491
|
+
|
|
2492
|
+
if attempt:
|
|
2493
|
+
status = attempt['status']
|
|
2494
|
+
elif not_practice_exam and due_date_is_passed:
|
|
2495
|
+
status = ProctoredExamStudentAttemptStatus.expired
|
|
2496
|
+
else:
|
|
2497
|
+
status = ProctoredExamStudentAttemptStatus.eligible
|
|
2498
|
+
|
|
2499
|
+
status_map = STATUS_SUMMARY_MAP if not_practice_exam else PRACTICE_STATUS_SUMMARY_MAP
|
|
2500
|
+
|
|
2501
|
+
summary = {}
|
|
2502
|
+
if status in status_map:
|
|
2503
|
+
summary.update(status_map[status])
|
|
2504
|
+
else:
|
|
2505
|
+
summary.update(status_map['_default'])
|
|
2506
|
+
|
|
2507
|
+
# Note: translate the short description as it was stored unlocalized
|
|
2508
|
+
summary.update({
|
|
2509
|
+
'status': status,
|
|
2510
|
+
'short_description': _(summary['short_description']) # pylint: disable=translation-of-non-string
|
|
2511
|
+
})
|
|
2512
|
+
|
|
2513
|
+
return summary
|
|
2514
|
+
|
|
2515
|
+
|
|
2516
|
+
# pylint: disable=invalid-name
|
|
2517
|
+
def get_last_verified_onboarding_attempts_per_user(users_list, backend):
|
|
2518
|
+
"""
|
|
2519
|
+
Returns a dictionary of last verified proctored onboarding attempt for a specific backend,
|
|
2520
|
+
keyed off the user_id within the passed in users_list, if attempts exist.
|
|
2521
|
+
This only considers attempts within the last two years, as attempts
|
|
2522
|
+
before this point are considered expired.
|
|
2523
|
+
|
|
2524
|
+
Parameters:
|
|
2525
|
+
* users: A list of users object of which we are checking the attempts
|
|
2526
|
+
* proctoring_backend: The name of the proctoring backend
|
|
2527
|
+
|
|
2528
|
+
Returns:
|
|
2529
|
+
A dictionary:
|
|
2530
|
+
{
|
|
2531
|
+
342455 (user_id): attempt_object(attempt_id=1233),
|
|
2532
|
+
455454 (user_id): attempt_object(attempt_id=34303),
|
|
2533
|
+
}
|
|
2534
|
+
"""
|
|
2535
|
+
attempts = ProctoredExamStudentAttempt.objects.get_last_verified_proctored_onboarding_attempts(
|
|
2536
|
+
users_list,
|
|
2537
|
+
backend,
|
|
2538
|
+
)
|
|
2539
|
+
user_attempt_dict = {}
|
|
2540
|
+
for attempt in attempts:
|
|
2541
|
+
user_id = attempt.user_id
|
|
2542
|
+
if not user_attempt_dict.get(user_id):
|
|
2543
|
+
user_attempt_dict[user_id] = attempt
|
|
2544
|
+
return user_attempt_dict
|
|
2545
|
+
|
|
2546
|
+
|
|
2547
|
+
def _does_time_remain(attempt):
|
|
2548
|
+
"""
|
|
2549
|
+
Helper function returns True if time remains for an attempt and False
|
|
2550
|
+
otherwise. Called from _get_timed_exam_view(), _get_practice_exam_view()
|
|
2551
|
+
and _get_proctored_exam_view()
|
|
2552
|
+
"""
|
|
2553
|
+
does_time_remain = False
|
|
2554
|
+
has_started_exam = (
|
|
2555
|
+
attempt and
|
|
2556
|
+
attempt.get('started_at') and
|
|
2557
|
+
ProctoredExamStudentAttemptStatus.is_incomplete_status(attempt.get('status'))
|
|
2558
|
+
)
|
|
2559
|
+
if has_started_exam:
|
|
2560
|
+
expires_at = attempt['started_at'] + timedelta(minutes=attempt['allowed_time_limit_mins'])
|
|
2561
|
+
does_time_remain = datetime.now(pytz.UTC) < expires_at
|
|
2562
|
+
return does_time_remain
|
|
2563
|
+
|
|
2564
|
+
|
|
2565
|
+
# pylint: disable=inconsistent-return-statements
|
|
2566
|
+
def _get_timed_exam_view(exam, context, exam_id, user_id, course_id):
|
|
2567
|
+
"""
|
|
2568
|
+
Returns a rendered view for the Timed Exams
|
|
2569
|
+
"""
|
|
2570
|
+
student_view_template = None
|
|
2571
|
+
attempt = get_current_exam_attempt(exam_id, user_id)
|
|
2572
|
+
has_time_expired = False
|
|
2573
|
+
|
|
2574
|
+
attempt_status = attempt['status'] if attempt else None
|
|
2575
|
+
has_due_date = exam['due_date'] is not None
|
|
2576
|
+
if not attempt_status or attempt_status == ProctoredExamStudentAttemptStatus.created:
|
|
2577
|
+
if is_exam_passed_due(exam, user=user_id):
|
|
2578
|
+
student_view_template = 'timed_exam/expired.html'
|
|
2579
|
+
else:
|
|
2580
|
+
student_view_template = 'timed_exam/entrance.html'
|
|
2581
|
+
elif is_exam_passed_due(exam, user_id) and ProctoredExamStudentAttemptStatus.is_incomplete_status(attempt_status):
|
|
2582
|
+
# When the exam is past due, we should prevent learners from accessing the exam even if
|
|
2583
|
+
# they already accessed the exam before, but haven't completed.
|
|
2584
|
+
student_view_template = 'timed_exam/expired.html'
|
|
2585
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.timed_out:
|
|
2586
|
+
raise NotImplementedError('There is no defined rendering for ProctoredExamStudentAttemptStatus.timed_out!')
|
|
2587
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.started:
|
|
2588
|
+
# when we're taking the exam we should not override the view
|
|
2589
|
+
return None
|
|
2590
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.ready_to_submit:
|
|
2591
|
+
student_view_template = 'timed_exam/ready_to_submit.html'
|
|
2592
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.submitted:
|
|
2593
|
+
# If we are not hiding the exam after the due_date has passed,
|
|
2594
|
+
# check if the exam's due_date has passed. If so, return None
|
|
2595
|
+
# so that the user can see their exam answers in read only mode.
|
|
2596
|
+
if not exam['hide_after_due'] and is_exam_passed_due(exam, user=user_id):
|
|
2597
|
+
has_context_updated = verify_and_add_wait_deadline(context, exam, user_id)
|
|
2598
|
+
if not has_context_updated:
|
|
2599
|
+
return None
|
|
2600
|
+
|
|
2601
|
+
student_view_template = 'timed_exam/submitted.html'
|
|
2602
|
+
|
|
2603
|
+
current_datetime = datetime.now(pytz.UTC)
|
|
2604
|
+
start_time = attempt['started_at']
|
|
2605
|
+
end_time = attempt['completed_at']
|
|
2606
|
+
attempt_duration_sec = (end_time - start_time).total_seconds()
|
|
2607
|
+
allowed_duration_sec = attempt['allowed_time_limit_mins'] * 60
|
|
2608
|
+
since_exam_ended_sec = (current_datetime - end_time).total_seconds()
|
|
2609
|
+
|
|
2610
|
+
# if the user took >= the available time, then the exam must have expired.
|
|
2611
|
+
# but we show expiry message only when the exam was taken recently (less than SHOW_EXPIRY_MESSAGE_DURATION)
|
|
2612
|
+
if attempt_duration_sec >= allowed_duration_sec and since_exam_ended_sec < SHOW_EXPIRY_MESSAGE_DURATION:
|
|
2613
|
+
has_time_expired = True
|
|
2614
|
+
|
|
2615
|
+
if student_view_template:
|
|
2616
|
+
template = loader.get_template(student_view_template)
|
|
2617
|
+
allowed_time_limit_mins = attempt.get('allowed_time_limit_mins', None) if attempt else None
|
|
2618
|
+
|
|
2619
|
+
if not allowed_time_limit_mins:
|
|
2620
|
+
# no existing attempt or user has not started exam yet, so compute the user's allowed
|
|
2621
|
+
# time limit, including any accommodations or time saved from a previous attempt
|
|
2622
|
+
allowed_time_limit_mins = _calculate_allowed_mins(exam, user_id)
|
|
2623
|
+
|
|
2624
|
+
total_time = humanized_time(allowed_time_limit_mins)
|
|
2625
|
+
|
|
2626
|
+
# According to WCAG, there is no need to allow for extra time if > 20 hours allowed
|
|
2627
|
+
hide_extra_time_footer = exam['time_limit_mins'] > 20 * 60
|
|
2628
|
+
|
|
2629
|
+
progress_page_url = ''
|
|
2630
|
+
try:
|
|
2631
|
+
progress_page_url = reverse('progress', args=[course_id])
|
|
2632
|
+
except NoReverseMatch:
|
|
2633
|
+
log.exception(
|
|
2634
|
+
"Can't find progress url for course_id=%(course_id)s",
|
|
2635
|
+
{
|
|
2636
|
+
'course_id': course_id,
|
|
2637
|
+
}
|
|
2638
|
+
)
|
|
2639
|
+
|
|
2640
|
+
context.update({
|
|
2641
|
+
'total_time': total_time,
|
|
2642
|
+
'hide_extra_time_footer': hide_extra_time_footer,
|
|
2643
|
+
'will_be_revealed': has_due_date and not exam['hide_after_due'],
|
|
2644
|
+
'exam_id': exam_id,
|
|
2645
|
+
'exam_name': exam['exam_name'],
|
|
2646
|
+
'progress_page_url': progress_page_url,
|
|
2647
|
+
'does_time_remain': _does_time_remain(attempt),
|
|
2648
|
+
'has_time_expired': has_time_expired,
|
|
2649
|
+
'enter_exam_endpoint': reverse('edx_proctoring:proctored_exam.attempt.collection'),
|
|
2650
|
+
'change_state_url': reverse(
|
|
2651
|
+
'edx_proctoring:proctored_exam.attempt',
|
|
2652
|
+
args=[attempt['id']]
|
|
2653
|
+
) if attempt else '',
|
|
2654
|
+
'course_id': course_id,
|
|
2655
|
+
})
|
|
2656
|
+
return template.render(context)
|
|
2657
|
+
|
|
2658
|
+
|
|
2659
|
+
def _calculate_allowed_mins(exam, user_id):
|
|
2660
|
+
"""
|
|
2661
|
+
Returns the allowed minutes w.r.t due date
|
|
2662
|
+
"""
|
|
2663
|
+
due_datetime = get_exam_due_date(exam, user_id)
|
|
2664
|
+
allowed_time_limit_mins = exam['time_limit_mins']
|
|
2665
|
+
|
|
2666
|
+
current_attempt = get_current_exam_attempt(exam['id'], user_id)
|
|
2667
|
+
if current_attempt and current_attempt['time_remaining_seconds']:
|
|
2668
|
+
# if resuming from a previous attempt, use the time remaining
|
|
2669
|
+
allowed_time_limit_mins = int(current_attempt['time_remaining_seconds'] / 60)
|
|
2670
|
+
else:
|
|
2671
|
+
# add in the allowed additional time
|
|
2672
|
+
allowance_extra_mins = ProctoredExamStudentAllowance.get_additional_time_granted(exam.get('id'), user_id)
|
|
2673
|
+
if allowance_extra_mins:
|
|
2674
|
+
allowed_time_limit_mins += allowance_extra_mins
|
|
2675
|
+
|
|
2676
|
+
if due_datetime:
|
|
2677
|
+
current_datetime = datetime.now(pytz.UTC)
|
|
2678
|
+
if current_datetime + timedelta(minutes=allowed_time_limit_mins) > due_datetime:
|
|
2679
|
+
# e.g current_datetime=09:00, due_datetime=10:00 and allowed_mins=120(2hours)
|
|
2680
|
+
# then allowed_mins should be 60(1hour)
|
|
2681
|
+
allowed_time_limit_mins = max(int((due_datetime - current_datetime).total_seconds() / 60), 0)
|
|
2682
|
+
|
|
2683
|
+
return allowed_time_limit_mins
|
|
2684
|
+
|
|
2685
|
+
|
|
2686
|
+
def _get_proctored_exam_context(exam, attempt, user_id, course_id, is_practice_exam=False):
|
|
2687
|
+
"""
|
|
2688
|
+
Common context variables for the Proctored and Practice exams' templates.
|
|
2689
|
+
"""
|
|
2690
|
+
password_url = ''
|
|
2691
|
+
try:
|
|
2692
|
+
password_assistance_url = reverse('password_assistance')
|
|
2693
|
+
scheme = 'https' if getattr(settings, 'HTTPS', 'on') == 'on' else 'http'
|
|
2694
|
+
password_url = f'{scheme}://{constants.SITE_NAME}{password_assistance_url}'
|
|
2695
|
+
except NoReverseMatch:
|
|
2696
|
+
log.exception("Can't find password reset link")
|
|
2697
|
+
|
|
2698
|
+
has_due_date = exam['due_date'] is not None
|
|
2699
|
+
attempt_time = attempt.get('allowed_time_limit_mins', None) if attempt else None
|
|
2700
|
+
|
|
2701
|
+
# if there is no attempt or an attempt with no time limit attribute, calculate the allowed time
|
|
2702
|
+
# also, if the attempt is in the ready to resume status, calculate the allowed time to correctly
|
|
2703
|
+
# display the time remaining
|
|
2704
|
+
if not attempt_time or (attempt and is_attempt_ready_to_resume(attempt)):
|
|
2705
|
+
attempt_time = _calculate_allowed_mins(exam, user_id)
|
|
2706
|
+
|
|
2707
|
+
total_time = humanized_time(attempt_time)
|
|
2708
|
+
progress_page_url = ''
|
|
2709
|
+
try:
|
|
2710
|
+
progress_page_url = reverse('progress', args=[course_id])
|
|
2711
|
+
except NoReverseMatch:
|
|
2712
|
+
log.exception(
|
|
2713
|
+
"Can't find progress url for course_id=%(course_id)s",
|
|
2714
|
+
{
|
|
2715
|
+
'course_id': course_id,
|
|
2716
|
+
}
|
|
2717
|
+
)
|
|
2718
|
+
|
|
2719
|
+
provider = get_backend_provider(exam)
|
|
2720
|
+
|
|
2721
|
+
context = {
|
|
2722
|
+
'platform_name': settings.PLATFORM_NAME,
|
|
2723
|
+
'total_time': total_time,
|
|
2724
|
+
'exam_id': exam['id'],
|
|
2725
|
+
'progress_page_url': progress_page_url,
|
|
2726
|
+
'is_sample_attempt': is_practice_exam,
|
|
2727
|
+
'has_due_date': has_due_date,
|
|
2728
|
+
'has_due_date_passed': is_exam_passed_due(exam, user=user_id),
|
|
2729
|
+
'able_to_reenter_exam': _does_time_remain(attempt) and not provider.should_block_access_to_exam_material(),
|
|
2730
|
+
'enter_exam_endpoint': reverse('edx_proctoring:proctored_exam.attempt.collection'),
|
|
2731
|
+
'exam_started_poll_url': reverse(
|
|
2732
|
+
'edx_proctoring:proctored_exam.attempt',
|
|
2733
|
+
args=[attempt['id']]
|
|
2734
|
+
) if attempt else '',
|
|
2735
|
+
'change_state_url': reverse(
|
|
2736
|
+
'edx_proctoring:proctored_exam.attempt',
|
|
2737
|
+
args=[attempt['id']]
|
|
2738
|
+
) if attempt else '',
|
|
2739
|
+
'update_is_status_acknowledge_url': reverse(
|
|
2740
|
+
'edx_proctoring:proctored_exam.attempt.review_status',
|
|
2741
|
+
args=[attempt['id']]
|
|
2742
|
+
) if attempt else '',
|
|
2743
|
+
'link_urls': settings.PROCTORING_SETTINGS.get('LINK_URLS', {}),
|
|
2744
|
+
'tech_support_email': settings.TECH_SUPPORT_EMAIL,
|
|
2745
|
+
'proctoring_escalation_email': _get_proctoring_escalation_email(exam['course_id']),
|
|
2746
|
+
'exam_review_policy': _get_review_policy_by_exam_id(exam['id']),
|
|
2747
|
+
'backend_js_bundle': provider.get_javascript(),
|
|
2748
|
+
'provider_tech_support_email': provider.tech_support_email,
|
|
2749
|
+
'provider_tech_support_phone': provider.tech_support_phone,
|
|
2750
|
+
'provider_name': provider.verbose_name,
|
|
2751
|
+
'learner_notification_from_email': provider.learner_notification_from_email,
|
|
2752
|
+
'integration_specific_email': get_integration_specific_email(provider),
|
|
2753
|
+
'exam_display_name': exam['exam_name'],
|
|
2754
|
+
'reset_link': password_url,
|
|
2755
|
+
'ping_interval': provider.ping_interval,
|
|
2756
|
+
'can_view_content_past_due': constants.CONTENT_VIEWABLE_PAST_DUE_DATE,
|
|
2757
|
+
}
|
|
2758
|
+
if attempt:
|
|
2759
|
+
context['exam_code'] = attempt['attempt_code']
|
|
2760
|
+
if attempt['status'] in (ProctoredExamStudentAttemptStatus.created,
|
|
2761
|
+
ProctoredExamStudentAttemptStatus.download_software_clicked):
|
|
2762
|
+
# since this may make an http request, let's not include it on every page
|
|
2763
|
+
provider_attempt = provider.get_attempt(attempt)
|
|
2764
|
+
download_url = provider_attempt.get('download_url', None) or provider.get_software_download_url()
|
|
2765
|
+
|
|
2766
|
+
context.update({
|
|
2767
|
+
'backend_instructions': provider_attempt.get('instructions', None),
|
|
2768
|
+
'software_download_url': download_url,
|
|
2769
|
+
})
|
|
2770
|
+
return context
|
|
2771
|
+
|
|
2772
|
+
|
|
2773
|
+
# pylint: disable=inconsistent-return-statements
|
|
2774
|
+
def _get_practice_exam_view(exam, context, exam_id, user_id, course_id):
|
|
2775
|
+
"""
|
|
2776
|
+
Returns a rendered view for the practice Exams
|
|
2777
|
+
"""
|
|
2778
|
+
user = USER_MODEL.objects.get(id=user_id)
|
|
2779
|
+
|
|
2780
|
+
if not user.has_perm('edx_proctoring.can_take_proctored_exam', exam):
|
|
2781
|
+
return None
|
|
2782
|
+
|
|
2783
|
+
student_view_template = None
|
|
2784
|
+
|
|
2785
|
+
attempt = get_current_exam_attempt(exam_id, user_id)
|
|
2786
|
+
|
|
2787
|
+
attempt_status = attempt['status'] if attempt else None
|
|
2788
|
+
|
|
2789
|
+
if not user.is_active:
|
|
2790
|
+
student_view_template = 'proctored_exam/inactive_account.html'
|
|
2791
|
+
elif not attempt_status:
|
|
2792
|
+
student_view_template = 'practice_exam/entrance.html'
|
|
2793
|
+
elif is_attempt_ready_to_resume(attempt):
|
|
2794
|
+
student_view_template = 'proctored_exam/ready_to_resume.html'
|
|
2795
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.started:
|
|
2796
|
+
provider = get_backend_provider(exam)
|
|
2797
|
+
if provider.should_block_access_to_exam_material():
|
|
2798
|
+
student_view_template = 'proctored_exam/error_wrong_browser.html'
|
|
2799
|
+
else:
|
|
2800
|
+
# when we're taking the exam we should not override the view
|
|
2801
|
+
return None
|
|
2802
|
+
elif attempt_status in [ProctoredExamStudentAttemptStatus.created,
|
|
2803
|
+
ProctoredExamStudentAttemptStatus.download_software_clicked]:
|
|
2804
|
+
student_view_template = 'proctored_exam/instructions.html'
|
|
2805
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.ready_to_start:
|
|
2806
|
+
student_view_template = 'proctored_exam/ready_to_start.html'
|
|
2807
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.error:
|
|
2808
|
+
student_view_template = 'practice_exam/error.html'
|
|
2809
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.submitted:
|
|
2810
|
+
student_view_template = 'practice_exam/submitted.html'
|
|
2811
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.ready_to_submit:
|
|
2812
|
+
student_view_template = 'proctored_exam/ready_to_submit.html'
|
|
2813
|
+
|
|
2814
|
+
if student_view_template:
|
|
2815
|
+
template = loader.get_template(student_view_template)
|
|
2816
|
+
context.update(_get_proctored_exam_context(exam, attempt, user_id, course_id, is_practice_exam=True))
|
|
2817
|
+
return template.render(context)
|
|
2818
|
+
|
|
2819
|
+
|
|
2820
|
+
# pylint: disable=inconsistent-return-statements
|
|
2821
|
+
def _get_onboarding_exam_view(exam, context, exam_id, user_id, course_id):
|
|
2822
|
+
"""
|
|
2823
|
+
Returns a rendered view for onboarding exams, which for some backends establish a user's profile
|
|
2824
|
+
"""
|
|
2825
|
+
user = USER_MODEL.objects.get(id=user_id)
|
|
2826
|
+
|
|
2827
|
+
if not user.has_perm('edx_proctoring.can_take_proctored_exam', exam):
|
|
2828
|
+
return None
|
|
2829
|
+
|
|
2830
|
+
student_view_template = None
|
|
2831
|
+
|
|
2832
|
+
attempt = get_current_exam_attempt(exam_id, user_id)
|
|
2833
|
+
|
|
2834
|
+
attempt_status = attempt['status'] if attempt else None
|
|
2835
|
+
|
|
2836
|
+
if not user.is_active:
|
|
2837
|
+
student_view_template = 'proctored_exam/inactive_account.html'
|
|
2838
|
+
elif not attempt_status:
|
|
2839
|
+
# if exam due date has passed, then we can't take the exam
|
|
2840
|
+
if is_exam_passed_due(exam, user_id):
|
|
2841
|
+
student_view_template = 'proctored_exam/expired.html'
|
|
2842
|
+
else:
|
|
2843
|
+
student_view_template = 'onboarding_exam/entrance.html'
|
|
2844
|
+
elif is_attempt_ready_to_resume(attempt):
|
|
2845
|
+
student_view_template = 'proctored_exam/ready_to_resume.html'
|
|
2846
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.started:
|
|
2847
|
+
# when we're taking the exam we should not override the view
|
|
2848
|
+
return None
|
|
2849
|
+
elif attempt_status in [ProctoredExamStudentAttemptStatus.created,
|
|
2850
|
+
ProctoredExamStudentAttemptStatus.download_software_clicked]:
|
|
2851
|
+
student_view_template = 'proctored_exam/instructions.html'
|
|
2852
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.ready_to_start:
|
|
2853
|
+
student_view_template = 'proctored_exam/ready_to_start.html'
|
|
2854
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.error:
|
|
2855
|
+
student_view_template = 'onboarding_exam/error.html'
|
|
2856
|
+
elif attempt_status in [ProctoredExamStudentAttemptStatus.submitted,
|
|
2857
|
+
ProctoredExamStudentAttemptStatus.second_review_required]:
|
|
2858
|
+
student_view_template = 'onboarding_exam/submitted.html'
|
|
2859
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.ready_to_submit:
|
|
2860
|
+
student_view_template = 'onboarding_exam/ready_to_submit.html'
|
|
2861
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.verified:
|
|
2862
|
+
student_view_template = 'onboarding_exam/verified.html'
|
|
2863
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.rejected:
|
|
2864
|
+
student_view_template = 'onboarding_exam/rejected.html'
|
|
2865
|
+
|
|
2866
|
+
if student_view_template:
|
|
2867
|
+
template = loader.get_template(student_view_template)
|
|
2868
|
+
context.update(_get_proctored_exam_context(exam, attempt, user_id, course_id, is_practice_exam=True))
|
|
2869
|
+
return template.render(context)
|
|
2870
|
+
|
|
2871
|
+
|
|
2872
|
+
# pylint: disable=inconsistent-return-statements
|
|
2873
|
+
def _get_proctored_exam_view(exam, context, exam_id, user_id, course_id):
|
|
2874
|
+
"""
|
|
2875
|
+
Returns a rendered view for the Proctored Exams
|
|
2876
|
+
"""
|
|
2877
|
+
student_view_template = None
|
|
2878
|
+
|
|
2879
|
+
user = USER_MODEL.objects.get(id=user_id)
|
|
2880
|
+
|
|
2881
|
+
if not user.has_perm('edx_proctoring.can_take_proctored_exam', exam):
|
|
2882
|
+
return None
|
|
2883
|
+
|
|
2884
|
+
attempt = get_current_exam_attempt(exam_id, user_id)
|
|
2885
|
+
|
|
2886
|
+
attempt_status = attempt['status'] if attempt else None
|
|
2887
|
+
|
|
2888
|
+
# if user has declined the attempt, then we don't show the
|
|
2889
|
+
# proctored exam, a quick exit....
|
|
2890
|
+
if attempt_status == ProctoredExamStudentAttemptStatus.declined:
|
|
2891
|
+
return None
|
|
2892
|
+
|
|
2893
|
+
if not user.is_active:
|
|
2894
|
+
student_view_template = 'proctored_exam/inactive_account.html'
|
|
2895
|
+
elif not attempt_status:
|
|
2896
|
+
# student has not started an attempt
|
|
2897
|
+
# so, show them:
|
|
2898
|
+
# 1) If there are failed prerequisites then block user and say why
|
|
2899
|
+
# 2) If there are pending prerequisites then block user and allow them to remediate them
|
|
2900
|
+
# 3) If there are declined prerequisites, then we auto-decline proctoring since user
|
|
2901
|
+
# explicitly declined their interest in credit
|
|
2902
|
+
# 4) Otherwise - all prerequisites are satisfied - then give user
|
|
2903
|
+
# option to take exam as proctored
|
|
2904
|
+
|
|
2905
|
+
# get information about prerequisites
|
|
2906
|
+
|
|
2907
|
+
credit_requirement_status = context.get('credit_state', {}).get('credit_requirement_status', [])
|
|
2908
|
+
|
|
2909
|
+
prerequisite_status = _are_prerequirements_satisfied(
|
|
2910
|
+
credit_requirement_status,
|
|
2911
|
+
evaluate_for_requirement_name=exam['content_id'],
|
|
2912
|
+
filter_out_namespaces=['grade']
|
|
2913
|
+
)
|
|
2914
|
+
|
|
2915
|
+
# add any prerequisite information, if applicable
|
|
2916
|
+
context.update({
|
|
2917
|
+
'prerequisite_status': prerequisite_status
|
|
2918
|
+
})
|
|
2919
|
+
|
|
2920
|
+
# if exam due date has passed, then we can't take the exam
|
|
2921
|
+
if is_exam_passed_due(exam, user_id):
|
|
2922
|
+
student_view_template = 'proctored_exam/expired.html'
|
|
2923
|
+
elif not prerequisite_status['are_prerequisites_satisifed']:
|
|
2924
|
+
# do we have any declined prerequisites, if so, then we
|
|
2925
|
+
# will auto-decline this proctored exam
|
|
2926
|
+
if prerequisite_status['declined_prerequisites']:
|
|
2927
|
+
# user hasn't a record of attempt, create one now
|
|
2928
|
+
# so we can mark it as declined
|
|
2929
|
+
_create_and_decline_attempt(exam_id, user_id)
|
|
2930
|
+
return None
|
|
2931
|
+
|
|
2932
|
+
# do we have failed prerequisites? That takes priority in terms of
|
|
2933
|
+
# messaging
|
|
2934
|
+
if prerequisite_status['failed_prerequisites']:
|
|
2935
|
+
# Let's resolve the URLs to jump to this prequisite
|
|
2936
|
+
prerequisite_status['failed_prerequisites'] = _resolve_prerequisite_links(
|
|
2937
|
+
exam,
|
|
2938
|
+
prerequisite_status['failed_prerequisites']
|
|
2939
|
+
)
|
|
2940
|
+
student_view_template = 'proctored_exam/failed-prerequisites.html'
|
|
2941
|
+
else:
|
|
2942
|
+
# Let's resolve the URLs to jump to this prequisite
|
|
2943
|
+
prerequisite_status['pending_prerequisites'] = _resolve_prerequisite_links(
|
|
2944
|
+
exam,
|
|
2945
|
+
prerequisite_status['pending_prerequisites']
|
|
2946
|
+
)
|
|
2947
|
+
student_view_template = 'proctored_exam/pending-prerequisites.html'
|
|
2948
|
+
else:
|
|
2949
|
+
student_view_template = 'proctored_exam/entrance.html'
|
|
2950
|
+
# emit an event that the user was presented with the option
|
|
2951
|
+
# to start timed exam
|
|
2952
|
+
emit_event(exam, 'option-presented')
|
|
2953
|
+
elif is_exam_passed_due(exam, user_id) and ProctoredExamStudentAttemptStatus.is_incomplete_status(attempt_status):
|
|
2954
|
+
# When the exam is past due, we should prevent learners from accessing the exam even if
|
|
2955
|
+
# they already accessed the exam before, but haven't completed.
|
|
2956
|
+
student_view_template = 'proctored_exam/expired.html'
|
|
2957
|
+
elif is_attempt_ready_to_resume(attempt):
|
|
2958
|
+
student_view_template = 'proctored_exam/ready_to_resume.html'
|
|
2959
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.started:
|
|
2960
|
+
provider = get_backend_provider(exam)
|
|
2961
|
+
if provider.should_block_access_to_exam_material():
|
|
2962
|
+
student_view_template = 'proctored_exam/error_wrong_browser.html'
|
|
2963
|
+
else:
|
|
2964
|
+
# when we're taking the exam we should not override the view
|
|
2965
|
+
return None
|
|
2966
|
+
elif attempt_status in [ProctoredExamStudentAttemptStatus.created,
|
|
2967
|
+
ProctoredExamStudentAttemptStatus.download_software_clicked]:
|
|
2968
|
+
student_view_template = 'proctored_exam/instructions.html'
|
|
2969
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.ready_to_start:
|
|
2970
|
+
student_view_template = 'proctored_exam/ready_to_start.html'
|
|
2971
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.error:
|
|
2972
|
+
student_view_template = 'proctored_exam/error.html'
|
|
2973
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.timed_out:
|
|
2974
|
+
raise NotImplementedError('There is no defined rendering for ProctoredExamStudentAttemptStatus.timed_out!')
|
|
2975
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.submitted:
|
|
2976
|
+
student_view_template = None if (_was_review_status_acknowledged(
|
|
2977
|
+
attempt['is_status_acknowledged'],
|
|
2978
|
+
exam
|
|
2979
|
+
) and constants.CONTENT_VIEWABLE_PAST_DUE_DATE) else 'proctored_exam/submitted.html'
|
|
2980
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.second_review_required:
|
|
2981
|
+
# the student should still see a 'submitted'
|
|
2982
|
+
# rendering even if the review needs a 2nd review
|
|
2983
|
+
student_view_template = None if (_was_review_status_acknowledged(
|
|
2984
|
+
attempt['is_status_acknowledged'],
|
|
2985
|
+
exam
|
|
2986
|
+
) and constants.CONTENT_VIEWABLE_PAST_DUE_DATE) else 'proctored_exam/submitted.html'
|
|
2987
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.verified:
|
|
2988
|
+
student_view_template = None if (_was_review_status_acknowledged(
|
|
2989
|
+
attempt['is_status_acknowledged'],
|
|
2990
|
+
exam
|
|
2991
|
+
) and constants.CONTENT_VIEWABLE_PAST_DUE_DATE) else 'proctored_exam/verified.html'
|
|
2992
|
+
has_context_updated = verify_and_add_wait_deadline(context, exam, user_id)
|
|
2993
|
+
# The edge case where student has already acknowledged the result
|
|
2994
|
+
# but the course team changed the grace period
|
|
2995
|
+
if has_context_updated and not student_view_template:
|
|
2996
|
+
student_view_template = 'proctored_exam/verified.html'
|
|
2997
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.rejected:
|
|
2998
|
+
student_view_template = None if (_was_review_status_acknowledged(
|
|
2999
|
+
attempt['is_status_acknowledged'],
|
|
3000
|
+
exam
|
|
3001
|
+
) and constants.CONTENT_VIEWABLE_PAST_DUE_DATE) else 'proctored_exam/rejected.html'
|
|
3002
|
+
elif attempt_status == ProctoredExamStudentAttemptStatus.ready_to_submit:
|
|
3003
|
+
student_view_template = 'proctored_exam/ready_to_submit.html'
|
|
3004
|
+
elif attempt_status in ProctoredExamStudentAttemptStatus.onboarding_errors:
|
|
3005
|
+
student_view_template = 'proctored_exam/onboarding_error.html'
|
|
3006
|
+
context['onboarding_status'] = attempt['status']
|
|
3007
|
+
context['support_email_subject'] = _('Onboarding status question')
|
|
3008
|
+
onboarding_exam = ProctoredExam.objects.filter(course_id=course_id,
|
|
3009
|
+
is_active=True,
|
|
3010
|
+
is_practice_exam=True).first()
|
|
3011
|
+
context['onboarding_link'] = reverse('jump_to', args=[course_id, onboarding_exam.content_id])
|
|
3012
|
+
|
|
3013
|
+
if student_view_template:
|
|
3014
|
+
template = loader.get_template(student_view_template)
|
|
3015
|
+
context.update(_get_proctored_exam_context(exam, attempt, user_id, course_id))
|
|
3016
|
+
return template.render(context)
|
|
3017
|
+
|
|
3018
|
+
|
|
3019
|
+
def get_student_view(user_id, course_id, content_id,
|
|
3020
|
+
context, user_role='student'):
|
|
3021
|
+
"""
|
|
3022
|
+
Helper method that will return the view HTML related to the exam control
|
|
3023
|
+
flow (i.e. entering, expired, completed, etc.) If there is no specific
|
|
3024
|
+
content to display, then None will be returned and the caller should
|
|
3025
|
+
render it's own view
|
|
3026
|
+
"""
|
|
3027
|
+
|
|
3028
|
+
# non-student roles should never see any proctoring related
|
|
3029
|
+
# screens
|
|
3030
|
+
if user_role != 'student':
|
|
3031
|
+
return None
|
|
3032
|
+
|
|
3033
|
+
course_end_date = context.get('credit_state', {}).get('course_end_date', None)
|
|
3034
|
+
|
|
3035
|
+
exam_id = None
|
|
3036
|
+
try:
|
|
3037
|
+
exam = get_exam_by_content_id(course_id, content_id)
|
|
3038
|
+
if not exam['is_active']:
|
|
3039
|
+
# Exam is no longer active
|
|
3040
|
+
# Note, we don't hard delete exams since we need to retain
|
|
3041
|
+
# data
|
|
3042
|
+
return None
|
|
3043
|
+
|
|
3044
|
+
# Just in case the due date has been changed because of the
|
|
3045
|
+
# self-paced courses, use the due date from the context
|
|
3046
|
+
if exam['due_date'] != context.get('due_date', None):
|
|
3047
|
+
exam['due_date'] = context.get('due_date', None)
|
|
3048
|
+
|
|
3049
|
+
exam_id = exam['id']
|
|
3050
|
+
except ProctoredExamNotFoundException:
|
|
3051
|
+
# This really shouldn't happen
|
|
3052
|
+
# as Studio will be setting this up
|
|
3053
|
+
exam_id = create_exam(
|
|
3054
|
+
course_id=course_id,
|
|
3055
|
+
content_id=str(content_id),
|
|
3056
|
+
exam_name=context['display_name'],
|
|
3057
|
+
time_limit_mins=context['default_time_limit_mins'],
|
|
3058
|
+
is_proctored=context.get('is_proctored', False),
|
|
3059
|
+
is_practice_exam=context.get('is_practice_exam', False),
|
|
3060
|
+
due_date=context.get('due_date', None),
|
|
3061
|
+
hide_after_due=context.get('hide_after_due', False),
|
|
3062
|
+
)
|
|
3063
|
+
exam = get_exam_by_content_id(course_id, content_id)
|
|
3064
|
+
|
|
3065
|
+
is_practice_exam = exam['is_proctored'] and exam['is_practice_exam']
|
|
3066
|
+
is_proctored_exam = exam['is_proctored'] and not exam['is_practice_exam']
|
|
3067
|
+
is_timed_exam = not exam['is_proctored'] and not exam['is_practice_exam']
|
|
3068
|
+
|
|
3069
|
+
exam_backend = get_backend_provider(exam=exam)
|
|
3070
|
+
|
|
3071
|
+
sub_view_func = None
|
|
3072
|
+
if is_timed_exam:
|
|
3073
|
+
sub_view_func = _get_timed_exam_view
|
|
3074
|
+
elif is_practice_exam and not has_due_date_passed(course_end_date):
|
|
3075
|
+
if exam_backend.supports_onboarding:
|
|
3076
|
+
sub_view_func = _get_onboarding_exam_view
|
|
3077
|
+
else:
|
|
3078
|
+
sub_view_func = _get_practice_exam_view
|
|
3079
|
+
elif is_proctored_exam and not has_due_date_passed(course_end_date):
|
|
3080
|
+
sub_view_func = _get_proctored_exam_view
|
|
3081
|
+
|
|
3082
|
+
if sub_view_func:
|
|
3083
|
+
return sub_view_func(exam, context, exam_id, user_id, course_id)
|
|
3084
|
+
return None
|
|
3085
|
+
|
|
3086
|
+
|
|
3087
|
+
def get_exam_violation_report(course_id, include_practice_exams=False):
|
|
3088
|
+
"""
|
|
3089
|
+
Returns proctored exam attempts for the course id, including review details.
|
|
3090
|
+
Violation status messages are aggregated as a list per attempt for each
|
|
3091
|
+
violation type.
|
|
3092
|
+
"""
|
|
3093
|
+
|
|
3094
|
+
attempts_by_code = {
|
|
3095
|
+
attempt['attempt_code']: {
|
|
3096
|
+
'course_id': attempt['proctored_exam']['course_id'],
|
|
3097
|
+
'exam_name': attempt['proctored_exam']['exam_name'],
|
|
3098
|
+
'username': attempt['user']['username'],
|
|
3099
|
+
'email': attempt['user']['email'],
|
|
3100
|
+
'attempt_code': attempt['attempt_code'],
|
|
3101
|
+
'allowed_time_limit_mins': attempt['allowed_time_limit_mins'],
|
|
3102
|
+
'is_sample_attempt': attempt['is_sample_attempt'],
|
|
3103
|
+
'started_at': attempt['started_at'],
|
|
3104
|
+
'completed_at': attempt['completed_at'],
|
|
3105
|
+
'status': attempt['status'],
|
|
3106
|
+
'review_status': None,
|
|
3107
|
+
'provider': attempt['proctored_exam']['backend'],
|
|
3108
|
+
'user_id': attempt['user']['id']
|
|
3109
|
+
|
|
3110
|
+
} for attempt in get_all_exam_attempts(course_id)
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
reviews = ProctoredExamSoftwareSecureReview.objects.prefetch_related(
|
|
3114
|
+
'proctoredexamsoftwaresecurecomment_set'
|
|
3115
|
+
).filter(
|
|
3116
|
+
exam__course_id=course_id,
|
|
3117
|
+
exam__is_practice_exam=include_practice_exams
|
|
3118
|
+
)
|
|
3119
|
+
|
|
3120
|
+
for review in reviews:
|
|
3121
|
+
attempt_code = review.attempt_code
|
|
3122
|
+
if attempt_code in attempts_by_code:
|
|
3123
|
+
attempts_by_code[attempt_code]['review_status'] = review.review_status
|
|
3124
|
+
|
|
3125
|
+
for comment in review.proctoredexamsoftwaresecurecomment_set.all():
|
|
3126
|
+
comments_key = f'{comment.status} Comments'
|
|
3127
|
+
|
|
3128
|
+
if comments_key not in attempts_by_code[attempt_code]:
|
|
3129
|
+
attempts_by_code[attempt_code][comments_key] = []
|
|
3130
|
+
|
|
3131
|
+
attempts_by_code[attempt_code][comments_key].append(comment.comment)
|
|
3132
|
+
|
|
3133
|
+
return sorted(list(attempts_by_code.values()), key=lambda a: a['exam_name'])
|
|
3134
|
+
|
|
3135
|
+
|
|
3136
|
+
def is_backend_dashboard_available(course_id):
|
|
3137
|
+
"""
|
|
3138
|
+
Returns whether the backend for this course supports the instructor dashboard feature
|
|
3139
|
+
"""
|
|
3140
|
+
exams = ProctoredExam.get_all_exams_for_course(
|
|
3141
|
+
course_id,
|
|
3142
|
+
active_only=True
|
|
3143
|
+
)
|
|
3144
|
+
for exam in exams:
|
|
3145
|
+
try:
|
|
3146
|
+
if get_backend_provider(name=exam.backend).has_dashboard:
|
|
3147
|
+
return True
|
|
3148
|
+
except NotImplementedError:
|
|
3149
|
+
log.exception(
|
|
3150
|
+
'No proctoring backend configured for backend=%(backend)s',
|
|
3151
|
+
{
|
|
3152
|
+
'backend': exam.backend,
|
|
3153
|
+
}
|
|
3154
|
+
)
|
|
3155
|
+
|
|
3156
|
+
return False
|
|
3157
|
+
|
|
3158
|
+
|
|
3159
|
+
def does_backend_support_onboarding(backend):
|
|
3160
|
+
"""
|
|
3161
|
+
Returns whether this backend supports onboarding exams.
|
|
3162
|
+
"""
|
|
3163
|
+
try:
|
|
3164
|
+
return get_backend_provider(name=backend).supports_onboarding
|
|
3165
|
+
except NotImplementedError:
|
|
3166
|
+
log.exception(
|
|
3167
|
+
'No proctoring backend configured for backend=%(backend)s',
|
|
3168
|
+
{
|
|
3169
|
+
'backend': backend,
|
|
3170
|
+
}
|
|
3171
|
+
)
|
|
3172
|
+
return False
|
|
3173
|
+
|
|
3174
|
+
|
|
3175
|
+
def get_exam_configuration_dashboard_url(course_id, content_id):
|
|
3176
|
+
"""
|
|
3177
|
+
Returns the exam configuration dashboard URL, if the exam exists and the backend
|
|
3178
|
+
has an exam configuration dashboard. Otherwise, returns None.
|
|
3179
|
+
"""
|
|
3180
|
+
try:
|
|
3181
|
+
exam = get_exam_by_content_id(course_id, content_id)
|
|
3182
|
+
except ProctoredExamNotFoundException:
|
|
3183
|
+
log.exception(
|
|
3184
|
+
('Cannot find the proctored exam in course_id=%(course_id)s with '
|
|
3185
|
+
'content_id=%(content_id)s'),
|
|
3186
|
+
{
|
|
3187
|
+
'course_id': course_id,
|
|
3188
|
+
'content_id': content_id,
|
|
3189
|
+
}
|
|
3190
|
+
)
|
|
3191
|
+
return None
|
|
3192
|
+
|
|
3193
|
+
if is_backend_dashboard_available(course_id):
|
|
3194
|
+
base_url = reverse(
|
|
3195
|
+
'edx_proctoring:instructor_dashboard_exam', args=(course_id, exam['id'])
|
|
3196
|
+
)
|
|
3197
|
+
return f'{base_url}?config=true'
|
|
3198
|
+
|
|
3199
|
+
return None
|
|
3200
|
+
|
|
3201
|
+
|
|
3202
|
+
def get_integration_specific_email(provider):
|
|
3203
|
+
"""
|
|
3204
|
+
Return the edX contact email to use for a particular provider.
|
|
3205
|
+
"""
|
|
3206
|
+
return getattr(provider, 'integration_specific_email', None) or constants.DEFAULT_CONTACT_EMAIL
|
|
3207
|
+
|
|
3208
|
+
|
|
3209
|
+
def get_enrollments_can_take_proctored_exams(course_id, text_search=None):
|
|
3210
|
+
"""
|
|
3211
|
+
Return all enrollments for a course from the LMS Enrollments runtime API.
|
|
3212
|
+
|
|
3213
|
+
Parameters:
|
|
3214
|
+
* course_id: course ID for the course
|
|
3215
|
+
* text_search: the string against which to do a match on users' username or email; optional
|
|
3216
|
+
"""
|
|
3217
|
+
enrollments_service = get_runtime_service('enrollments')
|
|
3218
|
+
return enrollments_service.get_enrollments_can_take_proctored_exams(course_id, text_search)
|
|
3219
|
+
|
|
3220
|
+
|
|
3221
|
+
def get_onboarding_attempt_data_for_learner(course_id, user, backend):
|
|
3222
|
+
"""
|
|
3223
|
+
Return the onboarding status and expiration date for a learner in a specific course. If no
|
|
3224
|
+
onboarding status is associated with the course, check to see if the
|
|
3225
|
+
learner is approved for onboarding in another course
|
|
3226
|
+
|
|
3227
|
+
Parameters:
|
|
3228
|
+
* course_id: course ID for the course
|
|
3229
|
+
* user: User object
|
|
3230
|
+
* backend: proctoring provider to filter exams on
|
|
3231
|
+
"""
|
|
3232
|
+
attempts = ProctoredExamStudentAttempt.objects.get_proctored_practice_attempts_by_course_id(course_id, [user])
|
|
3233
|
+
|
|
3234
|
+
# Default to the most recent attempt in the course if there are no verified attempts
|
|
3235
|
+
relevant_attempt = attempts[0] if attempts else None
|
|
3236
|
+
for attempt in attempts:
|
|
3237
|
+
if attempt.status == ProctoredExamStudentAttemptStatus.verified:
|
|
3238
|
+
relevant_attempt = attempt
|
|
3239
|
+
break
|
|
3240
|
+
|
|
3241
|
+
if relevant_attempt and relevant_attempt.status == ProctoredExamStudentAttemptStatus.verified:
|
|
3242
|
+
return {
|
|
3243
|
+
'onboarding_status': relevant_attempt.status,
|
|
3244
|
+
'expiration_date': relevant_attempt.modified + timedelta(days=constants.VERIFICATION_DAYS_VALID),
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
# otherwise look for another course's verified attempt
|
|
3248
|
+
attempt_dict = get_last_verified_onboarding_attempts_per_user(
|
|
3249
|
+
[user],
|
|
3250
|
+
backend,
|
|
3251
|
+
)
|
|
3252
|
+
verified_attempt = attempt_dict.get(user.id)
|
|
3253
|
+
|
|
3254
|
+
if verified_attempt:
|
|
3255
|
+
return {
|
|
3256
|
+
'onboarding_status': InstructorDashboardOnboardingAttemptStatus.other_course_approved,
|
|
3257
|
+
'expiration_date': verified_attempt.modified + timedelta(days=constants.VERIFICATION_DAYS_VALID),
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
if relevant_attempt:
|
|
3261
|
+
return {
|
|
3262
|
+
'onboarding_status': relevant_attempt.status,
|
|
3263
|
+
'expiration_date': None,
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3266
|
+
return {
|
|
3267
|
+
'onboarding_status': None,
|
|
3268
|
+
'expiration_date': None,
|
|
3269
|
+
}
|