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.
Files changed (293) hide show
  1. edx_proctoring/__init__.py +6 -0
  2. edx_proctoring/admin.py +502 -0
  3. edx_proctoring/api.py +3269 -0
  4. edx_proctoring/apps.py +162 -0
  5. edx_proctoring/backends/LICENSE.txt +202 -0
  6. edx_proctoring/backends/README.txt +1 -0
  7. edx_proctoring/backends/__init__.py +20 -0
  8. edx_proctoring/backends/backend.py +162 -0
  9. edx_proctoring/backends/mock.py +105 -0
  10. edx_proctoring/backends/null.py +61 -0
  11. edx_proctoring/backends/rest.py +422 -0
  12. edx_proctoring/backends/software_secure.py +423 -0
  13. edx_proctoring/backends/tests/__init__.py +3 -0
  14. edx_proctoring/backends/tests/test_backend.py +363 -0
  15. edx_proctoring/backends/tests/test_rest.py +624 -0
  16. edx_proctoring/backends/tests/test_review_payload.py +102 -0
  17. edx_proctoring/backends/tests/test_software_secure.py +628 -0
  18. edx_proctoring/callbacks.py +66 -0
  19. edx_proctoring/constants.py +83 -0
  20. edx_proctoring/decorators.py +3 -0
  21. edx_proctoring/exceptions.py +188 -0
  22. edx_proctoring/handlers.py +229 -0
  23. edx_proctoring/instructor_dashboard_exam_urls.py +19 -0
  24. edx_proctoring/locale/ar/LC_MESSAGES/django.mo +0 -0
  25. edx_proctoring/locale/ar/LC_MESSAGES/django.po +1445 -0
  26. edx_proctoring/locale/ar/LC_MESSAGES/djangojs.mo +0 -0
  27. edx_proctoring/locale/ar/LC_MESSAGES/djangojs.po +1443 -0
  28. edx_proctoring/locale/ca/LC_MESSAGES/django.mo +0 -0
  29. edx_proctoring/locale/ca/LC_MESSAGES/django.po +1464 -0
  30. edx_proctoring/locale/ca/LC_MESSAGES/djangojs.mo +0 -0
  31. edx_proctoring/locale/ca/LC_MESSAGES/djangojs.po +1463 -0
  32. edx_proctoring/locale/config.yaml +73 -0
  33. edx_proctoring/locale/cs/LC_MESSAGES/django.mo +0 -0
  34. edx_proctoring/locale/cs/LC_MESSAGES/django.po +1632 -0
  35. edx_proctoring/locale/cs/LC_MESSAGES/djangojs.mo +0 -0
  36. edx_proctoring/locale/cs/LC_MESSAGES/djangojs.po +1634 -0
  37. edx_proctoring/locale/de_DE/LC_MESSAGES/django.mo +0 -0
  38. edx_proctoring/locale/de_DE/LC_MESSAGES/django.po +1461 -0
  39. edx_proctoring/locale/de_DE/LC_MESSAGES/djangojs.mo +0 -0
  40. edx_proctoring/locale/de_DE/LC_MESSAGES/djangojs.po +1461 -0
  41. edx_proctoring/locale/el/LC_MESSAGES/django.mo +0 -0
  42. edx_proctoring/locale/el/LC_MESSAGES/django.po +1389 -0
  43. edx_proctoring/locale/el/LC_MESSAGES/djangojs.mo +0 -0
  44. edx_proctoring/locale/el/LC_MESSAGES/djangojs.po +1387 -0
  45. edx_proctoring/locale/en/LC_MESSAGES/django.mo +0 -0
  46. edx_proctoring/locale/en/LC_MESSAGES/django.po +1476 -0
  47. edx_proctoring/locale/en/LC_MESSAGES/djangojs.mo +0 -0
  48. edx_proctoring/locale/en/LC_MESSAGES/djangojs.po +264 -0
  49. edx_proctoring/locale/eo/LC_MESSAGES/django.mo +0 -0
  50. edx_proctoring/locale/eo/LC_MESSAGES/django.po +1880 -0
  51. edx_proctoring/locale/eo/LC_MESSAGES/djangojs.mo +0 -0
  52. edx_proctoring/locale/eo/LC_MESSAGES/djangojs.po +304 -0
  53. edx_proctoring/locale/es_419/LC_MESSAGES/django.mo +0 -0
  54. edx_proctoring/locale/es_419/LC_MESSAGES/django.po +1783 -0
  55. edx_proctoring/locale/es_419/LC_MESSAGES/djangojs.mo +0 -0
  56. edx_proctoring/locale/es_419/LC_MESSAGES/djangojs.po +1785 -0
  57. edx_proctoring/locale/es_AR/LC_MESSAGES/django.mo +0 -0
  58. edx_proctoring/locale/es_AR/LC_MESSAGES/django.po +1654 -0
  59. edx_proctoring/locale/es_AR/LC_MESSAGES/djangojs.mo +0 -0
  60. edx_proctoring/locale/es_AR/LC_MESSAGES/djangojs.po +1655 -0
  61. edx_proctoring/locale/es_ES/LC_MESSAGES/django.mo +0 -0
  62. edx_proctoring/locale/es_ES/LC_MESSAGES/django.po +1655 -0
  63. edx_proctoring/locale/es_ES/LC_MESSAGES/djangojs.mo +0 -0
  64. edx_proctoring/locale/es_ES/LC_MESSAGES/djangojs.po +1652 -0
  65. edx_proctoring/locale/eu_ES/LC_MESSAGES/django.mo +0 -0
  66. edx_proctoring/locale/eu_ES/LC_MESSAGES/django.po +1378 -0
  67. edx_proctoring/locale/eu_ES/LC_MESSAGES/djangojs.mo +0 -0
  68. edx_proctoring/locale/eu_ES/LC_MESSAGES/djangojs.po +1378 -0
  69. edx_proctoring/locale/fa_IR/LC_MESSAGES/django.mo +0 -0
  70. edx_proctoring/locale/fa_IR/LC_MESSAGES/django.po +1666 -0
  71. edx_proctoring/locale/fa_IR/LC_MESSAGES/djangojs.mo +0 -0
  72. edx_proctoring/locale/fa_IR/LC_MESSAGES/djangojs.po +1665 -0
  73. edx_proctoring/locale/fr/LC_MESSAGES/django.mo +0 -0
  74. edx_proctoring/locale/fr/LC_MESSAGES/django.po +1805 -0
  75. edx_proctoring/locale/fr/LC_MESSAGES/djangojs.mo +0 -0
  76. edx_proctoring/locale/fr/LC_MESSAGES/djangojs.po +1802 -0
  77. edx_proctoring/locale/fr_CA/LC_MESSAGES/django.mo +0 -0
  78. edx_proctoring/locale/fr_CA/LC_MESSAGES/django.po +1790 -0
  79. edx_proctoring/locale/fr_CA/LC_MESSAGES/djangojs.mo +0 -0
  80. edx_proctoring/locale/fr_CA/LC_MESSAGES/djangojs.po +1793 -0
  81. edx_proctoring/locale/he/LC_MESSAGES/django.mo +0 -0
  82. edx_proctoring/locale/he/LC_MESSAGES/django.po +1444 -0
  83. edx_proctoring/locale/he/LC_MESSAGES/djangojs.mo +0 -0
  84. edx_proctoring/locale/he/LC_MESSAGES/djangojs.po +1443 -0
  85. edx_proctoring/locale/id/LC_MESSAGES/django.mo +0 -0
  86. edx_proctoring/locale/id/LC_MESSAGES/django.po +1390 -0
  87. edx_proctoring/locale/id/LC_MESSAGES/djangojs.mo +0 -0
  88. edx_proctoring/locale/id/LC_MESSAGES/djangojs.po +1389 -0
  89. edx_proctoring/locale/it_IT/LC_MESSAGES/django.mo +0 -0
  90. edx_proctoring/locale/it_IT/LC_MESSAGES/django.po +1715 -0
  91. edx_proctoring/locale/it_IT/LC_MESSAGES/djangojs.mo +0 -0
  92. edx_proctoring/locale/it_IT/LC_MESSAGES/djangojs.po +1715 -0
  93. edx_proctoring/locale/ja_JP/LC_MESSAGES/djangojs.mo +0 -0
  94. edx_proctoring/locale/ja_JP/LC_MESSAGES/djangojs.po +1387 -0
  95. edx_proctoring/locale/ka/LC_MESSAGES/django.mo +0 -0
  96. edx_proctoring/locale/ka/LC_MESSAGES/django.po +1380 -0
  97. edx_proctoring/locale/ka/LC_MESSAGES/djangojs.mo +0 -0
  98. edx_proctoring/locale/ka/LC_MESSAGES/djangojs.po +1380 -0
  99. edx_proctoring/locale/lv/LC_MESSAGES/django.mo +0 -0
  100. edx_proctoring/locale/lv/LC_MESSAGES/django.po +1377 -0
  101. edx_proctoring/locale/lv/LC_MESSAGES/djangojs.mo +0 -0
  102. edx_proctoring/locale/lv/LC_MESSAGES/djangojs.po +1377 -0
  103. edx_proctoring/locale/mn/LC_MESSAGES/django.mo +0 -0
  104. edx_proctoring/locale/mn/LC_MESSAGES/django.po +1434 -0
  105. edx_proctoring/locale/mn/LC_MESSAGES/djangojs.mo +0 -0
  106. edx_proctoring/locale/mn/LC_MESSAGES/djangojs.po +1379 -0
  107. edx_proctoring/locale/nb/LC_MESSAGES/django.mo +0 -0
  108. edx_proctoring/locale/nb/LC_MESSAGES/django.po +1393 -0
  109. edx_proctoring/locale/nb/LC_MESSAGES/djangojs.mo +0 -0
  110. edx_proctoring/locale/nb/LC_MESSAGES/djangojs.po +1393 -0
  111. edx_proctoring/locale/pl/LC_MESSAGES/django.mo +0 -0
  112. edx_proctoring/locale/pl/LC_MESSAGES/django.po +1386 -0
  113. edx_proctoring/locale/pl/LC_MESSAGES/djangojs.mo +0 -0
  114. edx_proctoring/locale/pl/LC_MESSAGES/djangojs.po +1386 -0
  115. edx_proctoring/locale/pt_PT/LC_MESSAGES/django.mo +0 -0
  116. edx_proctoring/locale/pt_PT/LC_MESSAGES/django.po +1793 -0
  117. edx_proctoring/locale/pt_PT/LC_MESSAGES/djangojs.mo +0 -0
  118. edx_proctoring/locale/pt_PT/LC_MESSAGES/djangojs.po +1810 -0
  119. edx_proctoring/locale/ro/LC_MESSAGES/django.mo +0 -0
  120. edx_proctoring/locale/ro/LC_MESSAGES/django.po +1382 -0
  121. edx_proctoring/locale/ro/LC_MESSAGES/djangojs.mo +0 -0
  122. edx_proctoring/locale/ro/LC_MESSAGES/djangojs.po +1382 -0
  123. edx_proctoring/locale/ru/LC_MESSAGES/django.mo +0 -0
  124. edx_proctoring/locale/ru/LC_MESSAGES/django.po +1456 -0
  125. edx_proctoring/locale/ru/LC_MESSAGES/djangojs.mo +0 -0
  126. edx_proctoring/locale/ru/LC_MESSAGES/djangojs.po +1459 -0
  127. edx_proctoring/locale/sw_KE/LC_MESSAGES/django.mo +0 -0
  128. edx_proctoring/locale/sw_KE/LC_MESSAGES/django.po +1462 -0
  129. edx_proctoring/locale/sw_KE/LC_MESSAGES/djangojs.mo +0 -0
  130. edx_proctoring/locale/sw_KE/LC_MESSAGES/djangojs.po +1464 -0
  131. edx_proctoring/locale/tr_TR/LC_MESSAGES/django.mo +0 -0
  132. edx_proctoring/locale/tr_TR/LC_MESSAGES/django.po +1527 -0
  133. edx_proctoring/locale/tr_TR/LC_MESSAGES/djangojs.mo +0 -0
  134. edx_proctoring/locale/tr_TR/LC_MESSAGES/djangojs.po +1527 -0
  135. edx_proctoring/locale/uk/LC_MESSAGES/django.mo +0 -0
  136. edx_proctoring/locale/uk/LC_MESSAGES/django.po +1443 -0
  137. edx_proctoring/locale/uk/LC_MESSAGES/djangojs.mo +0 -0
  138. edx_proctoring/locale/uk/LC_MESSAGES/djangojs.po +1443 -0
  139. edx_proctoring/locale/zh_CN/LC_MESSAGES/django.mo +0 -0
  140. edx_proctoring/locale/zh_CN/LC_MESSAGES/django.po +1468 -0
  141. edx_proctoring/locale/zh_CN/LC_MESSAGES/djangojs.mo +0 -0
  142. edx_proctoring/locale/zh_CN/LC_MESSAGES/djangojs.po +1469 -0
  143. edx_proctoring/management/__init__.py +3 -0
  144. edx_proctoring/management/commands/__init__.py +3 -0
  145. edx_proctoring/management/commands/reset_attempts.py +87 -0
  146. edx_proctoring/management/commands/set_attempt_status.py +52 -0
  147. edx_proctoring/management/commands/set_is_attempt_active.py +104 -0
  148. edx_proctoring/management/commands/tests/__init__.py +3 -0
  149. edx_proctoring/management/commands/tests/test_reset_attempts.py +153 -0
  150. edx_proctoring/management/commands/tests/test_set_attempt_status.py +85 -0
  151. edx_proctoring/management/commands/tests/test_set_is_attempt_active.py +85 -0
  152. edx_proctoring/management/commands/tests/test_update_attempt_status_from_review.py +113 -0
  153. edx_proctoring/management/commands/tests/test_update_attempts_for_exam.py +62 -0
  154. edx_proctoring/management/commands/update_attempt_status_from_review.py +81 -0
  155. edx_proctoring/management/commands/update_attempts_for_exam.py +78 -0
  156. edx_proctoring/migrations/0001_initial.py +227 -0
  157. edx_proctoring/migrations/0002_proctoredexamstudentattempt_is_status_acknowledged.py +18 -0
  158. edx_proctoring/migrations/0003_auto_20160101_0525.py +24 -0
  159. edx_proctoring/migrations/0004_auto_20160201_0523.py +19 -0
  160. edx_proctoring/migrations/0005_proctoredexam_hide_after_due.py +19 -0
  161. edx_proctoring/migrations/0006_allowed_time_limit_mins.py +24 -0
  162. edx_proctoring/migrations/0007_proctoredexam_backend.py +20 -0
  163. edx_proctoring/migrations/0008_auto_20181116_1551.py +32 -0
  164. edx_proctoring/migrations/0009_proctoredexamreviewpolicy_remove_rules.py +23 -0
  165. edx_proctoring/migrations/0010_update_backend.py +32 -0
  166. edx_proctoring/migrations/0011_allow_multiple_attempts.py +17 -0
  167. edx_proctoring/migrations/0012_proctoredexamstudentattempt_time_remaining_seconds.py +18 -0
  168. edx_proctoring/migrations/0013_proctoredexamsoftwaresecurereview_is_active_attempt.py +23 -0
  169. edx_proctoring/migrations/0014_add_is_resumable_to_proctoredexamstudentattempt.py +23 -0
  170. edx_proctoring/migrations/0015_rm_proctoredexamstudentattempt_ips.py +29 -0
  171. edx_proctoring/migrations/0016_nullable_proctoredexamstudentattempt_name.py +23 -0
  172. edx_proctoring/migrations/0017_rm_proctoredexamstudentattempt_name.py +21 -0
  173. edx_proctoring/migrations/0018_historicalproctoredexamstudentattempt.py +53 -0
  174. edx_proctoring/migrations/0019_proctoredexamsoftwaresecurereview_encrypted_video_url.py +18 -0
  175. edx_proctoring/migrations/0020_auto_20211028_1915.py +23 -0
  176. edx_proctoring/migrations/0021_auto_20211029_1353.py +26 -0
  177. edx_proctoring/migrations/0022_proctoredexamstudentattempt_add_readytoresume_resumed.py +33 -0
  178. edx_proctoring/migrations/0023_historicalproctoredexam.py +50 -0
  179. edx_proctoring/migrations/0024_delete_proctoredexamstudentattempthistory.py +16 -0
  180. edx_proctoring/migrations/0025_auto_20230727_2112.py +21 -0
  181. edx_proctoring/migrations/__init__.py +0 -0
  182. edx_proctoring/models.py +844 -0
  183. edx_proctoring/rules.py +15 -0
  184. edx_proctoring/runtime.py +21 -0
  185. edx_proctoring/scripts/obscure_user.py +108 -0
  186. edx_proctoring/serializers.py +147 -0
  187. edx_proctoring/services.py +43 -0
  188. edx_proctoring/settings/__init__.py +0 -0
  189. edx_proctoring/settings/common.py +50 -0
  190. edx_proctoring/settings/production.py +13 -0
  191. edx_proctoring/settings/test.py +11 -0
  192. edx_proctoring/signals.py +5 -0
  193. edx_proctoring/static/index.js +41 -0
  194. edx_proctoring/static/proctoring/clippy.swf +0 -0
  195. edx_proctoring/static/proctoring/close-modal-hover.png +0 -0
  196. edx_proctoring/static/proctoring/close-modal.png +0 -0
  197. edx_proctoring/static/proctoring/js/collections/proctored_exam_allowance_collection.js +15 -0
  198. edx_proctoring/static/proctoring/js/collections/proctored_exam_attempt_grouped_collection.js +15 -0
  199. edx_proctoring/static/proctoring/js/collections/proctored_exam_collection.js +16 -0
  200. edx_proctoring/static/proctoring/js/collections/proctored_exam_onboarding_collection.js +13 -0
  201. edx_proctoring/static/proctoring/js/dropdown.js +97 -0
  202. edx_proctoring/static/proctoring/js/exam_action_handler.js +252 -0
  203. edx_proctoring/static/proctoring/js/models/learner_onboarding_model.js +9 -0
  204. edx_proctoring/static/proctoring/js/models/proctored_exam_allowance_model.js +15 -0
  205. edx_proctoring/static/proctoring/js/models/proctored_exam_attempt_model.js +15 -0
  206. edx_proctoring/static/proctoring/js/models/proctored_exam_bulk_allowance_model.js +15 -0
  207. edx_proctoring/static/proctoring/js/models/proctored_exam_model.js +56 -0
  208. edx_proctoring/static/proctoring/js/proctored_app.js +16 -0
  209. edx_proctoring/static/proctoring/js/vendor/backbone.js +1608 -0
  210. edx_proctoring/static/proctoring/js/vendor/date.js +145 -0
  211. edx_proctoring/static/proctoring/js/vendor/i18n.js +10 -0
  212. edx_proctoring/static/proctoring/js/vendor/jquery.js +4 -0
  213. edx_proctoring/static/proctoring/js/vendor/underscore.js +1416 -0
  214. edx_proctoring/static/proctoring/js/views/Backbone.ModalDialog.js +338 -0
  215. edx_proctoring/static/proctoring/js/views/proctored_exam_add_bulk_allowance_view.js +300 -0
  216. edx_proctoring/static/proctoring/js/views/proctored_exam_allowance_view.js +204 -0
  217. edx_proctoring/static/proctoring/js/views/proctored_exam_attempt_view.js +307 -0
  218. edx_proctoring/static/proctoring/js/views/proctored_exam_edit_allowance_view.js +165 -0
  219. edx_proctoring/static/proctoring/js/views/proctored_exam_info.js +232 -0
  220. edx_proctoring/static/proctoring/js/views/proctored_exam_instructor_launch.js +41 -0
  221. edx_proctoring/static/proctoring/js/views/proctored_exam_onboarding_view.js +288 -0
  222. edx_proctoring/static/proctoring/js/views/proctored_exam_view.js +267 -0
  223. edx_proctoring/static/proctoring/templates/add-new-bulk-allowance.underscore +105 -0
  224. edx_proctoring/static/proctoring/templates/course_grouped_allowances.underscore +70 -0
  225. edx_proctoring/static/proctoring/templates/dashboard.underscore +3 -0
  226. edx_proctoring/static/proctoring/templates/edit-allowance.underscore +52 -0
  227. edx_proctoring/static/proctoring/templates/proctored-exam-info.underscore +46 -0
  228. edx_proctoring/static/proctoring/templates/student-onboarding-status.underscore +153 -0
  229. edx_proctoring/static/proctoring/templates/student-proctored-exam-attempts-grouped.underscore +287 -0
  230. edx_proctoring/statuses.py +330 -0
  231. edx_proctoring/templates/common/waiting_banner.html +12 -0
  232. edx_proctoring/templates/emails/proctoring_attempt_satisfactory_email.html +28 -0
  233. edx_proctoring/templates/emails/proctoring_attempt_submitted_email.html +20 -0
  234. edx_proctoring/templates/emails/proctoring_attempt_unsatisfactory_email.html +33 -0
  235. edx_proctoring/templates/onboarding_exam/entrance.html +65 -0
  236. edx_proctoring/templates/onboarding_exam/error.html +18 -0
  237. edx_proctoring/templates/onboarding_exam/ready_to_submit.html +10 -0
  238. edx_proctoring/templates/onboarding_exam/rejected.html +33 -0
  239. edx_proctoring/templates/onboarding_exam/submitted.html +60 -0
  240. edx_proctoring/templates/onboarding_exam/verified.html +23 -0
  241. edx_proctoring/templates/practice_exam/entrance.html +90 -0
  242. edx_proctoring/templates/practice_exam/error.html +42 -0
  243. edx_proctoring/templates/practice_exam/submitted.html +31 -0
  244. edx_proctoring/templates/proctored_exam/confirm-decline.html +55 -0
  245. edx_proctoring/templates/proctored_exam/entrance.html +29 -0
  246. edx_proctoring/templates/proctored_exam/error.html +26 -0
  247. edx_proctoring/templates/proctored_exam/error_modal.html +26 -0
  248. edx_proctoring/templates/proctored_exam/error_wrong_browser.html +32 -0
  249. edx_proctoring/templates/proctored_exam/expired.html +13 -0
  250. edx_proctoring/templates/proctored_exam/failed-prerequisites.html +46 -0
  251. edx_proctoring/templates/proctored_exam/footer.html +80 -0
  252. edx_proctoring/templates/proctored_exam/inactive_account.html +38 -0
  253. edx_proctoring/templates/proctored_exam/instructions.html +227 -0
  254. edx_proctoring/templates/proctored_exam/onboarding_error.html +45 -0
  255. edx_proctoring/templates/proctored_exam/pending-prerequisites.html +40 -0
  256. edx_proctoring/templates/proctored_exam/proctoring_launch_callback.html +183 -0
  257. edx_proctoring/templates/proctored_exam/proctoring_opt_out_button.html +8 -0
  258. edx_proctoring/templates/proctored_exam/ready_to_resume.html +33 -0
  259. edx_proctoring/templates/proctored_exam/ready_to_start.html +70 -0
  260. edx_proctoring/templates/proctored_exam/ready_to_submit.html +39 -0
  261. edx_proctoring/templates/proctored_exam/rejected.html +30 -0
  262. edx_proctoring/templates/proctored_exam/submitted.html +24 -0
  263. edx_proctoring/templates/proctored_exam/verified.html +19 -0
  264. edx_proctoring/templates/proctored_exam/visit_exam_content.html +38 -0
  265. edx_proctoring/templates/timed_exam/entrance.html +79 -0
  266. edx_proctoring/templates/timed_exam/expired.html +13 -0
  267. edx_proctoring/templates/timed_exam/footer.html +10 -0
  268. edx_proctoring/templates/timed_exam/ready_to_submit.html +56 -0
  269. edx_proctoring/templates/timed_exam/submitted.html +32 -0
  270. edx_proctoring/tests/__init__.py +56 -0
  271. edx_proctoring/tests/test_api.py +3878 -0
  272. edx_proctoring/tests/test_email.py +325 -0
  273. edx_proctoring/tests/test_handlers.py +56 -0
  274. edx_proctoring/tests/test_mfe_views.py +579 -0
  275. edx_proctoring/tests/test_models.py +483 -0
  276. edx_proctoring/tests/test_reviews.py +844 -0
  277. edx_proctoring/tests/test_serializer.py +34 -0
  278. edx_proctoring/tests/test_services.py +429 -0
  279. edx_proctoring/tests/test_student_view.py +1676 -0
  280. edx_proctoring/tests/test_utils/factories.py +35 -0
  281. edx_proctoring/tests/test_utils/test_utils.py +207 -0
  282. edx_proctoring/tests/test_utils/utils.py +402 -0
  283. edx_proctoring/tests/test_views.py +6501 -0
  284. edx_proctoring/tests/test_workerconfig.py +96 -0
  285. edx_proctoring/urls.py +149 -0
  286. edx_proctoring/utils.py +488 -0
  287. edx_proctoring/views.py +2333 -0
  288. edx_proctoring-6.2.0.dist-info/METADATA +152 -0
  289. edx_proctoring-6.2.0.dist-info/RECORD +293 -0
  290. edx_proctoring-6.2.0.dist-info/WHEEL +5 -0
  291. edx_proctoring-6.2.0.dist-info/entry_points.txt +11 -0
  292. edx_proctoring-6.2.0.dist-info/licenses/LICENSE.txt +674 -0
  293. 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
+ }