├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .dockerignore ├── .envrc ├── .eslintrc.js ├── .github ├── config │ └── rubocop_linter_action.yml └── workflows │ ├── docker.yml │ ├── rubocop.yml │ └── test.yml ├── .gitignore ├── .rubocop.yml ├── .ruby-version ├── .vscode └── settings.json ├── ANOMALIES.md ├── Dockerfile ├── Gemfile ├── Gemfile.lock ├── README.md ├── Rakefile ├── app ├── channels │ ├── application_cable │ │ ├── channel.rb │ │ └── connection.rb │ └── graphql_channel.rb ├── controllers │ ├── api │ │ ├── professor │ │ │ └── versions_controller.rb │ │ ├── professor_controller.rb │ │ ├── student │ │ │ └── exams_controller.rb │ │ └── student_controller.rb │ ├── api_controller.rb │ ├── application_controller.rb │ ├── concerns │ │ └── .keep │ ├── graphql_controller.rb │ ├── main_controller.rb │ └── users │ │ └── omniauth_callbacks_controller.rb ├── graphql │ ├── association_loader.rb │ ├── foreign_key_loader.rb │ ├── hourglass_schema.rb │ ├── mutations │ │ ├── .keep │ │ ├── ask_question.rb │ │ ├── base_mutation.rb │ │ ├── change_all_that_apply_details.rb │ │ ├── change_code_details.rb │ │ ├── change_code_snippet_details.rb │ │ ├── change_code_tag_details.rb │ │ ├── change_exam_version_details.rb │ │ ├── change_html_details.rb │ │ ├── change_matching_details.rb │ │ ├── change_multiple_choice_details.rb │ │ ├── change_part_details.rb │ │ ├── change_preset_comment_details.rb │ │ ├── change_question_details.rb │ │ ├── change_rubric_details.rb │ │ ├── change_rubric_preset_details.rb │ │ ├── change_rubric_type_type.rb │ │ ├── change_text_details.rb │ │ ├── change_yes_no_details.rb │ │ ├── commence_grading.rb │ │ ├── copy_accommodations.rb │ │ ├── create_accommodation.rb │ │ ├── create_all_that_apply.rb │ │ ├── create_code.rb │ │ ├── create_code_snippet.rb │ │ ├── create_code_tag.rb │ │ ├── create_exam.rb │ │ ├── create_exam_version.rb │ │ ├── create_grading_check.rb │ │ ├── create_grading_comment.rb │ │ ├── create_html.rb │ │ ├── create_matching.rb │ │ ├── create_multiple_choice.rb │ │ ├── create_part.rb │ │ ├── create_preset_comment.rb │ │ ├── create_question.rb │ │ ├── create_rubric.rb │ │ ├── create_rubric_preset.rb │ │ ├── create_text.rb │ │ ├── create_yes_no.rb │ │ ├── destroy_accommodation.rb │ │ ├── destroy_anomaly.rb │ │ ├── destroy_body_item.rb │ │ ├── destroy_exam_version.rb │ │ ├── destroy_grading_comment.rb │ │ ├── destroy_part.rb │ │ ├── destroy_preset_comment.rb │ │ ├── destroy_question.rb │ │ ├── destroy_rubric.rb │ │ ├── finalize_item.rb │ │ ├── grade_next.rb │ │ ├── impersonate_user.rb │ │ ├── move_body_item_answer.rb │ │ ├── postpone_grading_lock.rb │ │ ├── publish_grades.rb │ │ ├── release_all_grading_locks.rb │ │ ├── release_grading_lock.rb │ │ ├── reorder_body_items.rb │ │ ├── reorder_parts.rb │ │ ├── reorder_preset_comments.rb │ │ ├── reorder_questions.rb │ │ ├── reorder_rubrics.rb │ │ ├── request_grading_lock.rb │ │ ├── send_message.rb │ │ ├── stop_impersonating.rb │ │ ├── sync_course_to_bottlenose.rb │ │ ├── sync_exam_to_bottlenose.rb │ │ ├── update_accommodation.rb │ │ ├── update_exam.rb │ │ ├── update_exam_rooms.rb │ │ ├── update_grading_comment.rb │ │ ├── update_staff_seating.rb │ │ ├── update_student_seating.rb │ │ ├── update_version_registrations.rb │ │ └── update_version_timing.rb │ ├── record_loader.rb │ ├── subscriptions │ │ ├── anomaly_was_created.rb │ │ ├── anomaly_was_destroyed.rb │ │ ├── base_subscription.rb │ │ ├── exam_announcement_was_sent.rb │ │ ├── grading_lock_updated.rb │ │ ├── message_received.rb │ │ ├── message_was_sent.rb │ │ ├── question_was_asked.rb │ │ ├── registration_was_updated.rb │ │ ├── room_announcement_received.rb │ │ ├── room_announcement_was_sent.rb │ │ ├── version_announcement_received.rb │ │ └── version_announcement_was_sent.rb │ └── types │ │ ├── .keep │ │ ├── accommodation_type.rb │ │ ├── anomaly_type.rb │ │ ├── base_argument.rb │ │ ├── base_enum.rb │ │ ├── base_field.rb │ │ ├── base_input_object.rb │ │ ├── base_interface.rb │ │ ├── base_object.rb │ │ ├── base_scalar.rb │ │ ├── base_union.rb │ │ ├── body_item_type.rb │ │ ├── checklist_item_status_type.rb │ │ ├── code_answer_input_type.rb │ │ ├── code_tag_choice_type.rb │ │ ├── code_tag_input_type.rb │ │ ├── course_role_type.rb │ │ ├── course_type.rb │ │ ├── exam_announcement_type.rb │ │ ├── exam_checklist_section_type.rb │ │ ├── exam_checklist_type.rb │ │ ├── exam_type.rb │ │ ├── exam_version_type.rb │ │ ├── future_registration_type.rb │ │ ├── grading_check_type.rb │ │ ├── grading_comment_type.rb │ │ ├── grading_lock_type.rb │ │ ├── html_input_type.rb │ │ ├── html_type.rb │ │ ├── lockdown_policy_type.rb │ │ ├── message_type.rb │ │ ├── mutation_type.rb │ │ ├── part_type.rb │ │ ├── policy_exemption_type.rb │ │ ├── proctor_registration_type.rb │ │ ├── professor_course_registration_type.rb │ │ ├── query_type.rb │ │ ├── question_type.rb │ │ ├── reference_type.rb │ │ ├── registration_type.rb │ │ ├── room_announcement_type.rb │ │ ├── room_type.rb │ │ ├── rubric_type.rb │ │ ├── section_type.rb │ │ ├── snapshot_type.rb │ │ ├── staff_registration_type.rb │ │ ├── student_question_type.rb │ │ ├── subscription_type.rb │ │ ├── term_type.rb │ │ ├── user_type.rb │ │ └── version_announcement_type.rb ├── helpers │ ├── application_helper.rb │ └── uploads_helper.rb ├── jobs │ └── application_job.rb ├── mailers │ └── application_mailer.rb ├── models │ ├── accommodation.rb │ ├── anomaly.rb │ ├── application_record.rb │ ├── body_item.rb │ ├── concerns │ │ └── .keep │ ├── course.rb │ ├── exam.rb │ ├── exam_announcement.rb │ ├── exam_version.rb │ ├── grading_check.rb │ ├── grading_comment.rb │ ├── grading_lock.rb │ ├── message.rb │ ├── part.rb │ ├── preset_comment.rb │ ├── proctor_registration.rb │ ├── professor_course_registration.rb │ ├── question.rb │ ├── reference.rb │ ├── registration.rb │ ├── room.rb │ ├── room_announcement.rb │ ├── rubric.rb │ ├── rubric_preset.rb │ ├── rubric_tree_path.rb │ ├── rubrics │ │ ├── all.rb │ │ ├── any.rb │ │ ├── none.rb │ │ └── one.rb │ ├── section.rb │ ├── snapshot.rb │ ├── staff_registration.rb │ ├── student_question.rb │ ├── student_registration.rb │ ├── term.rb │ ├── upload.rb │ ├── user.rb │ └── version_announcement.rb ├── packs │ ├── components │ │ ├── common │ │ │ ├── NumericInput.scss │ │ │ ├── NumericInput.tsx │ │ │ ├── ReadableDate.tsx │ │ │ ├── Scrubber.tsx │ │ │ ├── Spoiler.tsx │ │ │ ├── alerts.scss │ │ │ ├── alerts.tsx │ │ │ ├── archive │ │ │ │ ├── fileMarks.ts │ │ │ │ └── index.tsx │ │ │ ├── boundary.tsx │ │ │ ├── context.ts │ │ │ ├── documentTitle.tsx │ │ │ ├── helpers.ts │ │ │ ├── linkbutton.tsx │ │ │ ├── linkdropdownitem.tsx │ │ │ ├── linksplitbutton.tsx │ │ │ ├── loading.tsx │ │ │ ├── messages │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── mime.ts │ │ │ ├── navbar │ │ │ │ ├── NotLoggedIn.tsx │ │ │ │ └── index.tsx │ │ │ ├── rearrangeable.tsx │ │ │ ├── scrubber.scss │ │ │ ├── student-dnd │ │ │ │ ├── index.tsx │ │ │ │ └── store.ts │ │ │ ├── svg.d.ts │ │ │ └── types │ │ │ │ └── api.ts │ │ ├── graphiql │ │ │ └── index.tsx │ │ ├── relay │ │ │ └── environment │ │ │ │ └── index.ts │ │ └── workflows │ │ │ ├── FourOhFour.tsx │ │ │ ├── grading │ │ │ ├── UseRubrics.tsx │ │ │ ├── createComment.tsx │ │ │ ├── index.scss │ │ │ ├── index.tsx │ │ │ └── questions │ │ │ │ ├── GradeMatching.tsx │ │ │ │ ├── GradeMultipleChoice.tsx │ │ │ │ ├── GradeYesNo.tsx │ │ │ │ ├── ObjectiveGrade.scss │ │ │ │ └── ObjectiveGrade.tsx │ │ │ ├── home │ │ │ └── index.tsx │ │ │ ├── index.scss │ │ │ ├── index.tsx │ │ │ ├── proctor │ │ │ ├── exams │ │ │ │ ├── Favicon.tsx │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ └── registrations │ │ │ │ └── show │ │ │ │ ├── DisplayBody.tsx │ │ │ │ ├── Part.tsx │ │ │ │ ├── ShowQuestion.tsx │ │ │ │ ├── ShowRubric.tsx │ │ │ │ ├── Timeline.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── questions │ │ │ │ ├── DisplayAllThatApply.tsx │ │ │ │ ├── DisplayCode.tsx │ │ │ │ ├── DisplayCodeSnippet.tsx │ │ │ │ ├── DisplayCodeTag.tsx │ │ │ │ ├── DisplayMatching.tsx │ │ │ │ ├── DisplayMultipleChoice.tsx │ │ │ │ ├── DisplayText.tsx │ │ │ │ ├── DisplayYesNo.tsx │ │ │ │ └── Prompted.tsx │ │ │ ├── professor │ │ │ ├── courses │ │ │ │ ├── show.tsx │ │ │ │ └── sync.tsx │ │ │ └── exams │ │ │ │ ├── accommodations │ │ │ │ └── index.tsx │ │ │ │ ├── admin.scss │ │ │ │ ├── admin.tsx │ │ │ │ ├── allocate-versions.tsx │ │ │ │ ├── assign-staff │ │ │ │ └── index.tsx │ │ │ │ ├── dnd.scss │ │ │ │ ├── new │ │ │ │ ├── DateTimePicker.scss │ │ │ │ ├── DateTimePicker.tsx │ │ │ │ ├── editor │ │ │ │ │ ├── BodyItem.tsx │ │ │ │ │ ├── FileUploader.tsx │ │ │ │ │ ├── Instructions.tsx │ │ │ │ │ ├── Part.tsx │ │ │ │ │ ├── Policies.tsx │ │ │ │ │ ├── Question.tsx │ │ │ │ │ ├── Reference.tsx │ │ │ │ │ ├── Rubric.tsx │ │ │ │ │ ├── body-items │ │ │ │ │ │ ├── AllThatApply.tsx │ │ │ │ │ │ ├── Code.tsx │ │ │ │ │ │ ├── CodeSnippet.tsx │ │ │ │ │ │ ├── CodeTag.tsx │ │ │ │ │ │ ├── Html.tsx │ │ │ │ │ │ ├── Matching.css │ │ │ │ │ │ ├── Matching.tsx │ │ │ │ │ │ ├── MultipleChoice.tsx │ │ │ │ │ │ ├── Prompted.tsx │ │ │ │ │ │ ├── Text.tsx │ │ │ │ │ │ └── YesNo.tsx │ │ │ │ │ ├── components │ │ │ │ │ │ ├── CustomEditor.scss │ │ │ │ │ │ ├── CustomEditor.tsx │ │ │ │ │ │ ├── FilePicker.tsx │ │ │ │ │ │ ├── RemirrorDropdownButton.tsx │ │ │ │ │ │ └── helpers.tsx │ │ │ │ │ └── index.tsx │ │ │ │ └── index.tsx │ │ │ │ ├── rooms │ │ │ │ └── index.tsx │ │ │ │ ├── rubrics.scss │ │ │ │ ├── rubrics.tsx │ │ │ │ ├── stats │ │ │ │ ├── CustomTooltip.tsx │ │ │ │ ├── RubricPresetHistograms.tsx │ │ │ │ ├── ShowLinks.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── utils.ts │ │ │ │ ├── submissions │ │ │ │ └── index.tsx │ │ │ │ ├── types.ts │ │ │ │ └── versionTiming │ │ │ │ ├── editors.tsx │ │ │ │ └── index.tsx │ │ │ ├── student │ │ │ ├── exams │ │ │ │ └── show │ │ │ │ │ ├── actions │ │ │ │ │ └── index.ts │ │ │ │ │ ├── components │ │ │ │ │ ├── AnomalousMessaging.tsx │ │ │ │ │ ├── Body.tsx │ │ │ │ │ ├── ExamCodeBox.scss │ │ │ │ │ ├── ExamCodeBox.tsx │ │ │ │ │ ├── ExamShowContents.tsx │ │ │ │ │ ├── ExamSubmitted.tsx │ │ │ │ │ ├── ExamTaker.scss │ │ │ │ │ ├── ExamTaker.tsx │ │ │ │ │ ├── FileViewer.tsx │ │ │ │ │ ├── HTML.tsx │ │ │ │ │ ├── Icon.css │ │ │ │ │ ├── Icon.tsx │ │ │ │ │ ├── LockdownInfo.tsx │ │ │ │ │ ├── Locked.css │ │ │ │ │ ├── Locked.tsx │ │ │ │ │ ├── PaginationArrows.tsx │ │ │ │ │ ├── Part.css │ │ │ │ │ ├── Part.tsx │ │ │ │ │ ├── PreStart.tsx │ │ │ │ │ ├── ShowQuestion.tsx │ │ │ │ │ ├── SnapshotInfo.tsx │ │ │ │ │ ├── SubmitButton.tsx │ │ │ │ │ ├── Tooltip.tsx │ │ │ │ │ ├── TooltipButton.tsx │ │ │ │ │ ├── VerticalScrollShadow.scss │ │ │ │ │ ├── VerticalScrollShadow.tsx │ │ │ │ │ ├── navbar │ │ │ │ │ │ ├── AskQuestion.tsx │ │ │ │ │ │ ├── ExamMessages.tsx │ │ │ │ │ │ ├── JumpTo.tsx │ │ │ │ │ │ ├── NavAccordionItem.tsx │ │ │ │ │ │ ├── Scratch.tsx │ │ │ │ │ │ ├── TimeRemaining.tsx │ │ │ │ │ │ ├── index.css │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── questions │ │ │ │ │ │ ├── AllThatApply.tsx │ │ │ │ │ │ ├── Code.tsx │ │ │ │ │ │ ├── CodeTag.tsx │ │ │ │ │ │ ├── Matching.tsx │ │ │ │ │ │ ├── MultipleChoice.tsx │ │ │ │ │ │ ├── Text.tsx │ │ │ │ │ │ └── YesNo.tsx │ │ │ │ │ ├── containers │ │ │ │ │ ├── ExamShowContents.ts │ │ │ │ │ ├── ExamTaker.tsx │ │ │ │ │ ├── LockdownInfo.tsx │ │ │ │ │ ├── PaginationArrows.ts │ │ │ │ │ ├── PreStart.tsx │ │ │ │ │ ├── ShowQuestion.ts │ │ │ │ │ ├── SnapshotInfo.tsx │ │ │ │ │ ├── SubmitButton.ts │ │ │ │ │ ├── navbar │ │ │ │ │ │ ├── JumpTo.tsx │ │ │ │ │ │ ├── Scratch.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── questions │ │ │ │ │ │ ├── AllThatApply.tsx │ │ │ │ │ │ ├── Code.ts │ │ │ │ │ │ ├── CodeTag.tsx │ │ │ │ │ │ ├── Matching.tsx │ │ │ │ │ │ ├── MultipleChoice.tsx │ │ │ │ │ │ ├── Text.tsx │ │ │ │ │ │ ├── YesNo.ts │ │ │ │ │ │ └── connectors.ts │ │ │ │ │ └── scrollspy │ │ │ │ │ │ ├── Part.tsx │ │ │ │ │ │ ├── Question.tsx │ │ │ │ │ │ └── connectors.ts │ │ │ │ │ ├── files.ts │ │ │ │ │ ├── helpers.ts │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── lockdown │ │ │ │ │ ├── anomaly.ts │ │ │ │ │ ├── helpers.ts │ │ │ │ │ ├── listeners.ts │ │ │ │ │ └── lock.ts │ │ │ │ │ ├── reducers │ │ │ │ │ ├── contents.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── lockdown.ts │ │ │ │ │ ├── pagination.ts │ │ │ │ │ └── snapshot.ts │ │ │ │ │ ├── store │ │ │ │ │ └── index.ts │ │ │ │ │ └── types.ts │ │ │ └── registrations │ │ │ │ └── show │ │ │ │ ├── DisplayQuestions.tsx │ │ │ │ └── index.tsx │ │ │ └── wdyr.ts │ ├── entrypoints │ │ ├── application.js │ │ ├── bootstrap.js │ │ └── bootstrap.scss │ ├── images │ │ ├── hourglass.svg │ │ ├── navbar.png │ │ └── site-icon.png │ ├── rawimages │ │ ├── hourglass-dolphin.jpg │ │ └── hourglass-dolphin.png │ └── relay │ │ └── data │ │ └── .keep └── views │ ├── devise │ └── sessions │ │ └── new.html.erb │ └── layouts │ ├── application.html.erb │ ├── mailer.html.erb │ └── mailer.text.erb ├── babel.config.js ├── bin ├── rails ├── rake ├── setup ├── spring ├── webpack ├── webpack-dev-server └── yarn ├── config.ru ├── config ├── application.rb ├── boot.rb ├── cable.yml ├── credentials.yml.enc ├── database.yml ├── environment.rb ├── environments │ ├── development.rb │ ├── production.rb │ └── test.rb ├── initializers │ ├── application_controller_renderer.rb │ ├── backtrace_silencers.rb │ ├── content_security_policy.rb │ ├── cookies_serializer.rb │ ├── devise.rb │ ├── filter_parameter_logging.rb │ ├── graphql_schema_updater.rb │ ├── inflections.rb │ ├── mime_types.rb │ ├── permissions_policy.rb │ ├── secret_token.rb │ └── wrap_parameters.rb ├── locales │ ├── devise.en.yml │ └── en.yml ├── puma.rb ├── routes.rb ├── schemas │ ├── exam-upload.json │ └── files.json ├── spring.rb ├── webpack │ ├── base.js │ ├── development.js │ ├── production.js │ └── test.js └── webpacker.yml ├── db ├── migrate │ ├── 20200522182009_create_schema.rb │ ├── 20201022000727_add_unique_bottlenose_id_index_to_section.rb │ ├── 20210301005626_remove_rubrics_from_exam_info.rb │ ├── 20210331000943_change_questions_to_student_questions.rb │ ├── 20210331011204_create_qpb_tables.rb │ ├── 20210331014304_convert_exam_version_info_to_models.rb │ ├── 20210331111657_create_qpb_references.rb │ ├── 20210331111730_remove_qpb_nums.rb │ ├── 20210518175335_fix_rubric_uniqueness_indices.rb │ ├── 20210519203405_mark_preset_comments_order_required.rb │ ├── 20210521115135_create_rubric_closure_tree.rb │ ├── 20210521121346_remove_rubric_parent_section.rb │ ├── 20210523182059_remove_info_from_exam_versions.rb │ ├── 20210911223649_create_terms.rb │ ├── 20220911215356_add_times_to_exam_versions.rb │ ├── 20231106011324_add_updated_index_to_snapshots.rb │ ├── 20231116195043_add_pins_to_students_registrations.rb │ └── 20240531182212_add_notes_to_grading_lock.rb ├── schema.rb └── seeds.rb ├── flake.lock ├── flake.nix ├── hourglass.conf ├── lib ├── archive_utils.rb ├── assets │ └── .keep ├── audit.rb ├── bottlenose.rb ├── devise │ ├── models │ │ └── session_limitable.rb │ └── strategies │ │ └── debug_login.rb ├── marks_processor.rb └── tasks │ ├── .keep │ ├── factory_bot_lint.rake │ ├── graphql_schema_update.rake │ └── sample_data.rake ├── log └── .keep ├── package.json ├── postcss.config.js ├── public ├── 404.html ├── 422.html ├── 500.html ├── apple-touch-icon-precomposed.png ├── apple-touch-icon.png ├── favicon.ico ├── find_x.jpg └── robots.txt ├── relay.config.js ├── shell.nix ├── test ├── application_system_test_case.rb ├── channels │ └── graphql_channel_test.rb ├── controllers │ ├── .keep │ ├── api │ │ └── professor │ │ │ └── versions_controller_test.rb │ └── main_controller_test.rb ├── factories │ ├── accommodation.rb │ ├── anomaly.rb │ ├── course.rb │ ├── exam.rb │ ├── exam_announcement.rb │ ├── exam_version.rb │ ├── grading_check.rb │ ├── grading_comment.rb │ ├── grading_lock.rb │ ├── message.rb │ ├── proctor_registration.rb │ ├── professor_course_registrations.rb │ ├── registration.rb │ ├── room.rb │ ├── room_announcement.rb │ ├── section.rb │ ├── snapshot.rb │ ├── staff_registration.rb │ ├── student_question.rb │ ├── student_registration.rb │ ├── term.rb │ ├── upload.rb │ ├── user.rb │ └── version_announcement.rb ├── fixtures │ ├── .keep │ └── files │ │ ├── blank │ │ └── exam.yaml │ │ ├── cs2500midterm-v1 │ │ ├── exam.yaml │ │ └── files │ │ │ ├── q1 │ │ │ ├── all │ │ │ │ └── src │ │ │ │ │ ├── packageone │ │ │ │ │ └── Example.java │ │ │ │ │ └── packagetwo │ │ │ │ │ └── Example2.java │ │ │ └── p1 │ │ │ │ └── anything.txt │ │ │ └── test.txt │ │ ├── cs2500midterm-v2 │ │ ├── exam.yaml │ │ └── files │ │ │ ├── q1 │ │ │ ├── all │ │ │ │ └── src │ │ │ │ │ ├── packageone │ │ │ │ │ └── Example.java │ │ │ │ │ └── packagetwo │ │ │ │ │ └── Example2.java │ │ │ └── p1 │ │ │ │ └── anything.txt │ │ │ └── test.txt │ │ ├── cs3500final-v1 │ │ └── exam.yaml │ │ ├── cs3500final-v2 │ │ └── exam.yaml │ │ ├── extra-credits │ │ └── exam.yaml │ │ ├── long-snapshot.json │ │ └── tutorial │ │ ├── exam.yaml │ │ └── files │ │ ├── several │ │ ├── and │ │ │ └── seek.java │ │ └── hide.rkt │ │ └── singleFile.java ├── helpers │ ├── .keep │ └── upload.rb ├── integration │ ├── .keep │ ├── exam_version_administration_test.rb │ ├── finalization_test.rb │ ├── grading_locks_test.rb │ ├── messages_test.rb │ └── snapshot_perf_test.rb ├── mailers │ └── .keep ├── models │ ├── .keep │ ├── accommodation_test.rb │ ├── anomaly_test.rb │ ├── course_test.rb │ ├── exam_test.rb │ ├── exam_version_test.rb │ ├── grading_check_test.rb │ ├── grading_comment_test.rb │ ├── message_test.rb │ ├── question_test.rb │ ├── registration_test.rb │ ├── room_test.rb │ ├── snapshot_test.rb │ ├── upload_test.rb │ ├── user_test.rb │ └── version_announcement_test.rb ├── system │ ├── .keep │ ├── all_pages_load_test.rb │ ├── anomalies_test.rb │ └── extra_credit_test.rb └── test_helper.rb ├── tmp └── .keep ├── tsconfig.json ├── vendor └── .keep └── yarn.lock /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/xtruder/nix-devcontainer:v1 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .gitignore -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | use_flake() { 2 | watch_file flake.nix 3 | watch_file flake.lock 4 | eval "$(nix print-dev-env --profile "$(direnv_layout_dir)/flake-profile")" 5 | } 6 | 7 | use_flake 8 | -------------------------------------------------------------------------------- /.github/config/rubocop_linter_action.yml: -------------------------------------------------------------------------------- 1 | versions: 2 | - rubocop 3 | - rubocop-rails 4 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: build-docker 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - stress-changes 8 | - staging 9 | 10 | jobs: 11 | docker: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Login to GitHub Container Registry 15 | uses: docker/login-action@v3 16 | with: 17 | registry: ghcr.io 18 | username: ${{ github.actor }} 19 | password: ${{ secrets.GITHUB_TOKEN }} 20 | - name: Build and push Docker images 21 | uses: docker/build-push-action@v5 22 | with: 23 | push: true 24 | tags: ghcr.io/codegrade/hourglass:${{ github.ref_name }}-${{ github.sha }} 25 | - name: Cleanup old images 26 | uses: actions/delete-package-versions@v4 27 | with: 28 | package-name: 'hourglass' 29 | package-type: 'container' 30 | min-versions-to-keep: 20 -------------------------------------------------------------------------------- /.github/workflows/rubocop.yml: -------------------------------------------------------------------------------- 1 | name: RuboCop 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: get ruby version 12 | id: rubyversion 13 | run: echo "version=$(cat .ruby-version)" >> $GITHUB_OUTPUT 14 | - name: Set up Ruby 15 | uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: ${{ steps.rubyversion.outputs.version }} 18 | - name: Cache gems 19 | uses: actions/cache@v3 20 | with: 21 | path: vendor/bundle 22 | key: ${{ runner.os }}-rubocop-${{ hashFiles('**/Gemfile.lock') }} 23 | restore-keys: | 24 | ${{ runner.os }}-rubocop- 25 | - name: Install gems 26 | run: | 27 | bundle config path vendor/bundle 28 | bundle config set without 'default doc job cable storage ujs test db' 29 | bundle install --jobs 4 --retry 3 30 | - name: Run RuboCop 31 | run: bundle exec rubocop --parallel 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore all logfiles and tempfiles. 11 | /log/* 12 | /tmp/* 13 | !/log/.keep 14 | !/tmp/.keep 15 | 16 | # Ignore uploaded files in development 17 | /storage/* 18 | !/storage/.keep 19 | 20 | /node_modules 21 | /yarn-error.log 22 | 23 | /public/assets 24 | .byebug_history 25 | 26 | # Ignore master key for decrypting credentials and more. 27 | /config/master.key 28 | 29 | # Ignore uploaded files and submissions. 30 | /private/uploads 31 | /private/submissions 32 | 33 | # nix ignores 34 | /.nix-gems/ 35 | /postgres/ 36 | /postgres_data/ 37 | 38 | /public/packs 39 | /public/packs-test 40 | /node_modules 41 | /yarn-error.log 42 | yarn-debug.log* 43 | .yarn-integrity 44 | 45 | /.log/ 46 | __generated__/ 47 | /app/packs/relay/ 48 | 49 | config/schemas/graphql-queries.json 50 | 51 | Exams/ 52 | 53 | /.direnv/ 54 | /.envrc 55 | config/development_local_env.yml 56 | config/production_local_env.yml 57 | config/test_local_env.yml 58 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.0.2 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "nixEnvSelector.nixFile": "${workspaceRoot}/shell.nix", 3 | } 4 | -------------------------------------------------------------------------------- /ANOMALIES.md: -------------------------------------------------------------------------------- 1 | # Anomaly Detection Progress 2 | 3 | ## Currently Detected 4 | - if the developer console is open before the exam is started, `isFullScreen` is `false` 5 | - if the developer console is opened during the exam, resize gets triggered 6 | - window resizes trigger a fullscreen check. if `isFullScreen` returns `false`, an anomaly is recorded and the user is locked out of the exam. 7 | - `mouseout` event detects when the mouse leaves the window, triggers an anomaly 8 | - in chrome, mousing over the "x" at the top center of the screen to leave fullscreen counts as a `mouseout`. 9 | - `blur` event detects when the window loses focus from the user, triggers an anomaly 10 | - `contextmenu` is sent when the user right-clicks in the window, and this is disabled with `preventDefault` and `stopPropagation`. no anomaly is recorded 11 | - if a user attempts to save a snapshot and they already have a final submission or have some anomaly, they are locked out. 12 | - Chrome's "toggle device toolbar" mode (ctrl+shift+m when developer tools is open) is seen as "fullscreen" no matter what state the window is in. 13 | - the user could have other windows or the developer console open, but focus detection will still work if the cursor leaves the page 14 | - this is fixed by disallowing mobile useragents 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hourglass 2 | 3 | Web-based exam server. 4 | 5 | Sister project of [Bottlenose][bottlenose]. 6 | 7 | [Anomaly Detection Progress](ANOMALIES.md) 8 | 9 | ## Development Environment Setup 10 | 11 | A `shell.nix` is provided which should get you a reproducible environment for running hourglass. 12 | 13 | [More info about Nix][nix]. 14 | 15 | It will also setup postgres to store data locally. Once in the `nix-shell` environment, run `bundle install` to install gems locally. You can then run `start_postgres` and `stop_postgres` to control the local postgres server. 16 | 17 | ## Webpack and relay dev servers 18 | 19 | Webpack is configured with Hot Module Reloading, start the dev server with `yarn webpack-dev`. 20 | 21 | The relay dev server can be started with `yarn relay-dev`. 22 | 23 | ## Manual Database setup 24 | 25 | Hourglass expects postgresql. 26 | 27 | Databases can be created with `rails db:create` and the schema is loaded with `rails db:schema:load`. 28 | 29 | To setup the database with our development records, run `rails db:populate`. 30 | 31 | [bottlenose]: https://github.com/CodeGrade/bottlenose 32 | [nix]: https://nixos.org/nix/ 33 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Add your own tasks in files placed in lib/tasks ending in .rake, 4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 5 | 6 | require_relative 'config/application' 7 | 8 | Rails.application.load_tasks 9 | -------------------------------------------------------------------------------- /app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationCable 4 | # Necessary boilerplate class for channels 5 | class Channel < ActionCable::Channel::Base 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationCable 4 | # Main connection class for communicating with front end 5 | class Connection < ActionCable::Connection::Base 6 | identified_by :current_user, :true_user 7 | impersonates :user 8 | 9 | def connect 10 | self.current_user = find_verified_user 11 | reject_unauthorized_connection unless current_user 12 | end 13 | 14 | private 15 | 16 | def find_verified_user 17 | env['warden'].user 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/controllers/api/professor_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Entry for professor-facing API. 4 | module Api 5 | class ProfessorController < ApiController 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/controllers/api/student_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Entry for student-facing API. 4 | module Api 5 | class StudentController < ApiController 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/controllers/api_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Base controller for API. 4 | class ApiController < ApplicationController 5 | respond_to :json 6 | 7 | before_action :require_current_user 8 | 9 | private 10 | 11 | def require_current_user 12 | head :unauthorized if current_user.nil? 13 | end 14 | 15 | def find_exam_and_course 16 | @exam ||= Exam.find_by(id: params[:exam_id]) 17 | @course ||= @exam.course 18 | return unless @exam.nil? 19 | 20 | head :forbidden 21 | end 22 | 23 | def find_version 24 | @version ||= ExamVersion.find_by(id: params[:version_id]) 25 | @exam = @version.exam 26 | return unless @version.nil? 27 | 28 | head :forbidden 29 | end 30 | 31 | def require_student_reg 32 | @registration ||= @exam.registrations.find_by(user: current_user) 33 | return unless @registration.nil? 34 | 35 | head :forbidden 36 | end 37 | 38 | def require_prof_reg 39 | @professor_course_registration ||= ProfessorCourseRegistration.find_by(user: current_user, course: @course) 40 | return unless @professor_course_registration.nil? 41 | 42 | head :forbidden 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Base class for all application controllers 4 | class ApplicationController < ActionController::Base 5 | protect_from_forgery 6 | 7 | impersonates :user 8 | 9 | rescue_from DoubleLoginException do |_e| 10 | redirect_to root_path, 11 | alert: 'You are currently logged into another session.' 12 | end 13 | 14 | def bottlenose_api 15 | @bottlenose_api = Bottlenose::API.new(current_user) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /app/controllers/main_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Entry point for sign-in / React app. 4 | class MainController < ApplicationController 5 | def index 6 | unless current_user 7 | store_user_location! if storable_location? 8 | return redirect_to new_user_session_path 9 | end 10 | 11 | render component: 'workflows', prerender: false, class: 'h-100 d-flex flex-column' 12 | end 13 | 14 | def after_sign_in_path_for(resource) 15 | stored_location_for(resource) || super 16 | end 17 | 18 | private 19 | 20 | # Its important that the location is NOT stored if: 21 | # - The request method is not GET (non idempotent) 22 | # - The request is handled by a Devise controller such as Devise::SessionsController as that could cause an 23 | # infinite redirect loop. 24 | # - The request is an Ajax request as this can lead to very unexpected behaviour. 25 | def storable_location? 26 | request.get? && is_navigational_format? && !devise_controller? && !request.xhr? 27 | end 28 | 29 | def store_user_location! 30 | # :user is the scope we are authenticating 31 | store_location_for(:user, request.fullpath) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /app/graphql/foreign_key_loader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # from https://pganalyze.com/blog/efficient-graphql-queries-in-ruby-on-rails-and-postgres 4 | class ForeignKeyLoader < GraphQL::Batch::Loader 5 | attr_reader :model, :foreign_key, :merge 6 | 7 | def self.loader_key_for(*group_args) 8 | # avoiding including the `merge` lambda in loader key 9 | # each lambda is unique which defeats the purpose of 10 | # grouping queries together 11 | [self].concat(group_args.slice(0, 2)) 12 | end 13 | 14 | def initialize(model, foreign_key, merge: nil) 15 | @model = model 16 | @foreign_key = foreign_key 17 | @merge = merge 18 | end 19 | 20 | def perform(foreign_ids) 21 | # find all the records 22 | scope = model.where(foreign_key => foreign_ids) 23 | scope = scope.merge(merge) if merge.present? 24 | records = scope.to_a 25 | 26 | foreign_ids.each do |foreign_id| 27 | # find the records required to fulfill each promise 28 | matching_records = records.select do |r| 29 | foreign_id == r.send(foreign_key) 30 | end 31 | fulfill(foreign_id, matching_records) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/graphql/mutations/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/app/graphql/mutations/.keep -------------------------------------------------------------------------------- /app/graphql/mutations/change_html_details.rb: -------------------------------------------------------------------------------- 1 | module Mutations 2 | class ChangeHtmlDetails < BaseMutation 3 | argument :body_item_id, ID, required: true, loads: Types::BodyItemType 4 | 5 | argument :value, Types::HtmlInputType, required: true 6 | 7 | field :body_item, Types::BodyItemType, null: false 8 | 9 | def authorized?(body_item:, **_args) 10 | return true if body_item.course.user_is_professor?(context[:current_user]) 11 | 12 | raise GraphQL::ExecutionError, 'You do not have permission.' 13 | end 14 | 15 | def resolve(body_item:, value:) 16 | body_item.info = value 17 | 18 | saved = body_item.save 19 | raise GraphQL::ExecutionError, body_item.errors.full_messages.to_sentence unless saved 20 | 21 | { body_item: body_item } 22 | end 23 | end 24 | end -------------------------------------------------------------------------------- /app/graphql/mutations/change_rubric_details.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class ChangeRubricDetails < BaseMutation 5 | argument :rubric_id, ID, required: true, loads: Types::RubricType 6 | 7 | argument :points, Float, required: false 8 | argument :update_points, Boolean, required: false, default_value: false 9 | 10 | argument :description, String, required: false 11 | argument :update_description, Boolean, required: false, default_value: false 12 | 13 | field :rubric, Types::RubricType, null: false 14 | 15 | def authorized?(rubric:, **_args) 16 | return true if rubric.exam_version.course.user_is_professor?(context[:current_user]) 17 | 18 | raise GraphQL::ExecutionError, 'You do not have permission.' 19 | end 20 | 21 | def resolve(rubric:, update_points:, update_description:, points: nil, description: nil) 22 | rubric.description = description if update_description 23 | rubric.points = points if update_points 24 | saved = rubric.save 25 | raise GraphQL::ExecutionError, rubric.errors.full_messages.to_sentence unless saved 26 | 27 | { rubric: rubric } 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/graphql/mutations/change_rubric_preset_details.rb: -------------------------------------------------------------------------------- 1 | module Mutations 2 | class ChangeRubricPresetDetails < BaseMutation 3 | argument :rubric_preset_id, ID, required: true, loads: Types::RubricPresetType 4 | 5 | argument :label, String, required: false 6 | argument :update_label, Boolean, required: false, default_value: false 7 | 8 | argument :direction, Types::RubricDirectionType, required: false 9 | argument :update_direction, Boolean, required: false, default_value: false 10 | 11 | field :rubric_preset, Types::RubricPresetType, null: false 12 | 13 | def authorized?(rubric_preset:, **_args) 14 | return true if rubric_preset.exam_version.course.user_is_professor?(context[:current_user]) 15 | 16 | raise GraphQL::ExecutionError, 'You do not have permission.' 17 | end 18 | 19 | def resolve(rubric_preset:, update_label:, update_direction:, label: nil, direction: nil) 20 | rubric_preset.label = label if update_label 21 | rubric_preset.direction = direction if update_direction 22 | saved = rubric_preset.save 23 | raise GraphQL::ExecutionError, rubric_preset.errors.full_messages.to_sentence unless saved 24 | 25 | { rubric_preset: rubric_preset } 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/graphql/mutations/change_rubric_type_type.rb: -------------------------------------------------------------------------------- 1 | module Mutations 2 | class ChangeRubricTypeType < BaseMutation 3 | argument :rubric_id, ID, required: true, loads: Types::RubricType 4 | argument :type, Types::RubricVariantType, required: true 5 | 6 | field :rubric, Types::RubricType, null: false 7 | 8 | def authorized?(rubric:, **_args) 9 | return true if rubric.exam_version.course.user_is_professor?(context[:current_user]) 10 | 11 | raise GraphQL::ExecutionError, 'You do not have permission.' 12 | end 13 | 14 | def resolve(rubric:, type:) 15 | exam_version = rubric.exam_version 16 | rubric = rubric.change_type(type) 17 | 18 | cache_authorization!(exam_version.exam, exam_version.exam.course) 19 | { rubric: rubric } 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/graphql/mutations/change_text_details.rb: -------------------------------------------------------------------------------- 1 | module Mutations 2 | class ChangeTextDetails < BaseMutation 3 | argument :body_item_id, ID, required: true, loads: Types::BodyItemType 4 | 5 | argument :prompt, Types::HtmlInputType, required: false 6 | argument :update_prompt, Boolean, required: false, default_value: false 7 | 8 | argument :answer, String, required: false 9 | argument :update_answer, Boolean, required: false, default_value: false 10 | 11 | field :body_item, Types::BodyItemType, null: false 12 | 13 | def authorized?(body_item:, **_args) 14 | return true if body_item.course.user_is_professor?(context[:current_user]) 15 | 16 | raise GraphQL::ExecutionError, 'You do not have permission.' 17 | end 18 | 19 | def resolve(body_item:, **kwargs) 20 | if kwargs[:update_prompt] 21 | raise GraphQL::ExecutionError, 'Updated prompt must not be nil' unless kwargs[:prompt] 22 | body_item.info['prompt'] = kwargs[:prompt] 23 | end 24 | body_item.answer = kwargs[:answer] if kwargs[:update_answer] 25 | 26 | saved = body_item.save 27 | raise GraphQL::ExecutionError, body_item.errors.full_messages.to_sentence unless saved 28 | 29 | { body_item: body_item } 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /app/graphql/mutations/commence_grading.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class CommenceGrading < BaseMutation 5 | argument :exam_id, ID, required: true, loads: Types::ExamType 6 | 7 | field :success, Boolean, null: false 8 | 9 | def authorized?(exam:, **_args) 10 | return true if exam.course.professors.exists? context[:current_user].id 11 | 12 | raise GraphQL::ExecutionError, 'You do not have permission.' 13 | end 14 | 15 | def resolve(exam:) 16 | exam.finalize_registrations_that_have_run_out_of_time! 17 | exam.initialize_grading_locks! 18 | { success: true } 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/graphql/mutations/create_exam.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | # Mutation to create a new exam 5 | class CreateExam < BaseMutation 6 | argument :course_id, ID, required: true, loads: Types::CourseType 7 | argument :name, String, required: true 8 | argument :duration, Integer, required: true 9 | argument :start_time, GraphQL::Types::ISO8601DateTime, required: true 10 | argument :end_time, GraphQL::Types::ISO8601DateTime, required: true 11 | 12 | field :exam, Types::ExamType, null: false 13 | 14 | def authorized?(course:, **_args) 15 | return true if course.user_is_professor?(context[:current_user]) 16 | 17 | raise GraphQL::ExecutionError, 'You do not have permission.' 18 | end 19 | 20 | def resolve(**args) 21 | exam = Exam.new(args) 22 | saved = exam.save 23 | raise GraphQL::ExecutionError, exam.errors.full_messages.to_sentence unless saved 24 | 25 | cache_authorization!(exam, exam.course) 26 | { exam: exam } 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/graphql/mutations/create_exam_version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | # Mutation to create a new version for an exam 5 | class CreateExamVersion < BaseMutation 6 | argument :exam_id, ID, required: true, loads: Types::ExamType 7 | 8 | field :exam_version, Types::ExamVersionType, null: false 9 | 10 | def authorized?(exam:) 11 | return true if exam.user_is_professor?(context[:current_user]) 12 | 13 | raise GraphQL::ExecutionError, 'You do not have permission.' 14 | end 15 | 16 | def resolve(exam:) 17 | n = exam.exam_versions.length + 1 18 | version = ExamVersion.new( 19 | exam: exam, 20 | name: "#{exam.name} Version #{n}", 21 | files: [] 22 | ) 23 | saved = version.save 24 | raise GraphQL::ExecutionError, version.errors.full_messages.to_sentence unless saved 25 | 26 | { exam_version: version } 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/graphql/mutations/create_html.rb: -------------------------------------------------------------------------------- 1 | module Mutations 2 | class CreateHtml < BaseMutation 3 | argument :part_id, ID, required: true, loads: Types::PartType 4 | argument :value, Types::HtmlInputType, required: false 5 | 6 | field :part, Types::PartType, null: false 7 | 8 | def authorized?(part:, **_args) 9 | return true if part.course.user_is_professor?(context[:current_user]) 10 | 11 | raise GraphQL::ExecutionError, 'You do not have permission.' 12 | end 13 | 14 | def resolve(part:, value: nil, prompt: nil) 15 | index = part.body_items.count 16 | body_item = BodyItem.new( 17 | part: part, 18 | index: index, 19 | info: value || { 20 | type: 'HTML', 21 | value: '', 22 | }, 23 | ) 24 | saved = body_item.save 25 | raise GraphQL::ExecutionError, body_item.errors.full_messages.to_sentence unless saved 26 | 27 | { part: body_item.part } 28 | end 29 | 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/graphql/mutations/create_part.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class CreatePart < BaseMutation 5 | argument :question_id, ID, required: true, loads: Types::QuestionType 6 | argument :name, String, required: false 7 | argument :description, String, required: false 8 | argument :extra_credit, Boolean, required: false 9 | argument :points, Float, required: true 10 | 11 | field :question, Types::QuestionType, null: false 12 | 13 | def authorized?(question:, **_args) 14 | return true if question.course.user_is_professor?(context[:current_user]) 15 | 16 | raise GraphQL::ExecutionError, 'You do not have permission.' 17 | end 18 | 19 | def resolve(**args) 20 | index = args[:question].parts.count 21 | part = Part.new(index: index, **args) 22 | saved = part.save 23 | raise GraphQL::ExecutionError, part.errors.full_messages.to_sentence unless saved 24 | 25 | { question: part.question } 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/graphql/mutations/create_preset_comment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | # Mutation to create a single preset comment 5 | class CreatePresetComment < BaseMutation 6 | argument :rubric_preset_id, ID, required: true, loads: Types::RubricPresetType 7 | argument :label, String, required: false 8 | argument :grader_hint, String, required: true 9 | argument :student_feedback, String, required: false 10 | argument :points, Float, required: true 11 | 12 | field :rubric_preset, Types::RubricPresetType, null: false 13 | 14 | def authorized?(rubric_preset:, **_args) 15 | return true if rubric_preset.exam.user_is_professor?(context[:current_user]) 16 | 17 | raise GraphQL::ExecutionError, 'You do not have permission.' 18 | end 19 | 20 | def resolve(**args) 21 | order = args[:rubric_preset]&.preset_comments&.count || 0 22 | preset_comment = PresetComment.new(order: order, **args) 23 | saved = preset_comment.save 24 | raise GraphQL::ExecutionError, preset_comment.errors.full_messages.to_sentence unless saved 25 | 26 | { rubric_preset: preset_comment.rubric_preset } 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/graphql/mutations/create_question.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | # Mutation to create a single question 5 | class CreateQuestion < BaseMutation 6 | argument :exam_version_id, ID, required: true, loads: Types::ExamVersionType 7 | argument :name, String, required: false 8 | argument :description, String, required: false 9 | argument :extra_credit, Boolean, required: false 10 | argument :separate_subparts, Boolean, required: false 11 | 12 | field :exam_version, Types::ExamVersionType, null: false 13 | 14 | def authorized?(exam_version:, **_args) 15 | return true if exam_version.course.user_is_professor?(context[:current_user]) 16 | 17 | raise GraphQL::ExecutionError, 'You do not have permission.' 18 | end 19 | 20 | def resolve(**args) 21 | index = args[:exam_version].db_questions.count 22 | question = Question.new(index: index, **args) 23 | saved = question.save 24 | raise GraphQL::ExecutionError, question.errors.full_messages.to_sentence unless saved 25 | 26 | { exam_version: question.exam_version } 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/graphql/mutations/create_rubric_preset.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | # Mutation to create a single rubric preset 5 | class CreateRubricPreset < BaseMutation 6 | argument :rubric_id, ID, required: true, loads: Types::RubricType 7 | argument :direction, String, required: true 8 | argument :label, String, required: false 9 | argument :mercy, Float, required: false 10 | 11 | field :rubric, Types::RubricType, null: false 12 | 13 | def authorized?(rubric:, **_args) 14 | return true if rubric.course.user_is_professor?(context[:current_user]) 15 | 16 | raise GraphQL::ExecutionError, 'You do not have permission.' 17 | end 18 | 19 | def resolve(**args) 20 | RubricPreset.transaction do 21 | rubric_preset = RubricPreset.new(args) 22 | saved = rubric_preset.save 23 | raise GraphQL::ExecutionError, rubric_preset.errors.full_messages.to_sentence unless saved 24 | 25 | preset_comment = PresetComment.new(rubric_preset: rubric_preset, grader_hint: '', points: 0, order: 0) 26 | saved = preset_comment.save 27 | raise GraphQL::ExecutionError, preset_comment.errors.full_messages.to_sentence unless saved 28 | 29 | { rubric: rubric_preset.rubric } 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /app/graphql/mutations/create_text.rb: -------------------------------------------------------------------------------- 1 | module Mutations 2 | class CreateText < BaseMutation 3 | argument :part_id, ID, required: true, loads: Types::PartType 4 | argument :prompt, Types::HtmlInputType, required: false 5 | argument :answer, String, required: false 6 | 7 | field :part, Types::PartType, null: false 8 | 9 | def authorized?(part:, **_args) 10 | return true if part.course.user_is_professor?(context[:current_user]) 11 | 12 | raise GraphQL::ExecutionError, 'You do not have permission.' 13 | end 14 | 15 | def resolve(part:, answer: nil, prompt: nil) 16 | index = part.body_items.count 17 | body_item = BodyItem.new( 18 | part: part, 19 | index: index, 20 | info: { 21 | type: 'Text', 22 | prompt: prompt || { 23 | type: 'HTML', 24 | value: '', 25 | } 26 | }, 27 | answer: answer || '', 28 | ) 29 | saved = body_item.save 30 | raise GraphQL::ExecutionError, body_item.errors.full_messages.to_sentence unless saved 31 | 32 | { part: body_item.part } 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /app/graphql/mutations/destroy_body_item.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class DestroyBodyItem < BaseMutation 5 | argument :body_item_id, ID, required: true, loads: Types::BodyItemType 6 | 7 | field :part, Types::PartType, null: false 8 | 9 | def authorized?(body_item:) 10 | return true if body_item.course.user_is_professor?(context[:current_user]) 11 | 12 | raise GraphQL::ExecutionError, 'You do not have permission.' 13 | end 14 | 15 | def resolve(body_item:) 16 | BodyItem.transaction do 17 | part = body_item.part 18 | index = body_item.index 19 | destroyed = body_item.destroy 20 | raise GraphQL::ExecutionError, body_item.errors.full_messages.to_sentence unless destroyed 21 | 22 | part.reload 23 | part.body_items.where(index: index..).order(:index).each do |b| 24 | b.update(index: b.index - 1) 25 | end 26 | 27 | { part: part } 28 | end 29 | end 30 | 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /app/graphql/mutations/destroy_part.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class DestroyPart < BaseMutation 5 | argument :part_id, ID, required: true, loads: Types::PartType 6 | 7 | field :question, Types::QuestionType, null: false 8 | 9 | def authorized?(part:) 10 | return true if part.course.user_is_professor?(context[:current_user]) 11 | 12 | raise GraphQL::ExecutionError, 'You do not have permission.' 13 | end 14 | 15 | def resolve(part:) 16 | Part.transaction do 17 | question = part.question 18 | index = part.index 19 | destroyed = part.destroy 20 | raise GraphQL::ExecutionError, part.errors.full_messages.to_sentence unless destroyed 21 | 22 | question.reload 23 | question.parts.where(index: index..).order(:index).each do |p| 24 | p.update(index: p.index - 1) 25 | end 26 | 27 | { question: question } 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/graphql/mutations/destroy_question.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class DestroyQuestion < BaseMutation 5 | argument :question_id, ID, required: true, loads: Types::QuestionType 6 | 7 | field :exam_version, Types::ExamVersionType, null: false 8 | 9 | def authorized?(question:) 10 | return true if question.course.user_is_professor?(context[:current_user]) 11 | 12 | raise GraphQL::ExecutionError, 'You do not have permission.' 13 | end 14 | 15 | def resolve(question:) 16 | Question.transaction do 17 | exam_version = question.exam_version 18 | index = question.index 19 | destroyed = question.destroy 20 | raise GraphQL::ExecutionError, question.errors.full_messages.to_sentence unless destroyed 21 | 22 | exam_version.reload 23 | exam_version.db_questions.where(index: index..).order(:index).each do |q| 24 | q.update(index: q.index - 1) 25 | end 26 | 27 | { exam_version: exam_version } 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/graphql/mutations/impersonate_user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | # Mutation to start impersonating another user 5 | class ImpersonateUser < BaseMutation 6 | argument :user_id, ID, required: true, loads: Types::UserType 7 | argument :course_id, ID, required: false, loads: Types::CourseType 8 | 9 | field :success, Boolean, null: false 10 | 11 | def authorized?(user:, course: nil) 12 | return true if context[:true_user].admin? 13 | return true if course&.user_is_professor?(context[:true_user]) && course&.user_member?(user) 14 | 15 | raise GraphQL::ExecutionError, 'You do not have permission.' 16 | end 17 | 18 | def resolve(user:, **_args) 19 | context[:impersonate_user].call(user) 20 | { success: true } 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/graphql/mutations/publish_grades.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | # Mutation to up date an exam's administrative details 5 | class PublishGrades < BaseMutation 6 | argument :exam_id, ID, required: true, loads: Types::ExamType 7 | argument :published, Boolean, required: true 8 | 9 | field :exam, Types::ExamType, null: false 10 | field :published, Boolean, null: false 11 | field :count, Integer, null: false 12 | 13 | def authorized?(exam:, **_args) 14 | return true if exam.user_is_professor?(context[:current_user]) 15 | 16 | raise GraphQL::ExecutionError, 'You do not have permission.' 17 | end 18 | 19 | def resolve(exam:, published:) 20 | Registration.transaction do 21 | updated = exam.registrations.update_all(published: published) 22 | raise GraphQL::ExecutionError, exam.errors.full_messages.to_sentence unless updated 23 | 24 | cache_authorization!(exam, exam.course) 25 | { exam: exam, published: published, count: exam.registrations.count } 26 | end 27 | rescue ActiveRecord::RecordInvalid => exception 28 | raise GraphQL::ExecutionError, exception.message 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/graphql/mutations/release_all_grading_locks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class ReleaseAllGradingLocks < BaseMutation 5 | argument :exam_id, ID, required: true, loads: Types::ExamType 6 | 7 | field :released, Boolean, null: false 8 | 9 | def authorized?(exam:, **_args) 10 | return true if exam.course.professors.exists? context[:current_user].id 11 | 12 | raise GraphQL::ExecutionError, 'You do not have permission.' 13 | end 14 | 15 | def resolve(exam:) 16 | exam.initialize_grading_locks!(reset: true) 17 | { released: true } 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/graphql/mutations/reorder_body_items.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class ReorderBodyItems < BaseMutation 5 | argument :part_id, ID, required: true, loads: Types::PartType 6 | argument :from_index, Integer, required: true 7 | argument :to_index, Integer, required: true 8 | 9 | field :part, Types::PartType, null: false 10 | 11 | def authorized?(part:, **_kwargs) 12 | return true if part.course.user_is_professor?(context[:current_user]) 13 | 14 | raise GraphQL::ExecutionError, 'You do not have permission.' 15 | end 16 | 17 | def resolve(part:, from_index:, to_index:) 18 | part.move_body_items(from_index, to_index) 19 | 20 | { part: part } 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/graphql/mutations/reorder_parts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class ReorderParts < BaseMutation 5 | argument :question_id, ID, required: true, loads: Types::QuestionType 6 | argument :from_index, Integer, required: true 7 | argument :to_index, Integer, required: true 8 | 9 | field :question, Types::QuestionType, null: false 10 | 11 | def authorized?(question:, **_kwargs) 12 | return true if question.course.user_is_professor?(context[:current_user]) 13 | 14 | raise GraphQL::ExecutionError, 'You do not have permission.' 15 | end 16 | 17 | def resolve(question:, from_index:, to_index:) 18 | question.move_parts(from_index, to_index) 19 | 20 | { question: question } 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/graphql/mutations/reorder_preset_comments.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class ReorderPresetComments < BaseMutation 5 | argument :rubric_preset_id, ID, required: true, loads: Types::RubricPresetType 6 | argument :from_index, Integer, required: true 7 | argument :to_index, Integer, required: true 8 | 9 | field :rubric_preset, Types::RubricPresetType, null: false 10 | 11 | def authorized?(rubric_preset:, **_kwargs) 12 | return true if rubric_preset.exam_version.course.user_is_professor?(context[:current_user]) 13 | 14 | raise GraphQL::ExecutionError, 'You do not have permission.' 15 | end 16 | 17 | def resolve(rubric_preset:, from_index:, to_index:) 18 | rubric_preset.move_preset_comments(from_index, to_index) 19 | 20 | { rubric_preset: rubric_preset } 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/graphql/mutations/reorder_questions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class ReorderQuestions < BaseMutation 5 | argument :exam_version_id, ID, required: true, loads: Types::ExamVersionType 6 | argument :from_index, Integer, required: true 7 | argument :to_index, Integer, required: true 8 | 9 | field :exam_version, Types::ExamVersionType, null: false 10 | 11 | def authorized?(exam_version:, **_kwargs) 12 | return true if exam_version.course.user_is_professor?(context[:current_user]) 13 | 14 | raise GraphQL::ExecutionError, 'You do not have permission.' 15 | end 16 | 17 | def resolve(exam_version:, from_index:, to_index:) 18 | exam_version.move_questions(from_index, to_index) 19 | 20 | { exam_version: exam_version } 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/graphql/mutations/reorder_rubrics.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class ReorderRubrics < BaseMutation 5 | argument :parent_section_id, ID, required: true, loads: Types::RubricType 6 | argument :from_index, Integer, required: true 7 | argument :to_index, Integer, required: true 8 | 9 | field :parent_section, Types::RubricType, null: false 10 | 11 | def authorized?(parent_section:, **_kwargs) 12 | return true if parent_section.course.user_is_professor?(context[:current_user]) 13 | 14 | raise GraphQL::ExecutionError, 'You do not have permission.' 15 | end 16 | 17 | def resolve(parent_section:, from_index:, to_index:) 18 | parent_section.move_subsections(from_index, to_index) 19 | 20 | { parent_section: parent_section } 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/graphql/mutations/stop_impersonating.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class StopImpersonating < BaseMutation 5 | field :success, Boolean, null: false 6 | 7 | def authorized? 8 | return true if context[:true_user] != context[:current_user] 9 | 10 | raise GraphQL::ExecutionError, 'You are not currently impersonating another user.' 11 | end 12 | 13 | def resolve 14 | context[:stop_impersonating_user].call 15 | { success: true } 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/graphql/mutations/sync_course_to_bottlenose.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | # Mutation to synchronize course enrollments with Bottlenose course 5 | class SyncCourseToBottlenose < BaseMutation 6 | argument :course_id, ID, required: true, loads: Types::CourseType 7 | 8 | field :course, Types::CourseType, null: false 9 | 10 | def authorized?(course:) 11 | return true if course.user_is_professor?(context[:current_user]) 12 | 13 | raise GraphQL::ExecutionError, 'You do not have permission.' 14 | end 15 | 16 | def resolve(course:) 17 | context[:bottlenose_api].sync_course_regs(course) 18 | cache_authorization!(nil, course) 19 | { course: course } 20 | rescue Bottlenose::UnauthorizedError => e 21 | raise GraphQL::ExecutionError, e.message 22 | rescue Bottlenose::ApiError => e 23 | raise GraphQL::ExecutionError, e.message 24 | rescue Bottlenose::ConnectionFailed => e 25 | raise GraphQL::ExecutionError, e.message 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/graphql/mutations/sync_exam_to_bottlenose.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | # Mutation to synchronize exam grades with Bottlenose course 5 | class SyncExamToBottlenose < BaseMutation 6 | argument :exam_id, ID, required: true, loads: Types::ExamType 7 | 8 | field :exam, Types::ExamType, null: false 9 | 10 | def authorized?(exam:) 11 | return true if exam.user_is_professor?(context[:current_user]) 12 | 13 | raise GraphQL::ExecutionError, 'You do not have permission.' 14 | end 15 | 16 | def resolve(exam:) 17 | context[:bottlenose_api].create_exam(exam) 18 | cache_authorization!(exam, exam.course) 19 | { exam: exam } 20 | rescue Bottlenose::UnauthorizedError => e 21 | raise GraphQL::ExecutionError, e.message 22 | rescue Bottlenose::ApiError => e 23 | raise GraphQL::ExecutionError, e.message 24 | rescue Bottlenose::ConnectionFailed => e 25 | raise GraphQL::ExecutionError, e.message 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/graphql/mutations/update_accommodation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | # Mutation to update a student's exam accommodations 5 | class UpdateAccommodation < BaseMutation 6 | argument :accommodation_id, ID, required: true, loads: Types::AccommodationType 7 | argument :new_start_time, GraphQL::Types::ISO8601DateTime, required: false 8 | argument :percent_time_expansion, Integer, required: true 9 | argument :policy_exemptions, [Types::PolicyExemptionType], required: false 10 | 11 | field :accommodation, Types::AccommodationType, null: true 12 | 13 | def authorized?(accommodation:, **_args) 14 | return true if accommodation.exam.user_is_professor?(context[:current_user]) 15 | 16 | raise GraphQL::ExecutionError, 'You do not have permission.' 17 | end 18 | 19 | def resolve(accommodation:, **args) 20 | args[:new_start_time] = nil unless args.key?(:new_start_time) 21 | args[:policy_exemptions] = args[:policy_exemptions].join(',') if args[:policy_exemptions] 22 | updated = accommodation.update(args) 23 | raise GraphQL::ExecutionError, accommodation.errors.full_messages unless updated 24 | 25 | { accommodation: accommodation } 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/graphql/mutations/update_exam.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | # Mutation to up date an exam's administrative details 5 | class UpdateExam < BaseMutation 6 | argument :exam_id, ID, required: true, loads: Types::ExamType 7 | argument :name, String, required: true 8 | argument :duration, Integer, required: true 9 | argument :start_time, GraphQL::Types::ISO8601DateTime, required: true 10 | argument :end_time, GraphQL::Types::ISO8601DateTime, required: true 11 | 12 | field :exam, Types::ExamType, null: true 13 | 14 | def authorized?(exam:, **_args) 15 | return true if exam.user_is_professor?(context[:current_user]) 16 | 17 | raise GraphQL::ExecutionError, 'You do not have permission.' 18 | end 19 | 20 | def resolve(exam:, **args) 21 | updated = exam.update(args) 22 | raise GraphQL::ExecutionError, exam.errors.full_messages.to_sentence unless updated 23 | 24 | cache_authorization!(exam, exam.course) 25 | { exam: exam } 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/graphql/mutations/update_version_timing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | # Mutation to up date an exam's administrative details 5 | class UpdateVersionTiming < BaseMutation 6 | argument :exam_version_id, ID, required: true, loads: Types::ExamVersionType 7 | argument :duration, Integer, required: false 8 | argument :start_time, GraphQL::Types::ISO8601DateTime, required: false 9 | argument :end_time, GraphQL::Types::ISO8601DateTime, required: false 10 | 11 | field :exam_version, Types::ExamVersionType, null: false 12 | field :exam, Types::ExamType, null: false 13 | 14 | def authorized?(exam_version:, **_args) 15 | return true if exam_version.course.user_is_professor?(context[:current_user]) 16 | 17 | raise GraphQL::ExecutionError, 'You do not have permission.' 18 | end 19 | 20 | def resolve(exam_version:, **args) 21 | updated = exam_version.update(args) 22 | raise GraphQL::ExecutionError, exam_version.errors.full_messages.to_sentence unless updated 23 | 24 | cache_authorization!(exam_version.exam, exam_version.course) 25 | { exam_version: exam_version, exam: exam_version.exam } 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/graphql/record_loader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # from https://github.com/Shopify/graphql-batch/blob/master/examples/record_loader.rb 4 | class RecordLoader < GraphQL::Batch::Loader 5 | def initialize(model, column: model.primary_key, where: nil, includes: nil) 6 | @model = model 7 | @column = column.to_s 8 | @column_type = model.type_for_attribute(@column) 9 | @where = where 10 | @includes = includes 11 | end 12 | 13 | def load(key) 14 | super(@column_type.cast(key)) 15 | end 16 | 17 | def perform(keys) 18 | query(keys).each { |record| fulfill(record.public_send(@column), record) } 19 | keys.each { |key| fulfill(key, nil) unless fulfilled?(key) } 20 | end 21 | 22 | private 23 | 24 | def query(keys) 25 | scope = @model 26 | scope = scope.where(@where) if @where.present? 27 | scope = scope.includes(@includes) if @includes.present? 28 | scope.where(@column => keys) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/graphql/subscriptions/anomaly_was_created.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Subscriptions 4 | class AnomalyWasCreated < Subscriptions::BaseSubscription 5 | argument :exam_id, ID, required: true, loads: Types::ExamType 6 | 7 | field :anomaly, Types::AnomalyType, null: false 8 | field :anomalies_connection, Types::AnomalyType.connection_type, null: false 9 | field :anomaly_edge, Types::AnomalyType.edge_type, null: false 10 | 11 | def authorized?(exam:) 12 | return true if exam.user_is_proctor?(context[:current_user]) 13 | return true if exam.user_is_professor?(context[:current_user]) 14 | 15 | raise GraphQL::ExecutionError, 'You do not have permission.' 16 | end 17 | 18 | def update(exam:) 19 | range_add = GraphQL::Relay::RangeAdd.new(parent: exam, collection: exam.anomalies, item: object, context: context) 20 | 21 | { 22 | anomaly: object, 23 | anomalies_connection: range_add.connection, 24 | anomaly_edge: range_add.edge, 25 | } 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/graphql/subscriptions/anomaly_was_destroyed.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Subscriptions 4 | class AnomalyWasDestroyed < Subscriptions::BaseSubscription 5 | argument :exam_id, ID, required: true, loads: Types::ExamType 6 | 7 | field :deleted_id, ID, null: false 8 | 9 | def authorized?(exam:) 10 | return true if exam.proctors.exists? context[:current_user].id 11 | return true if exam.professors.exists? context[:current_user].id 12 | 13 | raise GraphQL::ExecutionError, 'You do not have permission.' 14 | end 15 | 16 | def update(*) 17 | { deleted_id: object } 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/graphql/subscriptions/base_subscription.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Subscriptions 4 | # Base class for app-specific GraphQL subscriptions 5 | class BaseSubscription < GraphQL::Schema::Subscription 6 | object_class Types::BaseObject 7 | field_class Types::BaseField 8 | argument_class Types::BaseArgument 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/graphql/subscriptions/exam_announcement_was_sent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Subscriptions 4 | class ExamAnnouncementWasSent < Subscriptions::BaseSubscription 5 | argument :exam_id, ID, required: true, loads: Types::ExamType 6 | 7 | field :exam_announcement, Types::ExamAnnouncementType, null: false 8 | field :exam_announcements_edge, Types::ExamAnnouncementType.edge_type, null: false 9 | 10 | def authorized?(exam:) 11 | return true if exam.students.exists? context[:current_user].id 12 | return true if exam.proctors.exists? context[:current_user].id 13 | return true if exam.professors.exists? context[:current_user].id 14 | 15 | raise GraphQL::ExecutionError, 'You do not have permission.' 16 | end 17 | 18 | def update(exam:) 19 | range_add = GraphQL::Relay::RangeAdd.new( 20 | parent: exam, 21 | collection: exam.exam_announcements, 22 | item: object, 23 | context: context, 24 | ) 25 | { 26 | exam_announcement: object, 27 | exam_announcements_edge: range_add.edge, 28 | } 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/graphql/subscriptions/grading_lock_updated.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Subscriptions 4 | class GradingLockUpdated < Subscriptions::BaseSubscription 5 | argument :exam_id, ID, required: true, loads: Types::ExamType 6 | 7 | field :grading_lock, Types::GradingLockType, null: false 8 | field :exam, Types::ExamType, null: false 9 | 10 | def authorized?(exam:) 11 | return true if exam.proctors.exists? context[:current_user].id 12 | return true if exam.professors.exists? context[:current_user].id 13 | 14 | raise GraphQL::ExecutionError, 'You do not have permission.' 15 | end 16 | 17 | def update(exam:) 18 | { 19 | grading_lock: object, 20 | exam: exam 21 | } 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/graphql/subscriptions/message_received.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Subscriptions 4 | class MessageReceived < Subscriptions::BaseSubscription 5 | argument :registration_id, ID, required: true, loads: Types::RegistrationType 6 | 7 | field :message, Types::MessageType, null: false 8 | field :messages_edge, Types::MessageType.edge_type, null: false 9 | 10 | def authorized?(registration:) 11 | return true if registration.user == context[:current_user] 12 | 13 | raise GraphQL::ExecutionError, 'You do not have permission.' 14 | end 15 | 16 | def update(registration:) 17 | range_add = GraphQL::Relay::RangeAdd.new( 18 | parent: registration, 19 | collection: registration.messages, 20 | item: object, 21 | context: context, 22 | ) 23 | 24 | { 25 | message: object, 26 | messages_edge: range_add.edge, 27 | } 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/graphql/subscriptions/message_was_sent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Subscriptions 4 | class MessageWasSent < Subscriptions::BaseSubscription 5 | argument :exam_id, ID, required: true, loads: Types::ExamType 6 | 7 | field :message, Types::MessageType, null: false 8 | field :messages_edge, Types::MessageType.edge_type, null: false 9 | 10 | def authorized?(exam:) 11 | return true if exam.proctors.exists? context[:current_user].id 12 | return true if exam.professors.exists? context[:current_user].id 13 | 14 | raise GraphQL::ExecutionError, 'You do not have permission.' 15 | end 16 | 17 | def update(exam:) 18 | range_add = GraphQL::Relay::RangeAdd.new( 19 | parent: exam, 20 | collection: exam.messages, 21 | item: object, 22 | context: context, 23 | ) 24 | { 25 | message: object, 26 | messages_edge: range_add.edge, 27 | } 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/graphql/subscriptions/question_was_asked.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Subscriptions 4 | class QuestionWasAsked < Subscriptions::BaseSubscription 5 | argument :exam_id, ID, required: true, loads: Types::ExamType 6 | 7 | field :student_question, Types::StudentQuestionType, null: false 8 | field :student_questions_edge, Types::StudentQuestionType.edge_type, null: false 9 | 10 | def authorized?(exam:) 11 | return true if exam.proctors_and_professors.exists? context[:current_user].id 12 | 13 | raise GraphQL::ExecutionError, 'You do not have permission.' 14 | end 15 | 16 | def update(exam:) 17 | range_add = GraphQL::Relay::RangeAdd.new(parent: exam, collection: exam.student_questions, item: object, context: context) 18 | { 19 | student_question: object, 20 | student_questions_edge: range_add.edge, 21 | } 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/graphql/subscriptions/registration_was_updated.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Subscriptions 4 | class RegistrationWasUpdated < Subscriptions::BaseSubscription 5 | argument :exam_id, ID, required: true, loads: Types::ExamType 6 | 7 | field :registration, Types::RegistrationType, null: false 8 | 9 | def authorized?(exam:) 10 | return true if exam.proctors.exists? context[:current_user].id 11 | return true if exam.professors.exists? context[:current_user].id 12 | 13 | raise GraphQL::ExecutionError, 'You do not have permission.' 14 | end 15 | 16 | def update(exam:) 17 | { 18 | registration: object, 19 | } 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/graphql/subscriptions/room_announcement_received.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Subscriptions 4 | class RoomAnnouncementReceived < Subscriptions::BaseSubscription 5 | argument :room_id, ID, required: true, loads: Types::RoomType 6 | 7 | field :room_announcement, Types::RoomAnnouncementType, null: false 8 | field :room_announcements_edge, Types::RoomAnnouncementType.edge_type, null: false 9 | 10 | def authorized?(room:) 11 | return true if room.students.exists? context[:current_user].id 12 | 13 | raise GraphQL::ExecutionError, 'You do not have permission.' 14 | end 15 | 16 | def update(room:) 17 | range_add = GraphQL::Relay::RangeAdd.new( 18 | parent: room, 19 | collection: room.room_announcements, 20 | item: object, 21 | context: context, 22 | ) 23 | { 24 | room_announcement: object, 25 | room_announcements_edge: range_add.edge, 26 | } 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/graphql/subscriptions/room_announcement_was_sent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Subscriptions 4 | class RoomAnnouncementWasSent < Subscriptions::BaseSubscription 5 | argument :exam_id, ID, required: true, loads: Types::ExamType 6 | 7 | field :room_announcement, Types::RoomAnnouncementType, null: false 8 | field :room_announcements_edge, Types::RoomAnnouncementType.edge_type, null: false 9 | 10 | def authorized?(exam:) 11 | return true if exam.proctors.exists? context[:current_user].id 12 | return true if exam.professors.exists? context[:current_user].id 13 | 14 | raise GraphQL::ExecutionError, 'You do not have permission.' 15 | end 16 | 17 | def update(exam:) 18 | range_add = GraphQL::Relay::RangeAdd.new( 19 | parent: exam, 20 | collection: exam.room_announcements, 21 | item: object, 22 | context: context, 23 | ) 24 | { 25 | room_announcement: object, 26 | room_announcements_edge: range_add.edge, 27 | } 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/graphql/subscriptions/version_announcement_received.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Subscriptions 4 | class VersionAnnouncementReceived < Subscriptions::BaseSubscription 5 | argument :exam_version_id, ID, required: true, loads: Types::ExamVersionType 6 | 7 | field :version_announcement, Types::VersionAnnouncementType, null: false 8 | field :version_announcements_edge, Types::VersionAnnouncementType.edge_type, null: false 9 | 10 | def authorized?(exam_version:) 11 | return true if exam_version.students.exists? context[:current_user].id 12 | 13 | raise GraphQL::ExecutionError, 'You do not have permission.' 14 | end 15 | 16 | def update(exam_version:) 17 | range_add = GraphQL::Relay::RangeAdd.new( 18 | parent: exam_version, 19 | collection: exam_version.version_announcements, 20 | item: object, 21 | context: context, 22 | ) 23 | { 24 | version_announcement: object, 25 | version_announcements_edge: range_add.edge, 26 | } 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/graphql/subscriptions/version_announcement_was_sent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Subscriptions 4 | class VersionAnnouncementWasSent < Subscriptions::BaseSubscription 5 | argument :exam_id, ID, required: true, loads: Types::ExamType 6 | 7 | field :version_announcement, Types::VersionAnnouncementType, null: false 8 | field :version_announcements_edge, Types::VersionAnnouncementType.edge_type, null: false 9 | 10 | def authorized?(exam:) 11 | return true if exam.proctors.exists? context[:current_user].id 12 | return true if exam.professors.exists? context[:current_user].id 13 | 14 | raise GraphQL::ExecutionError, 'You do not have permission.' 15 | end 16 | 17 | def update(exam:) 18 | range_add = GraphQL::Relay::RangeAdd.new( 19 | parent: exam, 20 | collection: exam.version_announcements, 21 | item: object, 22 | context: context, 23 | ) 24 | { 25 | version_announcement: object, 26 | version_announcements_edge: range_add.edge, 27 | } 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/graphql/types/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/app/graphql/types/.keep -------------------------------------------------------------------------------- /app/graphql/types/accommodation_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class AccommodationType < Types::BaseObject 5 | implements GraphQL::Types::Relay::Node 6 | global_id_field :id 7 | 8 | guard Guards::VISIBILITY 9 | 10 | description 'Accommodates a student with additional/alternate times, or alternate policies.' 11 | 12 | field :registration, Types::RegistrationType, null: false 13 | def registration 14 | RecordLoader.for(Registration).load(object.registration_id) 15 | end 16 | field :new_start_time, GraphQL::Types::ISO8601DateTime, null: true 17 | field :percent_time_expansion, Integer, null: false 18 | field :policy_exemptions, [Types::PolicyExemptionType], null: true 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/graphql/types/anomaly_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class AnomalyType < Types::BaseObject 5 | implements GraphQL::Types::Relay::Node 6 | global_id_field :id 7 | 8 | guard Guards::VISIBILITY 9 | 10 | field :reason, String, null: false 11 | 12 | field :registration, Types::RegistrationType, null: false 13 | def registration 14 | RecordLoader.for(Registration).load(object.registration_id) 15 | end 16 | 17 | field :created_at, GraphQL::Types::ISO8601DateTime, null: false 18 | 19 | field :prior_anomaly_count, Integer, null: false 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/graphql/types/base_argument.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class BaseArgument < GraphQL::Schema::Argument 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/graphql/types/base_enum.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class BaseEnum < GraphQL::Schema::Enum 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/graphql/types/base_field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class BaseField < GraphQL::Schema::Field 5 | argument_class Types::BaseArgument 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/graphql/types/base_input_object.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class BaseInputObject < GraphQL::Schema::InputObject 5 | argument_class Types::BaseArgument 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/graphql/types/base_interface.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | module BaseInterface 5 | include GraphQL::Schema::Interface 6 | 7 | field_class Types::BaseField 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/graphql/types/base_scalar.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class BaseScalar < GraphQL::Schema::Scalar 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/graphql/types/base_union.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class BaseUnion < GraphQL::Schema::Union 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/graphql/types/body_item_type.rb: -------------------------------------------------------------------------------- 1 | module Types 2 | class BodyItemType < Types::BaseObject 3 | implements GraphQL::Types::Relay::Node 4 | global_id_field :id 5 | 6 | guard Guards::VISIBILITY 7 | 8 | field :info, GraphQL::Types::JSON, null: false 9 | field :index, Integer, null: false 10 | 11 | field :answer, GraphQL::Types::JSON, null: true do 12 | guard Guards::ALL_STAFF 13 | end 14 | def answer 15 | if object.info['type'] == "Matching" 16 | object.answer.map{|i| i || -1} 17 | else 18 | object.answer 19 | end 20 | end 21 | 22 | field :rubrics, [Types::RubricType], null: false do 23 | guard Guards::ALL_STAFF 24 | end 25 | def rubrics 26 | AssociationLoader.for(BodyItem, :rubrics).load(object) 27 | end 28 | 29 | field :root_rubric, Types::RubricType, null: false do 30 | guard Guards::ALL_STAFF 31 | end 32 | def root_rubric 33 | AssociationLoader.for(BodyItem, :rubrics, merge: -> { body_item_root_rubrics }).load(object).then(&:first) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /app/graphql/types/checklist_item_status_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class ChecklistItemStatusType < Types::BaseEnum 5 | value 'NOT_STARTED', value: :not_started 6 | value 'COMPLETE', value: :complete 7 | value 'NA', value: :na 8 | value 'WARNING', value: :warning 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/graphql/types/code_tag_choice_type.rb: -------------------------------------------------------------------------------- 1 | module Types 2 | class CodeTagChoiceType < Types::BaseEnum 3 | value 'exam' 4 | value 'question' 5 | value 'part' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/graphql/types/code_tag_input_type.rb: -------------------------------------------------------------------------------- 1 | module Types 2 | class CodeTagInputType < Types::BaseInputObject 3 | argument :line_number, Integer, required: true 4 | argument :selected_file, String, required: false 5 | 6 | def prepare 7 | { 8 | lineNumber: line_number, 9 | selectedFile: selected_file 10 | }.compact 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/graphql/types/course_role_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class CourseRoleType < Types::BaseEnum 5 | value 'NONE', value: :none 6 | value 'STUDENT', value: :student 7 | value 'STAFF', value: :staff 8 | value 'PROCTOR', value: :proctor 9 | value 'PROFESSOR', value: :professor 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/graphql/types/exam_announcement_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | # Type describing exam announcements 5 | class ExamAnnouncementType < Types::BaseObject 6 | implements GraphQL::Types::Relay::Node 7 | global_id_field :id 8 | 9 | guard Guards::VISIBILITY 10 | 11 | field :exam_id, Integer, null: false 12 | field :body, String, null: false 13 | field :created_at, GraphQL::Types::ISO8601DateTime, null: false 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/graphql/types/exam_checklist_section_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class ExamChecklistSectionType < Types::BaseObject 5 | field :reason, String, null: false 6 | field :status, Types::ChecklistItemStatusType, null: false 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/graphql/types/exam_checklist_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class ExamChecklistType < Types::BaseObject 5 | field :rooms, Types::ExamChecklistSectionType, null: false 6 | field :timing, Types::ExamChecklistSectionType, null: false 7 | field :staff, Types::ExamChecklistSectionType, null: false 8 | field :seating, Types::ExamChecklistSectionType, null: false 9 | field :versions, Types::ExamChecklistSectionType, null: false 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/graphql/types/future_registration_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class FutureRegistrationType < Types::BaseObject 5 | implements GraphQL::Types::Relay::Node 6 | global_id_field :id 7 | 8 | guard Guards::VISIBILITY 9 | 10 | field :room, Types::RoomType, null: true 11 | def room 12 | RecordLoader.for(Room).load(object.room_id) 13 | end 14 | field :start_time, GraphQL::Types::ISO8601DateTime, null: true 15 | field :end_time, GraphQL::Types::ISO8601DateTime, null: true 16 | 17 | field :user, Types::UserType, null: false 18 | 19 | field :course_title, String, null: false 20 | def course_title 21 | object.exam.course.title 22 | end 23 | field :exam_name, String, null: false 24 | def exam_name 25 | object.exam.name 26 | end 27 | field :accommodated_start_time, GraphQL::Types::ISO8601DateTime, null: false 28 | def accommodated_start_time 29 | object.accommodated_start_time 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /app/graphql/types/grading_check_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class GradingCheckType < Types::BaseObject 5 | implements GraphQL::Types::Relay::Node 6 | global_id_field :id 7 | 8 | guard Guards::VISIBILITY 9 | 10 | field :qnum, Integer, null: false 11 | field :pnum, Integer, null: false 12 | field :bnum, Integer, null: false 13 | field :points, Float, null: true 14 | field :creator, Types::UserType, null: false 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/graphql/types/grading_comment_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class GradingCommentType < Types::BaseObject 5 | implements GraphQL::Types::Relay::Node 6 | global_id_field :id 7 | 8 | guard Guards::VISIBILITY 9 | 10 | field :preset_comment, Types::PresetCommentType, null: true 11 | def preset_comment 12 | RecordLoader.for(PresetComment).load(object.preset_comment_id) 13 | end 14 | field :qnum, Integer, null: false 15 | def qnum 16 | object.question.index 17 | end 18 | field :pnum, Integer, null: false 19 | def pnum 20 | object.part.index 21 | end 22 | field :bnum, Integer, null: false 23 | def bnum 24 | object.body_item.index 25 | end 26 | field :message, String, null: false 27 | field :points, Float, null: false 28 | field :creator, Types::UserType, null: false 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/graphql/types/html_input_type.rb: -------------------------------------------------------------------------------- 1 | module Types 2 | class HtmlInputType < Types::BaseInputObject 3 | argument :type, Types::HtmlTag, required: true 4 | argument :value, String, required: true 5 | 6 | def prepare 7 | { 8 | type: type, 9 | value: value 10 | } 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/graphql/types/html_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class HtmlTag < Types::BaseEnum 5 | value 'HTML' 6 | end 7 | class HtmlType < Types::BaseObject 8 | field :type, HtmlTag, null: false 9 | field :value, String, null: false 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/graphql/types/lockdown_policy_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class LockdownPolicyType < Types::BaseEnum 5 | value 'IGNORE_LOCKDOWN', "don't install anomaly handlers" 6 | value 'TOLERATE_WINDOWED', 'allow the browser to not be fullscreen' 7 | value 'MOCK_LOCKDOWN', 'install warning anomaly handlers' 8 | value 'STUDENT_PIN', 'require PIN for students to log in' 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/graphql/types/message_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class MessageType < Types::BaseObject 5 | implements GraphQL::Types::Relay::Node 6 | global_id_field :id 7 | 8 | guard Guards::VISIBILITY 9 | 10 | field :sender, Types::UserType, null: false do 11 | guard Guards::PROCTORS_AND_PROFESSORS 12 | end 13 | def sender 14 | RecordLoader.for(User).load(object.sender_id) 15 | end 16 | 17 | field :registration, Types::RegistrationType, null: false 18 | def registration 19 | RecordLoader.for(Registration).load(object.registration_id) 20 | end 21 | 22 | field :body, String, null: false 23 | field :created_at, GraphQL::Types::ISO8601DateTime, null: false 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/graphql/types/policy_exemption_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class PolicyExemptionType < Types::BaseEnum 5 | value 'IGNORE_LOCKDOWN', "don't install anomaly handlers" 6 | value 'TOLERATE_WINDOWED', 'allow the browser to not be fullscreen' 7 | value 'IGNORE_PIN', "don't require a PIN to unlock the exam" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/graphql/types/proctor_registration_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class ProctorRegistrationType < Types::BaseObject 5 | implements GraphQL::Types::Relay::Node 6 | global_id_field :id 7 | 8 | guard Guards::VISIBILITY 9 | 10 | field :user, Types::UserType, null: false 11 | def user 12 | RecordLoader.for(User).load(object.user_id) 13 | end 14 | 15 | field :exam, ExamType, null: false 16 | def exam 17 | RecordLoader.for(Exam).load(object.exam_id) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/graphql/types/professor_course_registration_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class ProfessorCourseRegistrationType < Types::BaseObject 5 | implements GraphQL::Types::Relay::Node 6 | global_id_field :id 7 | 8 | guard Guards::PROFESSORS 9 | 10 | field :course, CourseType, null: false 11 | def course 12 | RecordLoader.for(Course).load(object.course_id) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/graphql/types/reference_type.rb: -------------------------------------------------------------------------------- 1 | module Types 2 | class ReferenceTypeType < Types::BaseEnum 3 | value 'file' 4 | value 'dir' 5 | end 6 | 7 | class ReferenceType < Types::BaseObject 8 | implements GraphQL::Types::Relay::Node 9 | global_id_field :id 10 | 11 | field :path, String, null: false 12 | field :type, ReferenceTypeType, null: false 13 | end 14 | 15 | class ReferenceInputType < Types::BaseInputObject 16 | argument :id, ID, required: false, loads: Types::ReferenceType 17 | argument :path, String, required: true 18 | argument :type, ReferenceTypeType, required: true 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/graphql/types/room_announcement_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class RoomAnnouncementType < Types::BaseObject 5 | implements GraphQL::Types::Relay::Node 6 | global_id_field :id 7 | 8 | guard Guards::VISIBILITY 9 | 10 | field :room, Types::RoomType, null: false 11 | def room 12 | RecordLoader.for(Room).load(object.room_id) 13 | end 14 | field :body, String, null: false 15 | field :created_at, GraphQL::Types::ISO8601DateTime, null: false 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/graphql/types/room_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class RoomType < Types::BaseObject 5 | implements GraphQL::Types::Relay::Node 6 | global_id_field :id 7 | 8 | guard Guards::VISIBILITY 9 | 10 | field :name, String, null: false 11 | 12 | field :registrations, [Types::RegistrationType], null: false do 13 | guard Guards::PROCTORS_AND_PROFESSORS 14 | end 15 | def registrations 16 | AssociationLoader.for(Room, :registrations).load(object) 17 | end 18 | field :proctor_registrations, [Types::ProctorRegistrationType], null: false do 19 | guard Guards::PROFESSORS 20 | end 21 | def proctor_registrations 22 | AssociationLoader.for(Room, :proctor_registrations).load(object) 23 | end 24 | 25 | field :room_announcements, Types::RoomAnnouncementType.connection_type, null: false 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/graphql/types/section_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class SectionType < Types::BaseObject 5 | implements GraphQL::Types::Relay::Node 6 | global_id_field :id 7 | 8 | guard Guards::PROFESSORS 9 | 10 | field :title, String, null: false 11 | field :students, [Types::UserType], null: false 12 | def students 13 | AssociationLoader.for(Section, :students, merge: -> { order(display_name: :asc) }).load(object) 14 | end 15 | field :staff, [Types::UserType], null: false 16 | def staff 17 | AssociationLoader.for(Section, :staff, merge: -> { order(display_name: :asc) }).load(object) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/graphql/types/snapshot_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class SnapshotType < Types::BaseObject 5 | implements GraphQL::Types::Relay::Node 6 | global_id_field :id 7 | 8 | field :created_at, GraphQL::Types::ISO8601DateTime, null: false 9 | field :answers, GraphQL::Types::JSON, null: false 10 | end 11 | end -------------------------------------------------------------------------------- /app/graphql/types/staff_registration_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class StaffRegistrationType < Types::BaseObject 5 | implements GraphQL::Types::Relay::Node 6 | global_id_field :id 7 | 8 | guard Guards::VISIBILITY 9 | 10 | field :course, CourseType, null: false 11 | delegate :course, to: :object 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/graphql/types/student_question_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class StudentQuestionType < Types::BaseObject 5 | implements GraphQL::Types::Relay::Node 6 | global_id_field :id 7 | 8 | guard Guards::VISIBILITY 9 | 10 | field :registration, Types::RegistrationType, null: false 11 | def registration 12 | RecordLoader.for(Registration).load(object.registration_id) 13 | end 14 | field :body, String, null: false 15 | field :created_at, GraphQL::Types::ISO8601DateTime, null: false 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/graphql/types/subscription_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class SubscriptionType < GraphQL::Schema::Object 5 | field :anomaly_was_created, subscription: Subscriptions::AnomalyWasCreated 6 | field :anomaly_was_destroyed, subscription: Subscriptions::AnomalyWasDestroyed 7 | 8 | field :message_received, subscription: Subscriptions::MessageReceived 9 | field :message_was_sent, subscription: Subscriptions::MessageWasSent 10 | 11 | field :question_was_asked, subscription: Subscriptions::QuestionWasAsked 12 | 13 | field :exam_announcement_was_sent, subscription: Subscriptions::ExamAnnouncementWasSent 14 | 15 | field :version_announcement_received, subscription: Subscriptions::VersionAnnouncementReceived 16 | field :version_announcement_was_sent, subscription: Subscriptions::VersionAnnouncementWasSent 17 | 18 | field :room_announcement_received, subscription: Subscriptions::RoomAnnouncementReceived 19 | field :room_announcement_was_sent, subscription: Subscriptions::RoomAnnouncementWasSent 20 | 21 | field :registration_was_updated, subscription: Subscriptions::RegistrationWasUpdated 22 | 23 | field :grading_lock_updated, subscription: Subscriptions::GradingLockUpdated 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/graphql/types/version_announcement_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class VersionAnnouncementType < Types::BaseObject 5 | implements GraphQL::Types::Relay::Node 6 | global_id_field :id 7 | 8 | guard Guards::VISIBILITY 9 | 10 | field :exam_version, Types::ExamVersionType, null: false 11 | def exam_version 12 | RecordLoader.for(ExamVersion).load(object.exam_version_id) 13 | end 14 | field :body, String, null: false 15 | field :created_at, GraphQL::Types::ISO8601DateTime, null: false 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationJob < ActiveJob::Base 4 | end 5 | -------------------------------------------------------------------------------- /app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Boilerplate class 4 | class ApplicationMailer < ActionMailer::Base 5 | default from: 'from@example.com' 6 | layout 'mailer' 7 | end 8 | -------------------------------------------------------------------------------- /app/models/accommodation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Special accommodations for a student's exam. 4 | # Extra time / alternate start times. 5 | class Accommodation < ApplicationRecord 6 | belongs_to :registration 7 | 8 | validates :registration, uniqueness: { message: 'already has an accommodation' } 9 | validates :percent_time_expansion, numericality: { 10 | only_integer: true, 11 | greater_than_or_equal_to: 0, 12 | } 13 | 14 | has_one :user, through: :registration 15 | has_one :exam, through: :registration 16 | has_one :exam_version, through: :registration 17 | has_one :course, through: :exam 18 | 19 | def user 20 | super || registration.try(:user) 21 | end 22 | 23 | def exam 24 | super || registration.try(:exam) 25 | end 26 | 27 | def exam_version 28 | super || registration.try(:exam_version) 29 | end 30 | 31 | def course 32 | super || exam.try(:course) 33 | end 34 | 35 | def factor 36 | (percent_time_expansion.to_f / 100.0) + 1.0 37 | end 38 | 39 | def visible_to?(check_user, role_for_exam, _role_for_course) 40 | (role_for_exam >= Exam.roles[:professor]) || course.professors.exists?(check_user.id) 41 | end 42 | 43 | def policy_exemptions 44 | self[:policy_exemptions].to_s.split ',' 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /app/models/anomaly.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Anomalies for student registrations. 4 | class Anomaly < ApplicationRecord 5 | belongs_to :registration 6 | 7 | after_create :trigger_subscription 8 | 9 | has_one :user, through: :registration 10 | has_one :exam, through: :registration 11 | has_one :exam_version, through: :registration 12 | 13 | def user 14 | super || registration.try(:user) 15 | end 16 | 17 | def exam 18 | super || registration.try(:exam) 19 | end 20 | 21 | def exam_version 22 | super || registration.try(:exam_version) 23 | end 24 | 25 | scope :unforgiven, -> { where(forgiven: false) } 26 | scope :forgiven, -> { where(forgiven: true) } 27 | 28 | def trigger_subscription 29 | exam_id = HourglassSchema.id_from_object(exam, Types::ExamType, {}) 30 | HourglassSchema.subscriptions.trigger(:anomaly_was_created, { exam_id: exam_id }, self) 31 | end 32 | 33 | def visible_to?(check_user, role_for_exam, _role_for_course) 34 | (role_for_exam >= Exam.roles[:proctor]) || exam.proctors_and_professors.exists?(check_user.id) 35 | end 36 | 37 | def prior_anomaly_count 38 | registration.anomalies.count - 1 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/app/models/concerns/.keep -------------------------------------------------------------------------------- /app/models/exam_announcement.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # An announcement sent during an exam to all registered students. 4 | class ExamAnnouncement < ApplicationRecord 5 | belongs_to :exam 6 | 7 | validates :body, presence: true 8 | 9 | delegate :visible_to?, to: :exam 10 | end 11 | -------------------------------------------------------------------------------- /app/models/message.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A message sent from a professor to a student during an exam. 4 | class Message < ApplicationRecord 5 | belongs_to :sender, class_name: 'User' 6 | belongs_to :registration 7 | 8 | validates :body, presence: true, length: { maximum: 2000 } 9 | 10 | has_one :user, through: :registration 11 | has_one :exam, through: :registration 12 | delegate :proctors_and_professors, to: :exam 13 | delegate :visible_to?, to: :registration 14 | 15 | def user 16 | super || registration.try(:user) 17 | end 18 | 19 | def exam 20 | super || registration.try(:exam) 21 | end 22 | 23 | validate :sent_by_proctor, if: :sender 24 | 25 | def sent_by_proctor 26 | return if exam.proctors_and_professors.exists? sender.id 27 | 28 | errors.add(:sender, 'must be a proctor or professor') 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/models/preset_comment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Individual preset (grader suggestion and student feedback) comments for grading 4 | class PresetComment < ApplicationRecord 5 | belongs_to :rubric_preset 6 | # DON'T delete comments, but make them Unknown 7 | has_many :grading_comments, dependent: :nullify 8 | 9 | has_one :exam_version, through: :rubric_preset 10 | has_one :exam, through: :exam_version 11 | 12 | def exam_version 13 | super || rubric_preset.try(:exam_version) 14 | end 15 | 16 | def exam 17 | super || exam_version.try(:exam) 18 | end 19 | 20 | def in_use? 21 | !grading_comments.empty? 22 | end 23 | 24 | def as_json(preset_comments_in_use = nil, format:) 25 | { 26 | label: label, 27 | graderHint: grader_hint, 28 | studentFeedback: student_feedback, 29 | points: points, 30 | inUse: 31 | if format == :export 32 | nil 33 | elsif preset_comments_in_use.nil? 34 | in_use? 35 | else 36 | preset_comments_in_use.member? id 37 | end, 38 | }.compact 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /app/models/proctor_registration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Registrations for proctors to an exam, and optional room. 4 | class ProctorRegistration < ApplicationRecord 5 | belongs_to :user 6 | belongs_to :exam 7 | 8 | belongs_to :room, optional: true 9 | 10 | validate :room_in_exam 11 | 12 | has_one :course, through: :exam 13 | delegate :professors, to: :exam 14 | 15 | def course 16 | super || exam.try(:course) 17 | end 18 | 19 | def room_in_exam 20 | return unless room 21 | 22 | return if room.exam == exam 23 | 24 | errors.add(:room, 'needs to be part of the correct exam') 25 | end 26 | 27 | def visible_to?(check_user, role_for_exam, _role_for_course) 28 | ((user_id || user&.id) == check_user.id) || 29 | (role_for_exam >= Exam.roles[:professor]) || 30 | professors.exists?(check_user.id) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /app/models/professor_course_registration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Registrations for professors to a course. 4 | class ProfessorCourseRegistration < ApplicationRecord 5 | belongs_to :course 6 | belongs_to :user 7 | 8 | delegate :professors, to: :course 9 | end 10 | -------------------------------------------------------------------------------- /app/models/room.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A room where students can take an exam 4 | class Room < ApplicationRecord 5 | belongs_to :exam 6 | has_many :registrations, dependent: :restrict_with_error 7 | has_many :proctor_registrations, dependent: :restrict_with_error 8 | has_many :room_announcements, dependent: :destroy 9 | 10 | validates :name, presence: true 11 | 12 | delegate :professors, to: :exam 13 | delegate :proctors_and_professors, to: :exam 14 | 15 | def finalized? 16 | registrations.in_progress.empty? 17 | end 18 | 19 | def finalize! 20 | registrations.each(&:finalize!) 21 | end 22 | 23 | def has_staff? 24 | proctor_registrations.exists? 25 | end 26 | 27 | def has_registrations? 28 | registrations.exists? 29 | end 30 | 31 | def students 32 | User.where(id: registrations.select(:user_id)) 33 | end 34 | 35 | def visible_to?(check_user, role_for_exam, _role_for_course) 36 | (role_for_exam >= Exam.roles[:student] && role_for_exam != Exam.roles[:staff]) || 37 | proctors_and_professors.or(students).exists?(check_user.id) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /app/models/room_announcement.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # An announcement sent during an exam to a room. 4 | class RoomAnnouncement < ApplicationRecord 5 | belongs_to :room 6 | 7 | validates :body, presence: true 8 | 9 | has_one :exam, through: :room 10 | delegate :visible_to?, to: :room 11 | 12 | def exam 13 | super || room.try(:exam) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/models/rubric_tree_path.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Rubrics in an exam form a tree, whose leaves are RubricPresets 4 | class RubricTreePath < ApplicationRecord 5 | belongs_to :ancestor, class_name: 'Rubric' 6 | belongs_to :descendant, class_name: 'Rubric' 7 | end 8 | -------------------------------------------------------------------------------- /app/models/rubrics/all.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A rubric section where all subsections must be used 4 | class All < Rubric 5 | def total_points 6 | if rubric_preset 7 | rubric_preset.total_points 8 | else 9 | subsections.sum(&:total_points) 10 | end 11 | end 12 | 13 | def out_of 14 | if rubric_preset 15 | rubric_preset.total_points 16 | else 17 | subsections.sum(&:out_of) 18 | end 19 | end 20 | 21 | protected 22 | 23 | def confirm_complete(reg, comments, checks) 24 | if rubric_preset 25 | preset_ids = rubric_preset.preset_comment_ids 26 | (slice_hash_on_qpb(comments, is_hash: true)&.slice(*preset_ids)&.count.to_i + 27 | slice_hash_on_qpb(checks, is_hash: false)&.count.to_i) == preset_ids.count 28 | else 29 | subsections.all? { |s| s.send(:confirm_complete, reg, comments, checks) } 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /app/models/rubrics/any.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A rubric subsection where any of the comments may be used 4 | class Any < Rubric 5 | def total_points 6 | if rubric_preset 7 | rubric_preset.total_points 8 | else 9 | points 10 | end 11 | end 12 | 13 | protected 14 | 15 | def confirm_complete(reg, comments, checks) 16 | if rubric_preset 17 | preset_ids = rubric_preset.preset_comment_ids 18 | (slice_hash_on_qpb(comments, is_hash: true)&.slice(*preset_ids)&.count.to_i + 19 | slice_hash_on_qpb(checks, is_hash: false)&.count.to_i) >= 1 20 | else 21 | subsections.any? { |s| s.send(:confirm_complete, reg, comments, checks) } 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/models/rubrics/none.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # An empty section of the rubric 4 | class None < Rubric 5 | def total_points 6 | 0 7 | end 8 | 9 | def out_of 10 | 0 11 | end 12 | 13 | def as_json(_preset_comments_in_use, format:) 14 | { 15 | type: 'none', 16 | inUse: false, 17 | } 18 | end 19 | 20 | protected 21 | 22 | def confirm_complete(_reg, _comments, _checks) 23 | true 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/models/section.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A section of a course. 4 | class Section < ApplicationRecord 5 | belongs_to :course 6 | 7 | has_many :student_registrations, dependent: :destroy 8 | has_many :staff_registrations, dependent: :destroy 9 | 10 | has_many :students, through: :student_registrations, source: :user 11 | has_many :staff, through: :staff_registrations, source: :user 12 | 13 | validates :bottlenose_id, presence: true, uniqueness: { 14 | message: 'id already exists for another record', 15 | } 16 | 17 | delegate :professors, to: :course 18 | end 19 | -------------------------------------------------------------------------------- /app/models/snapshot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Mapping from timestamp to student's current answers. 4 | class Snapshot < ApplicationRecord 5 | belongs_to :registration 6 | 7 | has_one :user, through: :registration 8 | has_one :exam, through: :registration 9 | 10 | def user 11 | super || registration.try(:user) 12 | end 13 | 14 | def exam 15 | super || registation.try(:exam) 16 | end 17 | 18 | validates :answers, presence: true 19 | 20 | scope :most_recent_by_registration, lambda { 21 | from( 22 | <<~SQL.squish, 23 | ( 24 | SELECT snapshots.* 25 | FROM snapshots JOIN ( 26 | SELECT registration_id, max(created_at) AS created_at 27 | FROM snapshots 28 | GROUP BY registration_id 29 | ) latest_by_registration 30 | ON snapshots.created_at = latest_by_registration.created_at 31 | AND snapshots.registration_id = latest_by_registration.registration_id 32 | ) snapshots 33 | SQL 34 | ) 35 | } 36 | end 37 | -------------------------------------------------------------------------------- /app/models/staff_registration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Registrations for staff to a section. 4 | class StaffRegistration < ApplicationRecord 5 | belongs_to :section 6 | belongs_to :user 7 | 8 | has_one :course, through: :section 9 | delegate :professors, to: :course 10 | 11 | def course 12 | super || section.try(:course) 13 | end 14 | 15 | def visible_to?(check_user, role_for_exam, role_for_course) 16 | (user == check_user) || 17 | ([role_for_exam, role_for_course].max >= Exam.roles[:professor]) || 18 | professors.exists?(check_user.id) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/models/student_question.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Questions from students during an exam. 4 | class StudentQuestion < ApplicationRecord 5 | belongs_to :registration 6 | 7 | has_one :exam, through: :registration 8 | has_one :user, through: :registration 9 | delegate :proctors_and_professors, to: :exam 10 | 11 | def user 12 | super || registration.try(:user) 13 | end 14 | 15 | def exam 16 | super || registation.try(:exam) 17 | end 18 | 19 | validates :body, presence: true, length: { maximum: 2000 } 20 | 21 | def visible_to?(check_user, role_for_exam, _role_for_course) 22 | (user == check_user) || 23 | (role_for_exam >= Exam.roles[:proctor]) || 24 | proctors_and_professors.exists?(check_user.id) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/models/student_registration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Registrations for students to a section. 4 | class StudentRegistration < ApplicationRecord 5 | belongs_to :section 6 | belongs_to :user 7 | 8 | has_one :course, through: :section 9 | 10 | def course 11 | super || section.try(:course) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/models/version_announcement.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # An announcement sent during an exam to all students taking a particular version. 4 | class VersionAnnouncement < ApplicationRecord 5 | belongs_to :exam_version 6 | 7 | has_one :exam, through: :exam_version 8 | delegate :visible_to?, to: :exam_version 9 | 10 | def exam 11 | super || exam_version.try(:exam) 12 | end 13 | 14 | validates :body, presence: true 15 | end 16 | -------------------------------------------------------------------------------- /app/packs/components/common/NumericInput.scss: -------------------------------------------------------------------------------- 1 | div.numeric-input { 2 | button.button-up { 3 | border-top-left-radius: 0; 4 | line-height: 0.5em; 5 | } 6 | button.button-down { 7 | border-bottom-left-radius: 0; 8 | line-height: 0.5em; 9 | } 10 | } -------------------------------------------------------------------------------- /app/packs/components/common/Spoiler.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Tooltip from '@student/exams/show/components/Tooltip'; 3 | 4 | const Spoiler: React.FC<{ 5 | text: string; 6 | openAll?: boolean; 7 | // curent toggle state => true if toggling should be cancelled 8 | beforeToggle?: (curOpen: boolean) => boolean; 9 | // new toggle state => void 10 | onToggle?: (newOpen: boolean) => void; 11 | }> = (props) => { 12 | const { 13 | text, 14 | openAll = false, 15 | beforeToggle, 16 | onToggle, 17 | } = props; 18 | const [open, setOpen] = useState(false); 19 | const isOpen = open || openAll; 20 | const toggle = () => { 21 | if (beforeToggle && beforeToggle(open)) return; 22 | const newOpen = !open; 23 | setOpen(newOpen); 24 | if (onToggle) onToggle(newOpen); 25 | }; 26 | return ( 27 | 30 | 36 | {text} 37 | 38 | 39 | ); 40 | }; 41 | 42 | export default Spoiler; 43 | -------------------------------------------------------------------------------- /app/packs/components/common/alerts.scss: -------------------------------------------------------------------------------- 1 | #allAlerts { 2 | position: fixed; 3 | margin-top: 70px; 4 | right: 15px; 5 | z-index: 10000; 6 | display: flex; 7 | flex-direction: column; 8 | flex-wrap: wrap-reverse; 9 | max-width: 50%; 10 | } 11 | 12 | .toast-body { 13 | overflow: auto; 14 | max-height: 30vh; 15 | } 16 | 17 | .toast-body, .toast-header { 18 | right: 15px; 19 | direction: ltr; 20 | } 21 | 22 | .toast { 23 | flex-basis: initial !important; 24 | width: 350px !important; 25 | max-width: 100% !important; 26 | right: 0; 27 | resize: horizontal; 28 | direction: rtl; 29 | } 30 | -------------------------------------------------------------------------------- /app/packs/components/common/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { 3 | FileMap, 4 | ExamFile, 5 | AnswersState, 6 | FileRef, 7 | } from '@student/exams/show/types'; 8 | import { ExamRubric } from '@professor/exams/types'; 9 | 10 | interface IExamContext { 11 | files: ExamFile[]; 12 | fmap: FileMap; 13 | } 14 | export const ExamContext = createContext({} as IExamContext); 15 | 16 | interface IExamViewerContext { 17 | answers: AnswersState; 18 | rubric?: ExamRubric; 19 | } 20 | export const ExamViewerContext = createContext({} as IExamViewerContext); 21 | 22 | interface FilesContext { 23 | references: readonly FileRef[]; 24 | } 25 | 26 | export const ExamFilesContext = createContext({ 27 | references: [], 28 | }); 29 | 30 | export const QuestionFilesContext = createContext({ 31 | references: [], 32 | }); 33 | 34 | export const PartFilesContext = createContext({ 35 | references: [], 36 | }); 37 | -------------------------------------------------------------------------------- /app/packs/components/common/documentTitle.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | 3 | const DocumentTitle: React.FC> = (props) => { 6 | const { 7 | title, 8 | children, 9 | } = props; 10 | useEffect(() => { 11 | document.title = title; 12 | }, [title]); 13 | // eslint-disable-next-line react/jsx-no-useless-fragment 14 | return <>{children}; 15 | }; 16 | 17 | export default DocumentTitle; 18 | -------------------------------------------------------------------------------- /app/packs/components/common/linkbutton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button, ButtonProps } from 'react-bootstrap'; 3 | import { useHistory } from 'react-router-dom'; 4 | 5 | const LinkButton: React.FC['onClick']; 8 | }> = (props) => { 9 | const { 10 | children, 11 | onClick, 12 | to, 13 | } = props; 14 | const history = useHistory(); 15 | return ( 16 | 26 | ); 27 | }; 28 | export default LinkButton; 29 | -------------------------------------------------------------------------------- /app/packs/components/common/linkdropdownitem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Dropdown } from 'react-bootstrap'; 3 | import { useHistory } from 'react-router-dom'; 4 | import { DropdownItemProps } from 'react-bootstrap/esm/DropdownItem'; 5 | 6 | const LinkDropdownItem: React.FC = (props) => { 10 | const { 11 | children, 12 | onClick, 13 | to, 14 | } = props; 15 | const history = useHistory(); 16 | return ( 17 | { 19 | if (onClick) onClick(event); 20 | history.push(to); 21 | }} 22 | // eslint-disable-next-line react/jsx-props-no-spreading 23 | {...props} 24 | > 25 | {children} 26 | 27 | ); 28 | }; 29 | export default LinkDropdownItem; 30 | -------------------------------------------------------------------------------- /app/packs/components/common/linksplitbutton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { SplitButton, SplitButtonProps } from 'react-bootstrap'; 3 | import { useHistory } from 'react-router-dom'; 4 | 5 | const LinkSplitButton: React.FC = (props) => { 9 | const { 10 | children, 11 | onClick, 12 | to, 13 | } = props; 14 | const history = useHistory(); 15 | return ( 16 | { 18 | if (onClick) onClick(event); 19 | history.push(to); 20 | }} 21 | // eslint-disable-next-line react/jsx-props-no-spreading 22 | {...props} 23 | > 24 | {children} 25 | 26 | ); 27 | }; 28 | export default LinkSplitButton; 29 | -------------------------------------------------------------------------------- /app/packs/components/common/messages/index.scss: -------------------------------------------------------------------------------- 1 | @import "node_modules/bootstrap/scss/functions"; 2 | @import "node_modules/bootstrap/scss/variables"; 3 | 4 | .fancy-hr hr { 5 | height: 2px; 6 | outline: 0; 7 | border: 0; 8 | &.fancy-left { 9 | background: linear-gradient(to right, transparent, theme-color("secondary")); 10 | } 11 | &.fancy-right { 12 | background: linear-gradient(to right, theme-color("secondary"), transparent); 13 | } 14 | &.fancy-left-warning { 15 | background: linear-gradient(to right, transparent, theme-color("warning")); 16 | } 17 | &.fancy-right-warning { 18 | background: linear-gradient(to right, theme-color("warning"), transparent); 19 | } 20 | } 21 | 22 | .new-message { 23 | background-color: rgba(theme-color("warning"), 0.2); 24 | } 25 | -------------------------------------------------------------------------------- /app/packs/components/common/mime.ts: -------------------------------------------------------------------------------- 1 | import defaultMime, { Mime } from 'mime'; 2 | 3 | const customMime = new Mime(); 4 | 5 | customMime.define({ 6 | scheme: ['rkt', 'ss'], 7 | pyret: ['arr'], 8 | mllike: ['ml', 'mli'], 9 | 'text/x-ebnf': ['mly'], 10 | 'text/x-csrc': ['c', 'h'], 11 | 'text/x-c++src': ['cpp', 'c'], 12 | 'text/x-csharp': ['cs'], 13 | 'application/xml': ['svg', 'xml'], 14 | 'text/x-yaml': ['yml', 'yaml'], 15 | 'text/x-java': ['java'], 16 | }, true); 17 | 18 | export default (name: string): string => { 19 | const fname = name.toLowerCase(); 20 | switch (fname) { 21 | case 'makefile': 22 | return 'text/x-makefile'; 23 | default: 24 | return customMime.getType(fname) ?? defaultMime.getType(fname) ?? 'text/plain'; 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /app/packs/components/common/navbar/NotLoggedIn.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Navbar } from 'react-bootstrap'; 3 | // eslint-disable-next-line no-restricted-imports 4 | import NavbarLogo from '../../../images/hourglass.svg'; 5 | 6 | const NotLoggedIn: React.FC = () => ( 7 | 11 | 12 | 13 | Hourglass 14 | Hourglass 15 | 16 | 17 | 18 | 19 | ); 20 | 21 | export default NotLoggedIn; 22 | -------------------------------------------------------------------------------- /app/packs/components/common/student-dnd/store.ts: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers } from 'redux'; 2 | import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly'; 3 | import { reducer as formReducer } from 'redux-form'; 4 | 5 | const composeEnhancers = composeWithDevTools({ 6 | trace: true, 7 | traceLimit: 25, 8 | }); 9 | 10 | const reduxEnhancers = composeEnhancers(); 11 | 12 | const rootReducer = combineReducers({ 13 | form: formReducer, 14 | }); 15 | 16 | export default createStore(rootReducer, reduxEnhancers); 17 | -------------------------------------------------------------------------------- /app/packs/components/common/svg.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /app/packs/components/graphiql/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | // eslint-disable-next-line import/no-extraneous-dependencies 4 | import { GraphiQLInterface, GraphiQLProvider } from 'graphiql/dist'; 5 | // eslint-disable-next-line import/no-extraneous-dependencies 6 | import { Fetcher } from '@graphiql/toolkit'; 7 | // eslint-disable-next-line import/no-extraneous-dependencies 8 | import 'graphiql/graphiql.min.css'; 9 | 10 | import { getCSRFToken } from '@student/exams/show/helpers'; 11 | 12 | const URL = '/graphql'; 13 | 14 | const graphQLFetcher: Fetcher = async (graphQLParams) => ( 15 | fetch(URL, { 16 | method: 'post', 17 | headers: { 18 | 'Content-Type': 'application/json', 19 | 'X-CSRF-Token': getCSRFToken(), 20 | }, 21 | body: JSON.stringify(graphQLParams), 22 | }).then((response) => response.json()) 23 | ); 24 | 25 | const defaultQuery = ` 26 | { 27 | me { 28 | username 29 | } 30 | } 31 | `; 32 | 33 | const GIQL: React.FC = () => ( 34 |
35 | 39 | 40 | 41 |
42 | ); 43 | 44 | export default GIQL; 45 | -------------------------------------------------------------------------------- /app/packs/components/workflows/grading/createComment.tsx: -------------------------------------------------------------------------------- 1 | import { graphql } from 'react-relay'; 2 | import { RangeAddConfig } from 'relay-runtime/lib/mutations/RelayDeclarativeMutationConfig'; 3 | 4 | export const CREATE_COMMENT_MUTATION = graphql` 5 | mutation createCommentMutation($input: CreateGradingCommentInput!) { 6 | createGradingComment(input: $input) { 7 | gradingComment { 8 | id 9 | qnum 10 | pnum 11 | bnum 12 | points 13 | message 14 | presetComment { 15 | id 16 | } 17 | } 18 | gradingCommentEdge { 19 | node { 20 | id 21 | } 22 | } 23 | } 24 | } 25 | `; 26 | 27 | export const addCommentConfig = (registrationId: string): RangeAddConfig => ({ 28 | type: 'RANGE_ADD', 29 | parentID: registrationId, 30 | connectionInfo: [{ 31 | key: 'Registration_gradingComments', 32 | rangeBehavior: 'append', 33 | }], 34 | edgeName: 'gradingCommentEdge', 35 | }); 36 | -------------------------------------------------------------------------------- /app/packs/components/workflows/grading/questions/GradeMultipleChoice.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { MultipleChoiceProps } from '@proctor/registrations/show/questions/DisplayMultipleChoice'; 3 | import HTML from '@student/exams/show/components/HTML'; 4 | 5 | const GradeMultipleChoice: React.FC = (props) => { 6 | const { 7 | info, 8 | value, 9 | } = props; 10 | const { options } = info; 11 | if (value === undefined) { 12 | return ( 13 | <> 14 | Answer: 15 | None selected 16 | 17 | ); 18 | } 19 | return ( 20 | <> 21 | Answer: 22 | 23 | 24 | 25 | 26 | ); 27 | }; 28 | 29 | export default GradeMultipleChoice; 30 | -------------------------------------------------------------------------------- /app/packs/components/workflows/grading/questions/GradeYesNo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { YesNoProps } from '@proctor/registrations/show/questions/DisplayYesNo'; 3 | 4 | const GradeYesNo: React.FC> = (props) => { 5 | const { 6 | info, 7 | value, 8 | children, 9 | } = props; 10 | const { 11 | yesLabel, 12 | noLabel, 13 | } = info; 14 | if (value === undefined) { 15 | return ( 16 | <> 17 | Answer: 18 | No answer given 19 | {children} 20 | 21 | ); 22 | } 23 | return ( 24 | <> 25 | Answer: 26 | 27 | {value 28 | ? yesLabel 29 | : noLabel} 30 | 31 | {children} 32 | 33 | ); 34 | }; 35 | 36 | export default GradeYesNo; 37 | -------------------------------------------------------------------------------- /app/packs/components/workflows/grading/questions/ObjectiveGrade.scss: -------------------------------------------------------------------------------- 1 | .w-100px { width: 100px !important; } -------------------------------------------------------------------------------- /app/packs/components/workflows/proctor/exams/index.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | flex-basis: 100%; 3 | display: flex; 4 | margin: 0 auto; 5 | } 6 | .content-wrapper { 7 | position: relative; 8 | flex-grow: 1; 9 | } 10 | .inner-wrapper { 11 | display: flex; 12 | flex-direction: column; 13 | flex-basis: 100%; 14 | justify-content: space-between; 15 | } 16 | .content { 17 | flex-grow: 1; 18 | flex-basis: 100%; 19 | position: absolute; 20 | width: 100%; 21 | max-height: 100%; 22 | } 23 | .overflow-auto-y { 24 | overflow-y: auto; 25 | } 26 | 27 | .filterMessages__menu-list { 28 | z-index: 999 !important; 29 | } 30 | .proctor-groupstyles { 31 | display: flex; 32 | align-items: 'center'; 33 | justify-content: 'space-between'; 34 | } 35 | 36 | .printTable { 37 | page-break-inside: 'avoid'; 38 | border-collapse: separate; 39 | > tbody > tr > td { 40 | border: 1px solid gray; 41 | border-radius: 0.5em; 42 | padding: 0.5em; 43 | } 44 | table.pinInfo { 45 | tr.nuid { 46 | td:first-child { padding-right: 2em;} 47 | font-size: 80%; 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /app/packs/components/workflows/proctor/registrations/show/questions/DisplayAllThatApply.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AllThatApplyInfo, AllThatApplyState } from '@student/exams/show/types'; 3 | import HTML from '@student/exams/show/components/HTML'; 4 | 5 | interface AllThatApplyProps { 6 | info: AllThatApplyInfo; 7 | value?: AllThatApplyState; 8 | } 9 | 10 | const DisplayAllThatApply: React.FC = (props) => { 11 | const { 12 | info, 13 | value, 14 | } = props; 15 | const { options } = info; 16 | if (!value || !Object.values(value).some((ans) => !!ans)) { 17 | return ( 18 | <> 19 | Answer: 20 | None selected 21 | 22 | ); 23 | } 24 | return ( 25 | <> 26 | Answer: 27 |
    28 | {options.map((o, i) => { 29 | // options array is STATIC 30 | // eslint-disable-next-line react/no-array-index-key 31 | if (value?.[i]) { return
  • ; } 32 | return null; 33 | })} 34 |
35 | 36 | ); 37 | }; 38 | 39 | export default DisplayAllThatApply; 40 | -------------------------------------------------------------------------------- /app/packs/components/workflows/proctor/registrations/show/questions/DisplayMultipleChoice.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { MultipleChoiceInfo, MultipleChoiceState } from '@student/exams/show/types'; 3 | import HTML from '@student/exams/show/components/HTML'; 4 | 5 | export interface MultipleChoiceProps { 6 | info: MultipleChoiceInfo; 7 | value: MultipleChoiceState; 8 | } 9 | 10 | const DisplayMultipleChoice: React.FC = (props) => { 11 | const { 12 | info, 13 | value, 14 | } = props; 15 | const { options } = info; 16 | if (value === undefined) { 17 | return ( 18 | <> 19 | Answer: 20 | None selected 21 | 22 | ); 23 | } 24 | return ( 25 | <> 26 | Answer: 27 | 28 | 29 | 30 | 31 | ); 32 | }; 33 | 34 | export default DisplayMultipleChoice; 35 | -------------------------------------------------------------------------------- /app/packs/components/workflows/proctor/registrations/show/questions/DisplayText.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Form } from 'react-bootstrap'; 3 | import { TextInfo, TextState } from '@student/exams/show/types'; 4 | 5 | interface TextProps { 6 | info: TextInfo; 7 | value?: TextState; 8 | } 9 | 10 | const Text: React.FC = (props) => { 11 | const { 12 | value, 13 | } = props; 14 | if (!value) { 15 | return ( 16 | <> 17 | Answer: 18 | No answer given 19 | 20 | ); 21 | } 22 | return ( 23 | 30 | ); 31 | }; 32 | 33 | export default Text; 34 | -------------------------------------------------------------------------------- /app/packs/components/workflows/proctor/registrations/show/questions/DisplayYesNo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { YesNoInfo } from '@student/exams/show/types'; 3 | 4 | export interface YesNoProps { 5 | info: YesNoInfo; 6 | value: boolean; 7 | } 8 | 9 | const DisplayYesNo: React.FC = (props) => { 10 | const { 11 | info, 12 | value, 13 | } = props; 14 | const { 15 | yesLabel, 16 | noLabel, 17 | } = info; 18 | if (value === undefined) { 19 | return ( 20 | <> 21 | Answer: 22 | No answer given 23 | 24 | ); 25 | } 26 | return ( 27 | <> 28 | Answer: 29 | 30 | {value 31 | ? yesLabel 32 | : noLabel} 33 | 34 | 35 | ); 36 | }; 37 | 38 | export default DisplayYesNo; 39 | -------------------------------------------------------------------------------- /app/packs/components/workflows/proctor/registrations/show/questions/Prompted.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { HTMLVal } from '@student/exams/show/types'; 3 | import HTML from '@student/exams/show/components/HTML'; 4 | 5 | const Prompted: React.FC> = (props) => { 8 | const { 9 | children, 10 | prompt, 11 | } = props; 12 | return ( 13 |
14 | {prompt && (
)} 15 | {children} 16 |
17 | ); 18 | }; 19 | 20 | export default Prompted; 21 | -------------------------------------------------------------------------------- /app/packs/components/workflows/professor/exams/admin.scss: -------------------------------------------------------------------------------- 1 | .rotate-45 { 2 | transform: rotate(-45deg); 3 | } -------------------------------------------------------------------------------- /app/packs/components/workflows/professor/exams/dnd.scss: -------------------------------------------------------------------------------- 1 | .fixed-col-width > span, .fixed-col-width > button > span { 2 | max-width: 100%; 3 | display: inline-block; 4 | break-inside: avoid; 5 | word-wrap: anywhere; 6 | white-space: break-spaces; 7 | } 8 | -------------------------------------------------------------------------------- /app/packs/components/workflows/professor/exams/new/editor/Instructions.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Row, Col, 4 | } from 'react-bootstrap'; 5 | import { HTMLVal } from '@student/exams/show/types'; 6 | import { EditHTMLVal } from './components/helpers'; 7 | 8 | interface TextProps { 9 | value: HTMLVal; 10 | disabled?: boolean; 11 | onChange: (newVal: HTMLVal) => void; 12 | } 13 | 14 | const Instructions: React.FC = (props) => { 15 | const { 16 | value, 17 | disabled = false, 18 | onChange, 19 | } = props; 20 | return ( 21 | 22 | 23 |

Exam instructions

24 | 34 | 35 |
36 | ); 37 | }; 38 | 39 | export default Instructions; 40 | -------------------------------------------------------------------------------- /app/packs/components/workflows/professor/exams/new/editor/body-items/Matching.css: -------------------------------------------------------------------------------- 1 | .match-box { 2 | width: 85px; 3 | } 4 | -------------------------------------------------------------------------------- /app/packs/components/workflows/professor/exams/new/editor/body-items/Prompted.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Form, Row, Col } from 'react-bootstrap'; 3 | import { HTMLVal } from '@student/exams/show/types'; 4 | import { EditHTMLVal } from '@professor/exams/new/editor/components/helpers'; 5 | 6 | const Prompted: React.FC<{ 7 | disabled?: boolean; 8 | value?: HTMLVal; 9 | onChange: (newVal: HTMLVal) => void; 10 | debounceDelay?: number; 11 | className?: string; 12 | theme?: string; 13 | }> = (props) => { 14 | const { 15 | value = { type: 'HTML', value: '' }, 16 | disabled = false, 17 | debounceDelay = 1000, 18 | onChange, 19 | theme, 20 | className = 'bg-white', 21 | } = props; 22 | 23 | return ( 24 | 25 | Prompt 26 | 27 | 36 | 37 | 38 | ); 39 | }; 40 | 41 | export default Prompted; 42 | -------------------------------------------------------------------------------- /app/packs/components/workflows/professor/exams/stats/utils.ts: -------------------------------------------------------------------------------- 1 | import { RubricPresetHistograms$data } from './__generated__/RubricPresetHistograms.graphql'; 2 | 3 | export type RechartPayload = { 4 | payload: T, 5 | fill: string, 6 | color: string, 7 | name: string, 8 | value: number, 9 | } 10 | 11 | export type PresetUsageData = { 12 | key: 'placeholder', 13 | } | { 14 | key: 'none', 15 | } | { 16 | key: Exclude, 17 | 'Preset default': number, 18 | 'Edited points': number, 19 | 'Edited message': number, 20 | Customized: number, 21 | Total: number, 22 | }; 23 | 24 | export type GradingCommentList = RubricPresetHistograms$data['registrations'][number]['allGradingComments']; 25 | export type GradingComment = GradingCommentList[number]; 26 | 27 | export const colors = [ 28 | 'steelblue', 'maroon', 'gold', 'seagreen', 'orange', 'indigo', 29 | 'dodgerblue', 'firebrick', 'goldenrod', 'mediumseagreen', 'sandybrown', 'darkmagenta', 30 | ]; 31 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/components/ExamCodeBox.scss: -------------------------------------------------------------------------------- 1 | @import 'codemirror/lib/codemirror.css'; 2 | @import 'codemirror/theme/mdn-like.css'; 3 | 4 | .CodeMirror { 5 | border: 1px solid #eee; 6 | max-width: 100%; 7 | resize: vertical; 8 | } 9 | 10 | .react-codemirror2.h-auto .CodeMirror { 11 | height: auto !important; 12 | resize: none; 13 | } 14 | 15 | .CodeMirror-scroll { 16 | max-width: 100%; 17 | // overflow: auto; 18 | } 19 | 20 | .CodeMirror-vscrollbar { visibility: hidden !important; } 21 | 22 | // /* Hide scrollbar for Chrome, Safari and Opera */ 23 | // .CodeMirror-scroll::-webkit-scrollbar { 24 | // display: none; 25 | // } 26 | 27 | // /* Hide scrollbar for IE, Edge and Firefox */ 28 | // .CodeMirror-scroll { 29 | // -ms-overflow-style: none; /* IE and Edge */ 30 | // scrollbar-width: none; /* Firefox */ 31 | // } 32 | 33 | .CodeMirror .readOnly { 34 | background: #8d6d001a; 35 | } 36 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/components/ExamTaker.scss: -------------------------------------------------------------------------------- 1 | #exam-taker { 2 | --sidebar-small: calc(6em + 2px); 3 | --sidebar-expanded: 500px; 4 | } 5 | 6 | #exam-taker .sidebar-small + .flex-fill { 7 | width: calc(100% - var(--sidebar-small)); 8 | } 9 | 10 | #exam-taker .sidebar-expanded + .flex-fill { 11 | width: calc(100% - var(--sidebar-expanded)); 12 | } 13 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/components/HTML.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { HTMLVal } from '@student/exams/show/types'; 3 | 4 | export interface HTMLProps { 5 | value: HTMLVal; 6 | className?: string; 7 | } 8 | 9 | const HTML: React.FC = (props) => { 10 | const { value, className = 'no-hover' } = props; 11 | const theHTML = { 12 | __html: value?.value ?? '', 13 | }; 14 | 15 | return ( 16 |
21 | ); 22 | }; 23 | 24 | export default HTML; 25 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/components/Icon.css: -------------------------------------------------------------------------------- 1 | .icon-spin { 2 | display: inline-block; 3 | -webkit-animation: icon-spin 2s infinite linear; 4 | animation: icon-spin 2s infinite linear; 5 | } 6 | 7 | @-webkit-keyframes icon-spin { 8 | 0% { 9 | -webkit-transform: rotate(0deg); 10 | transform: rotate(0deg); 11 | } 12 | 100% { 13 | -webkit-transform: rotate(359deg); 14 | transform: rotate(359deg); 15 | } 16 | } 17 | 18 | @keyframes icon-spin { 19 | 0% { 20 | -webkit-transform: rotate(0deg); 21 | transform: rotate(0deg); 22 | } 23 | 100% { 24 | -webkit-transform: rotate(359deg); 25 | transform: rotate(359deg); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/components/Icon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IconType } from 'react-icons'; 3 | import { AiOutlineLoading } from 'react-icons/ai'; 4 | import './Icon.css'; 5 | 6 | const ICON_SIZE = '1.5em'; 7 | 8 | interface IconProps { 9 | I: IconType; 10 | size?: string; 11 | className?: string; 12 | } 13 | 14 | const Icon: React.FC = (props) => { 15 | const { 16 | I, 17 | size = ICON_SIZE, 18 | className = '', 19 | } = props; 20 | const spin = I === AiOutlineLoading; 21 | const spinClass = spin ? 'icon-spin' : ''; 22 | const allClasses = `${className} ${spinClass}`; 23 | return ( 24 | 25 | 26 | 27 | ); 28 | }; 29 | 30 | export default Icon; 31 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/components/Locked.css: -------------------------------------------------------------------------------- 1 | .spinnerOuter { 2 | z-index: 1000; 3 | } 4 | 5 | .spinnerOverlay { 6 | opacity: 0.6; 7 | } 8 | 9 | .spinnerInner { 10 | transform: translate(-50%, -50%); 11 | top: 50%; 12 | left: 50%; 13 | } 14 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/components/PaginationArrows.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { MdArrowForward, MdArrowBack } from 'react-icons/md'; 3 | import { Button } from 'react-bootstrap'; 4 | 5 | interface PaginationArrowsProps { 6 | show: boolean; 7 | hasNext: boolean; 8 | hasPrev: boolean; 9 | next: () => void; 10 | prev: () => void; 11 | } 12 | 13 | const PaginationArrows: React.FC = (props) => { 14 | const { 15 | show, 16 | hasNext, 17 | hasPrev, 18 | next, 19 | prev, 20 | } = props; 21 | return ( 22 |
25 | 34 | 43 |
44 | ); 45 | }; 46 | PaginationArrows.displayName = 'PaginationArrows'; 47 | 48 | export default PaginationArrows; 49 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/components/Part.css: -------------------------------------------------------------------------------- 1 | .bodyitem:not(.HTML, .CodeSnippet):hover { 2 | background-color: #eeeeee; 3 | } 4 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Tooltip as BSTooltip, 4 | OverlayTrigger, 5 | OverlayTriggerProps, 6 | } from 'react-bootstrap'; 7 | 8 | export interface TooltipProps { 9 | message: string; 10 | className?: string; 11 | showTooltip?: boolean; 12 | defaultShow?: boolean; 13 | placement?: OverlayTriggerProps['placement']; 14 | children: React.ReactElement; 15 | } 16 | 17 | const Tooltip: React.FC = (props) => { 18 | const { 19 | message, 20 | className, 21 | showTooltip = true, 22 | defaultShow, 23 | placement = 'bottom', 24 | children, 25 | } = props; 26 | const tooltip = showTooltip 27 | ? ( 28 | 33 | {message} 34 | 35 | ) 36 | : ((): JSX.Element => ); 37 | return ( 38 | 43 | {children} 44 | 45 | ); 46 | }; 47 | 48 | export default Tooltip; 49 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/components/navbar/Scratch.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Form } from 'react-bootstrap'; 3 | 4 | interface ScratchProps { 5 | value: string; 6 | onChange?: (newVal: string) => void; 7 | disabled?: boolean; 8 | } 9 | 10 | const Scratch: React.FC = (props) => { 11 | const { 12 | value, 13 | onChange, 14 | disabled = false, 15 | } = props; 16 | return ( 17 | { 21 | onChange(event.target.value); 22 | }} 23 | as="textarea" 24 | spellCheck={false} 25 | disabled={disabled} 26 | /> 27 | ); 28 | }; 29 | 30 | export default Scratch; 31 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/components/navbar/index.css: -------------------------------------------------------------------------------- 1 | .sidebar-small { 2 | width: var(--sidebar-small); 3 | } 4 | 5 | .sidebar-expanded { 6 | width: var(--sidebar-expanded); 7 | } 8 | 9 | .collapse:not(.width):not(.show) { 10 | display: none; 11 | } 12 | 13 | .collapsing:not(.width) { 14 | position: relative; 15 | height: 0; 16 | /* overflow: hidden; */ 17 | transition: height 0.35s ease-in-out; 18 | } 19 | 20 | @media (prefers-reduced-motion: reduce) { 21 | .collapsing { 22 | transition: none; 23 | } 24 | } 25 | 26 | .collapse.width, .collapsing.width { 27 | white-space: nowrap; 28 | /* overflow: hidden; */ 29 | display: inline-block; 30 | height: unset; /* undoes bootstrap .collapsing height */ 31 | transition: 0.35s width ease-in-out; 32 | } 33 | 34 | .collapse.width:not(.show) { 35 | display: none; 36 | height: unset; 37 | } 38 | .collapsing.width { 39 | position: relative; 40 | height: unset; /* undoes bootstrap .collapsing height */ 41 | width: 0; 42 | } 43 | 44 | .collapse.width.show { 45 | width: calc(var(--sidebar-expanded) - 7em); 46 | } 47 | 48 | 49 | .blue-glow { 50 | /* a somewhat-transparent lightblue */ 51 | background: radial-gradient(closest-side, #add8e688 40%,transparent); 52 | } -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/components/questions/Text.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Row, Col, Form, 4 | } from 'react-bootstrap'; 5 | import { TextInfo, TextState } from '@student/exams/show/types'; 6 | import HTML from '@student/exams/show/components/HTML'; 7 | 8 | interface TextProps { 9 | info: TextInfo; 10 | value: TextState; 11 | onChange: (newVal: TextState) => void; 12 | disabled: boolean; 13 | } 14 | 15 | const Text: React.FC = (props) => { 16 | const { 17 | info, 18 | value, 19 | onChange, 20 | disabled, 21 | } = props; 22 | const { prompt } = info; 23 | return ( 24 | <> 25 | 26 | 27 | 28 | { 36 | const elem = e.target as HTMLTextAreaElement; 37 | onChange(elem.value); 38 | }} 39 | /> 40 | 41 | 42 | 43 | ); 44 | }; 45 | 46 | export default Text; 47 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/containers/ExamShowContents.ts: -------------------------------------------------------------------------------- 1 | import ExamShowContents from '@student/exams/show/components/ExamShowContents'; 2 | import { connect } from 'react-redux'; 3 | import { saveSnapshot, submitExam } from '@student/exams/show/actions'; 4 | import { 5 | ExamVersion, 6 | MSTP, 7 | MDTP, 8 | } from '@student/exams/show/types'; 9 | 10 | interface OwnProps { 11 | examTakeUrl: string; 12 | } 13 | 14 | const mapStateToProps: MSTP<{exam: ExamVersion}, OwnProps> = (state) => ({ 15 | exam: state.contents.exam, 16 | }); 17 | 18 | const mapDispatchToProps: MDTP<{ 19 | save: () => void; 20 | submit: (cleanup: () => void) => void; 21 | }, OwnProps> = (dispatch, ownProps) => ({ 22 | save: (): void => { 23 | dispatch(saveSnapshot(ownProps.examTakeUrl)); 24 | }, 25 | submit: (cleanup: () => void): void => { 26 | dispatch(submitExam(ownProps.examTakeUrl, cleanup)); 27 | }, 28 | }); 29 | 30 | export default connect(mapStateToProps, mapDispatchToProps)(ExamShowContents); 31 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/containers/ExamTaker.tsx: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { 3 | LockdownStatus, 4 | MSTP, 5 | } from '@student/exams/show/types'; 6 | import ExamTaker from '@student/exams/show/components/ExamTaker'; 7 | 8 | const examTakerStateToProps: MSTP<{ ready: boolean }> = (state) => ({ 9 | ready: (state.lockdown.status === LockdownStatus.LOCKED 10 | || state.lockdown.status === LockdownStatus.IGNORED) 11 | && !!state.lockdown.loaded, 12 | }); 13 | 14 | export default connect(examTakerStateToProps)(ExamTaker); 15 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/containers/LockdownInfo.tsx: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { MSTP, LockdownStatus } from '@student/exams/show/types'; 3 | import LockdownInfo from '@student/exams/show/components/LockdownInfo'; 4 | 5 | const mapStateToProps: MSTP<{ status: LockdownStatus; message: string }> = (state) => { 6 | const { lockdown } = state; 7 | const { status, message } = lockdown; 8 | return { 9 | status, 10 | message, 11 | }; 12 | }; 13 | 14 | export default connect(mapStateToProps)(LockdownInfo); 15 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/containers/PaginationArrows.ts: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { 3 | nextQuestion, 4 | prevQuestion, 5 | } from '@student/exams/show/actions'; 6 | import PaginationArrows from '@student/exams/show/components/PaginationArrows'; 7 | import { MDTP, MSTP } from '@student/exams/show/types'; 8 | 9 | const mapStateToProps: MSTP<{ 10 | show: boolean; 11 | hasNext: boolean; 12 | hasPrev: boolean; 13 | }> = (state) => ({ 14 | show: state.pagination.paginated, 15 | hasNext: state.pagination.page !== state.pagination.pageCoords.length - 1, 16 | hasPrev: state.pagination.page !== 0, 17 | }); 18 | 19 | const mapDispatchToProps: MDTP<{ 20 | next: () => void; 21 | prev: () => void; 22 | }> = (dispatch) => ({ 23 | next: (): void => { 24 | dispatch(nextQuestion()); 25 | }, 26 | prev: (): void => { 27 | dispatch(prevQuestion()); 28 | }, 29 | }); 30 | 31 | export default connect(mapStateToProps, mapDispatchToProps)(PaginationArrows); 32 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/containers/ShowQuestion.ts: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { ExamTakerState, MSTP, MDTP } from '@student/exams/show/types'; 3 | import ShowQuestion from '@student/exams/show/components/ShowQuestion'; 4 | import { spyQuestion } from '@student/exams/show/actions'; 5 | 6 | const mapStateToProps: MSTP<{ 7 | paginated: boolean; 8 | selectedQuestion: number; 9 | selectedPart: number; 10 | }> = (state: ExamTakerState) => { 11 | const { pageCoords, paginated, page } = state.pagination; 12 | return { 13 | paginated, 14 | selectedQuestion: pageCoords[page].question, 15 | selectedPart: pageCoords[page].part, 16 | }; 17 | }; 18 | 19 | const mapDispatchToProps: MDTP<{ 20 | spyQuestion: (question: number, pnum?: number) => void; 21 | }> = (dispatch) => ({ 22 | spyQuestion: (question, part): void => { 23 | dispatch(spyQuestion({ question, part })); 24 | }, 25 | }); 26 | 27 | export default connect(mapStateToProps, mapDispatchToProps)(ShowQuestion); 28 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/containers/SnapshotInfo.tsx: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { ExamTakerState, MSTP, SnapshotStatus } from '@student/exams/show/types'; 3 | import SnapshotInfo from '@student/exams/show/components/SnapshotInfo'; 4 | 5 | const mapStateToProps: MSTP<{ 6 | status: SnapshotStatus; 7 | message: string; 8 | }> = (state: ExamTakerState) => ({ 9 | status: state.snapshot.status, 10 | message: state.snapshot.message, 11 | }); 12 | 13 | export default connect(mapStateToProps)(SnapshotInfo); 14 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/containers/SubmitButton.ts: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { submitExam } from '@student/exams/show/actions'; 3 | import SubmitButton from '@student/exams/show/components/SubmitButton'; 4 | import { MDTP } from '@student/exams/show/types'; 5 | 6 | interface OwnProps { 7 | examTakeUrl: string; 8 | cleanupBeforeSubmit: () => void; 9 | } 10 | 11 | const mapDispatchToProps: MDTP<{ 12 | submit: () => void; 13 | }, OwnProps> = (dispatch, ownProps) => ({ 14 | submit: (): void => { 15 | dispatch(submitExam(ownProps.examTakeUrl, ownProps.cleanupBeforeSubmit)); 16 | }, 17 | }); 18 | 19 | export default connect(null, mapDispatchToProps)(SubmitButton); 20 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/containers/navbar/Scratch.ts: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { MSTP, MDTP } from '@student/exams/show/types'; 3 | import Scratch from '@student/exams/show/components/navbar/Scratch'; 4 | import { updateScratch } from '@student/exams/show/actions'; 5 | 6 | const mapStateToProps: MSTP<{ 7 | value: string; 8 | }> = (state) => ({ 9 | value: state.contents.answers.scratch ?? '', 10 | }); 11 | 12 | const mapDispatchToProps: MDTP<{ 13 | onChange: (newVal: string) => void; 14 | }> = (dispatch) => ({ 15 | onChange: (newVal): void => { 16 | dispatch(updateScratch(newVal)); 17 | }, 18 | }); 19 | 20 | export default connect(mapStateToProps, mapDispatchToProps)(Scratch); 21 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/containers/navbar/index.ts: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import ExamNavbar from '@student/exams/show/components/navbar'; 3 | import { MSTP, TimeInfo } from '@student/exams/show/types'; 4 | 5 | const mapStateToProps: MSTP<{ 6 | time: TimeInfo; 7 | }> = (state) => ({ 8 | time: state.contents.time, 9 | }); 10 | 11 | export default connect(mapStateToProps)(ExamNavbar); 12 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/containers/questions/AllThatApply.tsx: -------------------------------------------------------------------------------- 1 | import AllThatApply from '@student/exams/show/components/questions/AllThatApply'; 2 | import { connectWithPath } from './connectors'; 3 | 4 | export default connectWithPath(AllThatApply); 5 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/containers/questions/Code.ts: -------------------------------------------------------------------------------- 1 | import Code from '@student/exams/show/components/questions/Code'; 2 | import { connectWithPath } from './connectors'; 3 | 4 | export default connectWithPath(Code); 5 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/containers/questions/CodeTag.tsx: -------------------------------------------------------------------------------- 1 | import CodeTag from '@student/exams/show/components/questions/CodeTag'; 2 | import { connectWithPath } from './connectors'; 3 | 4 | export default connectWithPath(CodeTag); 5 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/containers/questions/Matching.tsx: -------------------------------------------------------------------------------- 1 | import Matching from '@student/exams/show/components/questions/Matching'; 2 | import { connectWithPath } from './connectors'; 3 | 4 | export default connectWithPath(Matching); 5 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/containers/questions/MultipleChoice.tsx: -------------------------------------------------------------------------------- 1 | import MultipleChoice from '@student/exams/show/components/questions/MultipleChoice'; 2 | import { connectWithPath } from './connectors'; 3 | 4 | export default connectWithPath(MultipleChoice); 5 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/containers/questions/Text.tsx: -------------------------------------------------------------------------------- 1 | import Text from '@student/exams/show/components/questions/Text'; 2 | import { connectWithPath } from './connectors'; 3 | 4 | export default connectWithPath(Text); 5 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/containers/questions/YesNo.ts: -------------------------------------------------------------------------------- 1 | import YesNo from '@student/exams/show/components/questions/YesNo'; 2 | import { connectWithPath } from './connectors'; 3 | 4 | export default connectWithPath(YesNo); 5 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/containers/scrollspy/connectors.ts: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { MSTP, MDTP } from '@student/exams/show/types'; 3 | import { spyQuestion } from '@student/exams/show/actions'; 4 | 5 | const mapStateToProps: MSTP<{ 6 | paginated: boolean; 7 | selectedQuestion: number; 8 | selectedPart: number; 9 | waypointsActive: boolean; 10 | }> = (state) => { 11 | const { 12 | paginated, 13 | pageCoords, 14 | page, 15 | waypointsActive, 16 | } = state.pagination; 17 | return { 18 | paginated, 19 | selectedQuestion: pageCoords[page].question, 20 | selectedPart: pageCoords[page].part, 21 | waypointsActive, 22 | }; 23 | }; 24 | 25 | const mapDispatchToProps: MDTP<{ 26 | spy: (question: number, pnum?: number) => void; 27 | }> = (dispatch) => ({ 28 | spy: (question, part): void => { 29 | dispatch(spyQuestion({ question, part })); 30 | }, 31 | }); 32 | 33 | export default connect(mapStateToProps, mapDispatchToProps); 34 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/helpers.ts: -------------------------------------------------------------------------------- 1 | export function getCSRFToken(): string { 2 | const elem: HTMLMetaElement = document.querySelector('[name=csrf-token]'); 3 | return elem?.content ?? ''; 4 | } 5 | 6 | /** 7 | * Flash an element's background color for emphasis. 8 | */ 9 | function pulse(elem: HTMLElement): void { 10 | const listener = (): void => { 11 | elem.removeEventListener('animationend', listener); 12 | elem.classList.remove('bg-pulse'); 13 | }; 14 | elem.addEventListener('animationend', listener); 15 | elem.classList.add('bg-pulse'); 16 | } 17 | 18 | export function scrollToElem(id: string, smooth = true): void { 19 | setTimeout(() => { 20 | const elem = document.getElementById(id); 21 | if (!elem) { return; } 22 | const elemTop = elem.getBoundingClientRect().top + window.pageYOffset; 23 | window.scrollTo({ 24 | left: 0, 25 | top: elemTop + 1, 26 | behavior: smooth ? 'smooth' : 'auto', 27 | }); 28 | pulse(elem); 29 | }); 30 | } 31 | 32 | export function scrollToQuestion(qnum: number, pnum?: number, smooth?: boolean): void { 33 | if (pnum !== undefined) { 34 | scrollToElem(`question-${qnum}-part-${pnum}`, smooth); 35 | } else { 36 | scrollToElem(`question-${qnum}`, smooth); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/reducers/contents.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ContentsState, 3 | ExamTakerAction, 4 | } from '@student/exams/show/types'; 5 | 6 | export default (state: ContentsState = { 7 | exam: undefined, 8 | answers: { 9 | answers: [], 10 | scratch: '', 11 | }, 12 | }, action: ExamTakerAction): ContentsState => { 13 | switch (action.type) { 14 | case 'LOAD_EXAM': { 15 | return { 16 | exam: action.exam, 17 | answers: action.answers, 18 | time: action.time, 19 | }; 20 | } 21 | case 'UPDATE_ANSWER': { 22 | const { qnum, pnum, bnum } = action; 23 | const answers = [...state.answers.answers]; 24 | answers[qnum] = [...answers[qnum]]; 25 | answers[qnum][pnum] = [...answers[qnum][pnum]]; 26 | answers[qnum][pnum][bnum] = action.val; 27 | return { 28 | ...state, 29 | answers: { 30 | ...state.answers, 31 | answers, 32 | }, 33 | }; 34 | } 35 | case 'UPDATE_SCRATCH': 36 | return { 37 | ...state, 38 | answers: { 39 | ...state.answers, 40 | scratch: action.val, 41 | }, 42 | }; 43 | default: 44 | return state; 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import lockdown from './lockdown'; 4 | import contents from './contents'; 5 | import pagination from './pagination'; 6 | import snapshot from './snapshot'; 7 | 8 | export default combineReducers({ 9 | lockdown, 10 | contents, 11 | pagination, 12 | snapshot, 13 | }); 14 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/reducers/lockdown.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LockdownStatus, 3 | LockdownState, 4 | ExamTakerAction, 5 | } from '@student/exams/show/types'; 6 | 7 | export default (state: LockdownState = { 8 | loaded: false, 9 | status: LockdownStatus.BEFORE, 10 | message: '', 11 | }, action: ExamTakerAction): LockdownState => { 12 | switch (action.type) { 13 | case 'IN_PROGRESS': 14 | return { 15 | ...state, 16 | status: LockdownStatus.IN_PROGRESS, 17 | message: 'Please wait...', 18 | }; 19 | case 'LOCKDOWN_IGNORED': 20 | return { 21 | ...state, 22 | status: LockdownStatus.IGNORED, 23 | message: '', 24 | }; 25 | case 'LOCKED_DOWN': 26 | return { 27 | ...state, 28 | status: LockdownStatus.LOCKED, 29 | message: '', 30 | }; 31 | case 'LOCKDOWN_FAILED': 32 | return { 33 | ...state, 34 | status: LockdownStatus.FAILED, 35 | message: action.message, 36 | }; 37 | case 'LOAD_EXAM': 38 | return { 39 | ...state, 40 | loaded: true, 41 | }; 42 | default: 43 | return state; 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/reducers/snapshot.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExamTakerAction, 3 | SnapshotState, 4 | SnapshotStatus, 5 | } from '@student/exams/show/types'; 6 | 7 | export default function snapshot(state: SnapshotState = { 8 | status: SnapshotStatus.LOADING, 9 | message: '', 10 | }, action: ExamTakerAction): SnapshotState { 11 | switch (action.type) { 12 | case 'LOAD_EXAM': 13 | return { 14 | ...state, 15 | status: SnapshotStatus.SUCCESS, 16 | }; 17 | case 'SNAPSHOT_SAVING': 18 | return { 19 | ...state, 20 | status: SnapshotStatus.LOADING, 21 | }; 22 | case 'SNAPSHOT_SUCCESS': 23 | return { 24 | ...state, 25 | status: SnapshotStatus.SUCCESS, 26 | }; 27 | case 'SNAPSHOT_FAILURE': 28 | return { 29 | ...state, 30 | status: SnapshotStatus.FAILURE, 31 | message: action.message, 32 | }; 33 | case 'SNAPSHOT_FINISHED': 34 | return { 35 | ...state, 36 | status: SnapshotStatus.FINISHED, 37 | message: action.message, 38 | }; 39 | default: 40 | return state; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/packs/components/workflows/student/exams/show/store/index.ts: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, createStore } from 'redux'; 2 | import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly'; 3 | import ReduxThunk from 'redux-thunk'; 4 | import rootReducer from '@student/exams/show/reducers'; 5 | 6 | const composeEnhancers = composeWithDevTools({ 7 | trace: true, 8 | traceLimit: 25, 9 | }); 10 | 11 | const reduxEnhancers = composeEnhancers( 12 | applyMiddleware(ReduxThunk), 13 | ); 14 | 15 | export default createStore(rootReducer, reduxEnhancers); 16 | -------------------------------------------------------------------------------- /app/packs/components/workflows/wdyr.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | // eslint-disable-next-line import/no-extraneous-dependencies 3 | import whyDidYouRender from '@welldone-software/why-did-you-render'; 4 | 5 | whyDidYouRender(React, { 6 | trackAllPureComponents: true, 7 | }); 8 | -------------------------------------------------------------------------------- /app/packs/entrypoints/application.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // This file is automatically compiled by Webpack, along with any other files 4 | // present in this directory. You're encouraged to place your actual application logic in 5 | // a relevant structure within app/javascript and only use these pack files to reference 6 | // that code so it'll be compiled. 7 | 8 | require('@rails/ujs').start(); 9 | const componentRequireContext = require.context('components', true); 10 | const ReactRailsUJS = require('react_ujs'); 11 | 12 | const images = require.context('../images', true); 13 | 14 | ReactRailsUJS.useContext(componentRequireContext); 15 | -------------------------------------------------------------------------------- /app/packs/entrypoints/bootstrap.js: -------------------------------------------------------------------------------- 1 | import 'bootstrap/js/dist/alert'; 2 | import 'bootstrap/js/dist/button'; 3 | import 'bootstrap/js/dist/carousel'; 4 | import 'bootstrap/js/dist/collapse'; 5 | import 'bootstrap/js/dist/dropdown'; 6 | import 'bootstrap/js/dist/index'; 7 | import 'bootstrap/js/dist/modal'; 8 | import 'bootstrap/js/dist/popover'; 9 | import 'bootstrap/js/dist/scrollspy'; 10 | import 'bootstrap/js/dist/tab'; 11 | import 'bootstrap/js/dist/toast'; 12 | import 'bootstrap/js/dist/tooltip'; 13 | import 'bootstrap/js/dist/util'; 14 | 15 | import './bootstrap.scss'; 16 | -------------------------------------------------------------------------------- /app/packs/entrypoints/bootstrap.scss: -------------------------------------------------------------------------------- 1 | @import "~bootstrap/scss/bootstrap"; 2 | @import "~bootstrap/scss/functions"; 3 | @import "~bootstrap/scss/variables"; 4 | @import "~bootstrap/scss/mixins"; 5 | 6 | html, 7 | body { 8 | height: 100%; 9 | } 10 | 11 | .form-signin { 12 | width: 100%; 13 | max-width: 330px; 14 | padding: 15px; 15 | margin: 0 auto; 16 | 17 | .form-check { 18 | margin-bottom: 2rem; 19 | } 20 | 21 | .form-control { 22 | position: relative; 23 | box-sizing: border-box; 24 | height: auto; 25 | padding: 10px; 26 | font-size: 16px; 27 | } 28 | 29 | .form-control:focus { 30 | z-index: 2; 31 | } 32 | 33 | .form-group { 34 | margin: 0; 35 | } 36 | 37 | input[type="password"] { 38 | margin-bottom: 10px; 39 | border-top-left-radius: 0; 40 | border-top-right-radius: 0; 41 | } 42 | } 43 | 44 | #username { 45 | margin-bottom: -1px; 46 | border-bottom-right-radius: 0; 47 | border-bottom-left-radius: 0; 48 | } 49 | -------------------------------------------------------------------------------- /app/packs/images/navbar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/app/packs/images/navbar.png -------------------------------------------------------------------------------- /app/packs/images/site-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/app/packs/images/site-icon.png -------------------------------------------------------------------------------- /app/packs/rawimages/hourglass-dolphin.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/app/packs/rawimages/hourglass-dolphin.jpg -------------------------------------------------------------------------------- /app/packs/rawimages/hourglass-dolphin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/app/packs/rawimages/hourglass-dolphin.png -------------------------------------------------------------------------------- /app/packs/relay/data/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/app/packs/relay/data/.keep -------------------------------------------------------------------------------- /app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= csrf_meta_tags %> 8 | <%= csp_meta_tag %> 9 | 10 | <% default_page_title = "#{params[:controller]} / #{params[:action]}" %> 11 | <%= @page_title || default_page_title %> - Hourglass 12 | 13 | <%= stylesheet_pack_tag 'application', 'bootstrap', media: 'all' %> 14 | <%= javascript_pack_tag 'application', 'bootstrap' %> 15 | 16 | <%= favicon_pack_tag 'site-icon.png', id: 'favicon' %> 17 | 18 | 19 | 20 | <%= yield %> 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | load File.expand_path('spring', __dir__) 3 | APP_PATH = File.expand_path('../config/application', __dir__) 4 | require_relative '../config/boot' 5 | require 'rails/commands' 6 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | load File.expand_path('spring', __dir__) 3 | require_relative '../config/boot' 4 | require 'rake' 5 | Rake.application.run 6 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'fileutils' 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path('..', __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | # Install JavaScript dependencies 21 | system! 'bin/yarn' 22 | 23 | # puts "\n== Copying sample files ==" 24 | # unless File.exist?('config/database.yml') 25 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml' 26 | # end 27 | 28 | puts "\n== Preparing database ==" 29 | system! 'bin/rails db:prepare' 30 | 31 | puts "\n== Removing old logs and tempfiles ==" 32 | system! 'bin/rails log:clear tmp:clear' 33 | 34 | puts "\n== Restarting application server ==" 35 | system! 'bin/rails restart' 36 | end 37 | -------------------------------------------------------------------------------- /bin/spring: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | if !defined?(Spring) && [nil, "development", "test"].include?(ENV["RAILS_ENV"]) 3 | gem "bundler" 4 | require "bundler" 5 | 6 | # Load Spring without loading other gems in the Gemfile, for speed. 7 | Bundler.locked_gems&.specs&.find { |spec| spec.name == "spring" }&.tap do |spring| 8 | Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path 9 | gem "spring", spring.version 10 | require "spring/binstub" 11 | rescue Gem::LoadError 12 | # Ignore when Spring is not installed. 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /bin/webpack: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development" 4 | ENV["NODE_ENV"] ||= "development" 5 | 6 | require "pathname" 7 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 8 | Pathname.new(__FILE__).realpath) 9 | 10 | require "bundler/setup" 11 | 12 | require "webpacker" 13 | require "webpacker/webpack_runner" 14 | 15 | APP_ROOT = File.expand_path("..", __dir__) 16 | Dir.chdir(APP_ROOT) do 17 | Webpacker::WebpackRunner.run(ARGV) 18 | end 19 | -------------------------------------------------------------------------------- /bin/webpack-dev-server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development" 4 | ENV["NODE_ENV"] ||= "development" 5 | 6 | require "pathname" 7 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 8 | Pathname.new(__FILE__).realpath) 9 | 10 | require "bundler/setup" 11 | 12 | require "webpacker" 13 | require "webpacker/dev_server_runner" 14 | 15 | APP_ROOT = File.expand_path("..", __dir__) 16 | Dir.chdir(APP_ROOT) do 17 | Webpacker::DevServerRunner.run(ARGV) 18 | end 19 | -------------------------------------------------------------------------------- /bin/yarn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | APP_ROOT = File.expand_path("..", __dir__) 4 | Dir.chdir(APP_ROOT) do 5 | yarn = ENV["PATH"].split(File::PATH_SEPARATOR). 6 | select { |dir| File.expand_path(dir) != __dir__ }. 7 | product(["yarn", "yarnpkg", "yarn.cmd", "yarn.ps1"]). 8 | map { |dir, file| File.expand_path(file, dir) }. 9 | find { |file| File.executable?(file) } 10 | 11 | if yarn 12 | exec yarn, *ARGV 13 | else 14 | $stderr.puts "Yarn executable was not detected in the system." 15 | $stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install" 16 | exit 1 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is used by Rack-based servers to start the application. 4 | 5 | require_relative 'config/environment' 6 | 7 | run Rails.application 8 | Rails.application.load_server 9 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 4 | 5 | require 'bundler/setup' # Set up gems listed in the Gemfile. 6 | require 'bootsnap/setup' # Speed up boot time by caching expensive operations. 7 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | # development: 2 | # adapter: async 3 | # 4 | # test: 5 | # adapter: test 6 | # 7 | # production: 8 | # adapter: redis 9 | # url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | # channel_prefix: hourglass_production 11 | 12 | production: 13 | adapter: postgresql 14 | 15 | development: 16 | adapter: postgresql 17 | 18 | test: 19 | adapter: postgresql 20 | -------------------------------------------------------------------------------- /config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | Kl4b+RxwNsqGlBkwgKFd6oA2dqq+VCvryUaQytIEqhKyYLFUeoHcXoQ3GzI8siOBm21Tuz7NzG6hTukMfnJcuXvj/t8Fpba+UdWxjjlbYE6ooleEHj9/E4Arx8mNi3exxmddTh+NEErSt74sBxJCr5ohCvqlBJoOTu3cBLmHh4S9NkOiylE/UdKupwCH0hv+jmZJYpTpitsAl16QtK5mwDELFxP1iaBoFl/ICC5mMyAg+0F0WwkqxnVlhIiAQVBFexeGjzoM8jdw+bRthYpOSJdqNeC6T5jujTL3fuawIRfFo1AwjjIFGiZnV2Wb5sCW8j5SVZzTAhliFGNwYABouBV5jdD77zoXeBkvcAJy9A7VVWPB5XzdmaTAsk4SVga+c3lmeIyG4MKGw43GM/i7BLHx1ug6d41bs21x--ur/fG9mCEoMmGg8a--xEuwdt3f7ew3Z8cfU8S55Q== -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Load the Rails application. 4 | require_relative 'application' 5 | 6 | # Initialize the Rails application. 7 | Rails.application.initialize! 8 | -------------------------------------------------------------------------------- /config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Be sure to restart your server when you modify this file. 3 | 4 | # ActiveSupport::Reloader.to_prepare do 5 | # ApplicationController.renderer.defaults.merge!( 6 | # http_host: 'example.org', 7 | # https: false 8 | # ) 9 | # end 10 | -------------------------------------------------------------------------------- /config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Be sure to restart your server when you modify this file. 3 | 4 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 5 | # Rails.backtrace_cleaner.add_silencer { |line| /my_noisy_library/.match?(line) } 6 | 7 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code 8 | # by setting BACKTRACE=1 before calling your invocation, like "BACKTRACE=1 ./bin/rails runner 'MyClass.perform'". 9 | Rails.backtrace_cleaner.remove_silencers! if ENV['BACKTRACE'] 10 | -------------------------------------------------------------------------------- /config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Specify a serializer for the signed and encrypted cookie jars. 6 | # Valid options are :json, :marshal, and :hybrid. 7 | Rails.application.config.action_dispatch.cookies_serializer = :json 8 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Configure sensitive parameters which will be filtered from the log file. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn 8 | ] 9 | -------------------------------------------------------------------------------- /config/initializers/graphql_schema_updater.rb: -------------------------------------------------------------------------------- 1 | reloader = ActiveSupport::FileUpdateChecker.new([], { 2 | Rails.root.join('app/graphql/types').to_s => ['rb'], 3 | Rails.root.join('app/graphql/mutations').to_s => ['rb'], 4 | }) do 5 | HourglassSchema.write_json! 6 | HourglassSchema.write_graphql! 7 | end 8 | 9 | Rails.application.config.to_prepare do 10 | reloader.execute_if_updated 11 | end 12 | 13 | Rails.application.config.after_initialize do 14 | HourglassSchema.write_json! 15 | HourglassSchema.write_graphql! 16 | end 17 | 18 | if File.exists?(Rails.root.join('config/schemas/graphql-queries.json')) 19 | STATIC_GRAPHQL_QUERIES = JSON.parse(File.read(Rails.root.join('config/schemas/graphql-queries.json'))) 20 | KNOWN_GRAPHQL_QUERIES = STATIC_GRAPHQL_QUERIES.invert 21 | else 22 | STATIC_GRAPHQL_QUERIES = {} 23 | KNOWN_GRAPHQL_QUERIES = STATIC_GRAPHQL_QUERIES.invert 24 | end 25 | 26 | if Rails.env.production? 27 | STATIC_GRAPHQL_QUERIES.values.each(&:freeze) 28 | STATIC_GRAPHQL_QUERIES.freeze 29 | KNOWN_GRAPHQL_QUERIES.values.each(&:freeze) 30 | KNOWN_GRAPHQL_QUERIES.freeze 31 | end 32 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Be sure to restart your server when you modify this file. 3 | 4 | # Add new inflection rules using the following format. Inflections 5 | # are locale specific, and you may define rules for as many different 6 | # locales as you wish. All of these examples are active by default: 7 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 8 | # inflect.plural /^(ox)$/i, '\1en' 9 | # inflect.singular /^(ox)en/i, '\1' 10 | # inflect.irregular 'person', 'people' 11 | # inflect.uncountable %w( fish sheep ) 12 | # end 13 | 14 | # These inflection rules are supported but not enabled by default: 15 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 16 | # inflect.acronym 'RESTful' 17 | # end 18 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Be sure to restart your server when you modify this file. 3 | 4 | # Add new mime types for use in respond_to blocks: 5 | # Mime::Type.register "text/richtext", :rtf 6 | -------------------------------------------------------------------------------- /config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Define an application-wide HTTP permissions policy. For further 2 | # information see https://developers.google.com/web/updates/2018/06/feature-policy 3 | # 4 | # Rails.application.config.permissions_policy do |f| 5 | # f.camera :none 6 | # f.gyroscope :none 7 | # f.microphone :none 8 | # f.usb :none 9 | # f.fullscreen :self 10 | # f.payment :self, "https://secure.example.com" 11 | # end 12 | -------------------------------------------------------------------------------- /config/initializers/secret_token.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | # Your secret key for verifying the integrity of signed cookies. 3 | # If you change this key, all old signed cookies will become invalid! 4 | # Make sure the secret is at least 30 characters and all random, 5 | # no regular words or you'll be exposed to dictionary attacks. 6 | 7 | if Rails.env.production? 8 | require 'securerandom' 9 | key_file = File.expand_path("~/.rails_key").to_s 10 | unless File.exists?(key_file) 11 | kk = File.open(key_file, 'wb') 12 | 6.times do 13 | kk.write(SecureRandom.urlsafe_base64) 14 | end 15 | kk.close 16 | end 17 | if Rails.version.to_f <= 5.0 18 | Hourglass::Application.config.secret_token = File.open(key_file).read 19 | else 20 | Hourglass::Application.config.secret_key_base = File.open(key_file).read 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # This file contains settings for ActionController::ParamsWrapper which 6 | # is enabled by default. 7 | 8 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 9 | ActiveSupport.on_load(:action_controller) do 10 | wrap_parameters format: [:json] 11 | end 12 | 13 | # To enable root element in JSON for ActiveRecord objects. 14 | # ActiveSupport.on_load(:active_record) do 15 | # self.include_root_in_json = true 16 | # end 17 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | mount ActionCable.server => '/cable' 5 | post '/graphql', to: 'graphql#execute' 6 | get '/graphiql', to: 'graphql#graphiql' if Rails.env.development? 7 | 8 | namespace :api, shallow: true do 9 | namespace :professor do 10 | resources :exams, param: 'exam_id', only: [] do 11 | member do 12 | resources :versions, param: 'version_id', only: [] do 13 | collection do 14 | post :import 15 | end 16 | member do 17 | get :export_file 18 | get :export_archive 19 | end 20 | end 21 | end 22 | end 23 | end 24 | 25 | namespace :student do 26 | resources :exams, param: 'exam_id', only: [] do 27 | member do 28 | post :take 29 | end 30 | end 31 | end 32 | end 33 | 34 | devise_for :users, skip: [:registrations, :passwords], controllers: { 35 | omniauth_callbacks: 'users/omniauth_callbacks', 36 | } 37 | 38 | root to: 'main#index' 39 | get '*path', to: 'main#index' 40 | end 41 | -------------------------------------------------------------------------------- /config/spring.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Spring.watch( 4 | '.ruby-version', 5 | '.rbenv-vars', 6 | 'tmp/restart.txt', 7 | 'tmp/caching-dev.txt' 8 | ) 9 | -------------------------------------------------------------------------------- /config/webpack/development.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'development' 2 | 3 | const webpackConfig = require('./base') 4 | 5 | module.exports = webpackConfig 6 | -------------------------------------------------------------------------------- /config/webpack/production.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'production' 2 | 3 | const { merge } = require('@rails/webpacker'); 4 | const webpackConfig = require('./base') 5 | 6 | module.exports = merge( 7 | { 8 | module: { 9 | rules: [ 10 | { 11 | test: /graphiql|wdyr/, 12 | use: [{ loader: 'ignore-loader' }], 13 | }, 14 | ], 15 | }, 16 | }, 17 | webpackConfig, 18 | ); 19 | 20 | module.exports = webpackConfig 21 | -------------------------------------------------------------------------------- /config/webpack/test.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'development' 2 | 3 | const webpackConfig = require('./base') 4 | 5 | module.exports = webpackConfig 6 | -------------------------------------------------------------------------------- /db/migrate/20201022000727_add_unique_bottlenose_id_index_to_section.rb: -------------------------------------------------------------------------------- 1 | class AddUniqueBottlenoseIdIndexToSection < ActiveRecord::Migration[6.0] 2 | def change 3 | add_index :sections, [:bottlenose_id], unique: true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20210301005626_remove_rubrics_from_exam_info.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Removes rubric information from exam version's `info` field. 4 | # This information is now schematized in the database. 5 | class RemoveRubricsFromExamInfo < ActiveRecord::Migration[6.0] 6 | def up 7 | ExamVersion.transaction do 8 | ExamVersion.all.each do |ev| 9 | ev.update(info: ev.info.reject { |k| k == 'rubrics' }) 10 | end 11 | end 12 | end 13 | 14 | def down 15 | raise 'NOT IMPLEMENTED YET' 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/20210331000943_change_questions_to_student_questions.rb: -------------------------------------------------------------------------------- 1 | class ChangeQuestionsToStudentQuestions < ActiveRecord::Migration[6.0] 2 | def change 3 | rename_table "questions", "student_questions" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20210519203405_mark_preset_comments_order_required.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Marks `order` as required for preset comments. 4 | class MarkPresetCommentsOrderRequired < ActiveRecord::Migration[6.0] 5 | def up 6 | PresetComment.all.group_by(&:rubric_preset_id).each do |_, comments| 7 | max_order = comments.map(&:order).compact.max || -1 8 | missing_order_comments = comments.filter { |c| c.order.nil? } 9 | missing_order_comments.each_with_index do |comment, index| 10 | comment.update(order: max_order + index + 1) 11 | end 12 | end 13 | change_column :preset_comments, :order, :integer, null: false 14 | end 15 | 16 | def down 17 | change_column :preset_comments, :order, :integer, null: true 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /db/migrate/20210521115135_create_rubric_closure_tree.rb: -------------------------------------------------------------------------------- 1 | class CreateRubricClosureTree < ActiveRecord::Migration[6.0] 2 | def up 3 | create_table :rubric_tree_paths do |t| 4 | t.references :ancestor, null: false, foreign_key: { to_table: :rubrics } 5 | t.references :descendant, null: false, foreign_key: { to_table: :rubrics } 6 | t.integer :path_length, null: false 7 | t.index [:ancestor_id, :descendant_id], unique: true 8 | end 9 | all_rubrics = Rubric.all.map { |r| [r.id, r] }.to_h 10 | all_rubrics.each do |r_id, r| 11 | RubricTreePath.create(ancestor: r, descendant: r, path_length: 0) 12 | parent = all_rubrics[r.parent_section_id] 13 | path_length = 1 14 | while parent 15 | RubricTreePath.create(ancestor: parent, descendant: r, path_length: path_length) 16 | parent = all_rubrics[parent.parent_section_id] 17 | path_length += 1 18 | end 19 | end 20 | end 21 | 22 | def down 23 | drop_table :rubric_tree_paths 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /db/migrate/20210521121346_remove_rubric_parent_section.rb: -------------------------------------------------------------------------------- 1 | class RemoveRubricParentSection < ActiveRecord::Migration[6.0] 2 | def up 3 | change_table :rubrics do |t| 4 | t.remove :parent_section_id 5 | end 6 | end 7 | 8 | def down 9 | change_table :rubrics do |t| 10 | t.references :parent_section, null: true, foreign_key: { to_table: :rubrics } 11 | end 12 | RubricTreePath.where(path_length: 1) do |link| 13 | link.descendant.update(parent_section_id: link.ancestor_id) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /db/migrate/20210523182059_remove_info_from_exam_versions.rb: -------------------------------------------------------------------------------- 1 | class RemoveInfoFromExamVersions < ActiveRecord::Migration[6.0] 2 | def up 3 | change_table :exam_versions do |t| 4 | t.remove :info 5 | end 6 | end 7 | 8 | def down 9 | change_table :exam_versions do |t| 10 | t.jsonb :info, null: false, default: {placeholder: true} 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20210911223649_create_terms.rb: -------------------------------------------------------------------------------- 1 | class CreateTerms < ActiveRecord::Migration[6.0] 2 | def up 3 | create_table :terms do |t| 4 | t.integer :semester, null: false 5 | t.integer :year, null: false 6 | t.boolean :archived, default: false, null: false 7 | 8 | t.timestamps 9 | t.index ["semester", "year"], name: "index_terms_on_semester_and_year", unique: true 10 | end 11 | empty_term = Term.create(semester: Term.semesters["fall"], year: 2000) 12 | change_table :courses do |t| 13 | t.references :term, foreign_key: true 14 | end 15 | change_column_null :courses, :term_id, false, empty_term.id 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/20220911215356_add_times_to_exam_versions.rb: -------------------------------------------------------------------------------- 1 | class AddTimesToExamVersions < ActiveRecord::Migration[6.1] 2 | def change 3 | add_column :exam_versions, :start_time, :datetime, null: true 4 | add_column :exam_versions, :end_time, :datetime, null: true 5 | add_column :exam_versions, :duration, :integer, null: true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20231106011324_add_updated_index_to_snapshots.rb: -------------------------------------------------------------------------------- 1 | class AddUpdatedIndexToSnapshots < ActiveRecord::Migration[6.1] 2 | def change 3 | change_table :snapshots do |t| 4 | t.index :created_at 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20231116195043_add_pins_to_students_registrations.rb: -------------------------------------------------------------------------------- 1 | class AddPinsToStudentsRegistrations < ActiveRecord::Migration[6.1] 2 | def change 3 | change_table :exam_versions do |t| 4 | t.string :pin_nonce, null: true 5 | t.integer :pin_strength, null: false, default: 6 6 | end 7 | change_table :accommodations do |t| 8 | t.string :policy_exemptions, default: "", null: false 9 | end 10 | change_table :registrations do |t| 11 | t.integer :login_attempt_count, null: false, default: 0 12 | t.boolean :pin_validated, null: false, default: false 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20240531182212_add_notes_to_grading_lock.rb: -------------------------------------------------------------------------------- 1 | class AddNotesToGradingLock < ActiveRecord::Migration[6.1] 2 | def change 3 | change_table :grading_locks do |t| 4 | t.string :notes, null: false, default: "" 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/seeds.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # This file should contain all the record creation needed to seed the database with its default values. 3 | # The data can then be loaded with the rails db:seed command (or created alongside the database with db:setup). 4 | # 5 | # Examples: 6 | # 7 | # movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }]) 8 | # Character.create(name: 'Luke', movie: movies.first) 9 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1637010500, 6 | "narHash": "sha256-CVcZs8QP0Y2NbsizhgI/P42FqdeqXNe4h11W1Wk1aFY=", 7 | "owner": "nixos", 8 | "repo": "nixpkgs", 9 | "rev": "5e15d5da4abb74f0dd76967044735c70e94c5af1", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "nixos", 14 | "repo": "nixpkgs", 15 | "rev": "5e15d5da4abb74f0dd76967044735c70e94c5af1", 16 | "type": "github" 17 | } 18 | }, 19 | "nixpkgs-ruby": { 20 | "locked": { 21 | "lastModified": 1637010500, 22 | "narHash": "sha256-CVcZs8QP0Y2NbsizhgI/P42FqdeqXNe4h11W1Wk1aFY=", 23 | "owner": "nixos", 24 | "repo": "nixpkgs", 25 | "rev": "5e15d5da4abb74f0dd76967044735c70e94c5af1", 26 | "type": "github" 27 | }, 28 | "original": { 29 | "owner": "nixos", 30 | "repo": "nixpkgs", 31 | "rev": "5e15d5da4abb74f0dd76967044735c70e94c5af1", 32 | "type": "github" 33 | } 34 | }, 35 | "root": { 36 | "inputs": { 37 | "nixpkgs": "nixpkgs", 38 | "nixpkgs-ruby": "nixpkgs-ruby" 39 | } 40 | } 41 | }, 42 | "root": "root", 43 | "version": 7 44 | } 45 | -------------------------------------------------------------------------------- /hourglass.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | root /home/app/hourglass/public; 4 | passenger_enabled on; 5 | passenger_user app; 6 | passenger_app_root /home/app/hourglass; 7 | 8 | passenger_env_var RAILS_ENV production; 9 | passenger_env_var NODE_ENV production; 10 | passenger_env_var SECRET_KEY_BASE aaaaa; 11 | passenger_env_var HOURGLASS_DATABASE_HOST postgres; 12 | passenger_env_var BOTTLENOSE_URL http://bottlenose; 13 | passenger_env_var BOTTLENOSE_APP_ID YYdiyTMC4HRH9WpTvrFmpRHAf8xY09c67woaNzbI1OQ; 14 | passenger_env_var BOTTLENOSE_APP_SECRET RIu1dprvaQ5swuvtYXOvqNvbt3CbANaAr5KA1Eg-cpk; 15 | 16 | passenger_app_type rack; 17 | passenger_startup_file /home/app/hourglass/config.ru; 18 | 19 | location /cable { 20 | passenger_app_group_name hourglass_websocket; 21 | passenger_force_max_concurrent_requests_per_process 0; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/lib/assets/.keep -------------------------------------------------------------------------------- /lib/audit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # An Auditing logger 4 | class Audit 5 | @@log = Logger.new(File.open(Rails.root.join('log', "audit-#{Rails.env}.log"), 6 | File::WRONLY | File::APPEND | File::CREAT)) 7 | def self.log(msg) 8 | @@log.info("#{Time.now}: #{msg}") 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/devise/strategies/debug_login.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # From https://insights.kyan.com/devise-authentication-strategies-a1a6b4e2b891 4 | module Devise 5 | module Strategies 6 | # A Devise login mechanism to allow locally testing username/passwords, without LDAP 7 | class DebugLogin < Authenticatable 8 | def authenticate! 9 | user = User.find_by(username: params[:user][:username]) 10 | if user && 11 | params[:user][:password].present? && 12 | Devise::Encryptor.compare(user.class, user.encrypted_password, params[:user][:password]) 13 | success!(user) 14 | else 15 | fail('Did not recognize username/password') # rubocop:disable Style/SignalException 16 | end 17 | end 18 | 19 | def valid? 20 | params[:user] && params[:user][:username] && params[:user][:password] 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/lib/tasks/.keep -------------------------------------------------------------------------------- /lib/tasks/factory_bot_lint.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :factory_bot do 4 | desc 'Verify that all FactoryBot factories are valid' 5 | task lint: :environment do 6 | require 'factory_bot_rails' 7 | if Rails.env.test? 8 | conn = ActiveRecord::Base.connection 9 | conn.transaction do 10 | FactoryBot.lint 11 | raise ActiveRecord::Rollback 12 | end 13 | else 14 | system("bundle exec rake factory_bot:lint RAILS_ENV='test'") 15 | fail if $?.exitstatus.nonzero? 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/tasks/graphql_schema_update.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :graphql do 4 | desc 'Update schema.json file' 5 | task update_schema: :environment do 6 | HourglassSchema.write_json! 7 | HourglassSchema.write_graphql! 8 | HourglassSchema.ensure_queries_file! 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/log/.keep -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('postcss-import'), 4 | require('postcss-flexbugs-fixes'), 5 | require('postcss-preset-env')({ 6 | autoprefixer: { 7 | flexbox: 'no-2009' 8 | }, 9 | stage: 3 10 | }) 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/public/favicon.ico -------------------------------------------------------------------------------- /public/find_x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/public/find_x.jpg -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /relay.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // ... 3 | // Configuration options accepted by the `relay-compiler` command-line tool and `babel-plugin-relay`. 4 | src: './app/packs/components', 5 | schema: './app/packs/relay/data/schema.graphql', 6 | exclude: ['**/node_modules/**', '**/__generated__/**'], 7 | language: 'typescript', 8 | noFutureProofEnums: true, 9 | customScalars: { 10 | ISO8601DateTime: 'string', 11 | }, 12 | persistConfig: { 13 | file: "./config/schemas/graphql-queries.json", 14 | algorithm: "MD5", 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | (import (fetchTarball { 2 | url = "https://github.com/edolstra/flake-compat/archive/99f1c2157fba4bfe6211a321fd0ee43199025dbf.tar.gz"; 3 | sha256 = "0x2jn3vrawwv9xp15674wjz9pixwjyj3j771izayl962zziivbx2"; 4 | }) { 5 | src = ./.; 6 | }).shellNix 7 | -------------------------------------------------------------------------------- /test/application_system_test_case.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class ApplicationSystemTestCase < ActionDispatch::SystemTestCase 6 | include Devise::Test::IntegrationHelpers 7 | DRIVER = if ENV['DRIVER'] 8 | ENV['DRIVER'].to_sym 9 | else 10 | :headless_chrome 11 | end 12 | driven_by :selenium, using: DRIVER, screen_size: [1400, 1400] 13 | 14 | def with_resize_to(width, height) 15 | old_width, old_height = page.current_window.size 16 | page.current_window.resize_to(width, height) 17 | begin 18 | yield 19 | ensure 20 | page.current_window.resize_to(old_width, old_height) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/channels/graphql_channel_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class GraphqlChannelTest < ActionCable::Channel::TestCase 6 | # test "subscribes" do 7 | # subscribe 8 | # assert subscription.confirmed? 9 | # end 10 | end 11 | -------------------------------------------------------------------------------- /test/controllers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/test/controllers/.keep -------------------------------------------------------------------------------- /test/controllers/main_controller_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class MainControllerTest < ActionDispatch::IntegrationTest 6 | test 'should get home' do 7 | get root_url 8 | assert_redirected_to new_user_session_path 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/factories/accommodation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :accommodation do 5 | registration 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/factories/anomaly.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :anomaly do 5 | registration 6 | reason { 'Left fullscreen.' } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/factories/course.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :course do 5 | sequence(:title, 101) { |n| "Computing #{n}" } 6 | term 7 | last_sync { '2020-05-22 14:03:53' } 8 | active { true } 9 | sequence(:bottlenose_id) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/factories/exam.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :exam do 5 | course 6 | name { 'CS2500 Midterm' } 7 | duration { 30.minutes } 8 | start_time { DateTime.now } 9 | end_time { DateTime.now + 3.hours } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/factories/exam_announcement.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :exam_announcement do 5 | exam 6 | body { "Hello all students. You are taking #{exam.name}." } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/factories/grading_check.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :grading_check do 5 | transient do 6 | staff_registration { create(:staff_registration) } 7 | end 8 | 9 | registration 10 | creator { staff_registration.user } 11 | 12 | question { registration.exam_version.db_questions.find_by(index: 0) } 13 | part { question.parts.find_by(index: 0) } 14 | body_item { part.body_items.find_by(index: 0) } 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/factories/grading_comment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :grading_comment do 5 | transient do 6 | staff_registration { create(:staff_registration) } 7 | end 8 | 9 | registration 10 | creator { staff_registration.user } 11 | 12 | message { 'You answered incorrectly.' } 13 | 14 | points { 10 } 15 | 16 | question { registration.exam_version.db_questions.find_by(index: 0) } 17 | part { question.parts.find_by(index: 0) } 18 | body_item { part.body_items.find_by(index: 0) } 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/factories/grading_lock.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :grading_lock do 5 | transient do 6 | staff_registration { create(:staff_registration) } 7 | end 8 | 9 | registration 10 | grader { staff_registration.user } 11 | 12 | question { registration.exam_version.db_questions.find_by(index: 0) } 13 | part { question.parts.find_by(index: 0) } 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/factories/message.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :message do 5 | transient do 6 | prof_reg { create(:professor_course_registration, course: registration.exam.course) } 7 | end 8 | 9 | sender { prof_reg.user } 10 | registration 11 | body { 'Read the directions for that question more carefully..' } 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/factories/proctor_registration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :proctor_registration do 5 | user 6 | exam 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/factories/professor_course_registrations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :professor_course_registration do 5 | course 6 | user 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/factories/registration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :registration do 5 | transient do 6 | student_registration { create(:student_registration, course: exam_version.course) } 7 | end 8 | 9 | user { student_registration.user } 10 | exam_version 11 | 12 | # Student starts at the start of their window 13 | trait :early_start do 14 | start_time { accommodated_start_time } 15 | end 16 | 17 | # Student starts 1/8 of the way into their window 18 | trait :normal_start do 19 | start_time { accommodated_start_time + (accommodated_duration / 8.0) } 20 | end 21 | 22 | # Student starts with 1/4 of their time remaining in their window 23 | trait :late_start do 24 | start_time { accommodated_end_time - (accommodated_duration / 4.0) } 25 | end 26 | 27 | trait :done do 28 | early_start 29 | end_time { start_time + effective_duration } 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/factories/room.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :room do 5 | transient do 6 | sequence(:room_number, 200) 7 | end 8 | 9 | exam 10 | name { "Richards #{room_number}" } 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/factories/room_announcement.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :room_announcement do 5 | room 6 | body { "Hello, all students in #{room.name}!" } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/factories/section.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :section do 5 | lecture 6 | course 7 | sequence(:bottlenose_id) 8 | 9 | trait :lecture do 10 | title { 'Lecture (TF 11:45-1:25)' } 11 | end 12 | 13 | trait :lab do 14 | title { 'Lab (F 1:25-3:15)' } 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/factories/snapshot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :snapshot do 5 | registration 6 | answers { registration.exam_version.default_answers } 7 | 8 | trait :long_answers do 9 | answers do 10 | JSON.parse(Rails.root.join('test/fixtures/files/long-snapshot.json').read) 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/factories/staff_registration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :staff_registration do 5 | user 6 | section 7 | 8 | trait :ta do 9 | ta { true } 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/factories/student_question.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :student_question do 5 | registration 6 | body { 'Am I allowed to use toBinaryString?' } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/factories/student_registration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :student_registration do 5 | transient do 6 | course { create(:course) } 7 | end 8 | 9 | user 10 | section { create(:section, course: course) } 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/factories/term.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :term do 5 | sequence(:year, 2000) 6 | semester { Term.semesters.values.sample } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/factories/upload.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../helpers/upload' 4 | 5 | FactoryBot.define do 6 | factory :upload do 7 | cs2500_v1 8 | 9 | trait :blank do 10 | transient do 11 | file_name { 'blank' } 12 | end 13 | end 14 | 15 | trait :cs2500_v1 do 16 | transient do 17 | file_name { 'cs2500midterm-v1' } 18 | end 19 | end 20 | 21 | trait :cs2500_v2 do 22 | transient do 23 | file_name { 'cs2500midterm-v2' } 24 | end 25 | end 26 | 27 | trait :cs3500_v1 do 28 | transient do 29 | file_name { 'cs3500final-v1' } 30 | end 31 | end 32 | 33 | trait :cs3500_v2 do 34 | transient do 35 | file_name { 'cs3500final-v2' } 36 | end 37 | end 38 | 39 | trait :extra_credit do 40 | transient do 41 | file_name { 'extra-credits' } 42 | end 43 | end 44 | 45 | initialize_with do 46 | UploadTestHelper.with_test_uploaded_fixture_zip file_name do |real_upload| 47 | Upload.new(real_upload) 48 | end 49 | end 50 | 51 | to_create do 52 | # deliberately do nothing 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/factories/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :user do 5 | transient do 6 | sequence :num 7 | end 8 | 9 | username { "user#{num}" } 10 | encrypted_password { Devise::Encryptor.digest(User, username) } 11 | display_name { username } 12 | email { "#{username}@localhost.localdomain" } 13 | nuid { 100_000_000 + num } 14 | 15 | factory :admin do 16 | admin { true } 17 | display_name { 'Admin' } 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/factories/version_announcement.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :version_announcement do 5 | exam_version 6 | body { "Hello all students, welcome to #{exam_version.exam.name}!" } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/fixtures/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/test/fixtures/.keep -------------------------------------------------------------------------------- /test/fixtures/files/blank/exam.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | policies: [] 3 | contents: 4 | instructions: "" 5 | questions: 6 | - description: "Placeholder" 7 | separateSubparts: false 8 | parts: 9 | - description: "Placeholder" 10 | points: 1 11 | body: 12 | - "Placeholder" 13 | -------------------------------------------------------------------------------- /test/fixtures/files/cs2500midterm-v1/files/q1/all/src/packageone/Example.java: -------------------------------------------------------------------------------- 1 | package packageone; 2 | 3 | class Main { 4 | public static void main(String[] args) { 5 | System.out.println("hello!"); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/files/cs2500midterm-v1/files/q1/all/src/packagetwo/Example2.java: -------------------------------------------------------------------------------- 1 | package packagetwo; 2 | 3 | ~ro:1:s~// This should be locked to the left 4 | // and continues to here 5 | ~ro:1:e~class Foo { 6 | 7 | ~ro:2:s~// This should be locked in the middle~ro:2:e~ 8 | 9 | ~ro:3:s~}~ro:3:e~ 10 | -------------------------------------------------------------------------------- /test/fixtures/files/cs2500midterm-v1/files/q1/p1/anything.txt: -------------------------------------------------------------------------------- 1 | Question 1 part one. 2 | -------------------------------------------------------------------------------- /test/fixtures/files/cs2500midterm-v1/files/test.txt: -------------------------------------------------------------------------------- 1 | this is a file for the entire exam 2 | -------------------------------------------------------------------------------- /test/fixtures/files/cs2500midterm-v2/files/q1/all/src/packageone/Example.java: -------------------------------------------------------------------------------- 1 | package packageone; 2 | 3 | class Main { 4 | public static void main(String[] args) { 5 | System.out.println("hello!"); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/files/cs2500midterm-v2/files/q1/all/src/packagetwo/Example2.java: -------------------------------------------------------------------------------- 1 | package packagetwo; 2 | 3 | ~ro:1:s~// This should be locked to the left 4 | // and continues to here 5 | ~ro:1:e~class Foo { 6 | 7 | ~ro:2:s~// This should be locked in the middle~ro:2:e~ 8 | 9 | ~ro:3:s~}~ro:3:e~ 10 | -------------------------------------------------------------------------------- /test/fixtures/files/cs2500midterm-v2/files/q1/p1/anything.txt: -------------------------------------------------------------------------------- 1 | Question 1 part one. 2 | -------------------------------------------------------------------------------- /test/fixtures/files/cs2500midterm-v2/files/test.txt: -------------------------------------------------------------------------------- 1 | this is a file for the entire exam 2 | -------------------------------------------------------------------------------- /test/fixtures/files/cs3500final-v1/exam.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | policies: 3 | - IGNORE_LOCKDOWN 4 | - TOLERATE_WINDOWED 5 | contents: 6 | instructions: Exam instructions here... 7 | questions: 8 | - description: "Something to answer." 9 | separateSubparts: false 10 | parts: 11 | - description: "A part." 12 | points: 1 13 | body: 14 | - "Some instructions here for the first part." 15 | -------------------------------------------------------------------------------- /test/fixtures/files/cs3500final-v2/exam.yaml: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "policies": [ 4 | "IGNORE_LOCKDOWN", 5 | "TOLERATE_WINDOWED" 6 | ], 7 | "contents": { 8 | "instructions": "Exam instructions here...", 9 | "reference": [], 10 | "questions": [ 11 | { 12 | "reference": [], 13 | "description": "Something to answer.", 14 | "separateSubparts": false, 15 | "parts": [ 16 | { 17 | "reference": [], 18 | "description": "A part.", 19 | "points": 1.0, 20 | "body": [ 21 | "Some instructions here for the first part." 22 | ] 23 | } 24 | ] 25 | } 26 | ] 27 | } 28 | }, 29 | "files": [] 30 | } 31 | -------------------------------------------------------------------------------- /test/fixtures/files/tutorial/files/several/and/seek.java: -------------------------------------------------------------------------------- 1 | import tester.*; 2 | 3 | class Seek { 4 | int foundIt() { 5 | return 42; 6 | } 7 | } -------------------------------------------------------------------------------- /test/fixtures/files/tutorial/files/several/hide.rkt: -------------------------------------------------------------------------------- 1 | (define (hide x) 2 | (* x 2)) -------------------------------------------------------------------------------- /test/fixtures/files/tutorial/files/singleFile.java: -------------------------------------------------------------------------------- 1 | import tester.*; 2 | 3 | class SingleFile { 4 | String hideAndSeek() { 5 | return "Found you!"; 6 | } 7 | } -------------------------------------------------------------------------------- /test/helpers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/test/helpers/.keep -------------------------------------------------------------------------------- /test/helpers/upload.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module UploadTestHelper 4 | def self.with_temp_zip(glob_path) 5 | ArchiveUtils.mktmpdir do |path| 6 | dir = Pathname.new path 7 | zip = dir.join("#{name}.zip") 8 | ArchiveUtils.create_zip zip, Dir.glob(glob_path) 9 | file = File.new(zip) 10 | yield file 11 | end 12 | end 13 | 14 | def with_test_uploaded_zip(name, mime_type = nil) 15 | with_temp_zip name do |f| 16 | yield Rack::Test::UploadedFile.new(f.path, mime_type, false) 17 | end 18 | end 19 | 20 | def self.with_temp_fixture_zip(name, &block) 21 | with_temp_zip(Rails.root.join('test', 'fixtures', 'files', name, '**'), &block) 22 | end 23 | 24 | def self.with_test_uploaded_fixture_zip(name, mime_type = nil) 25 | with_temp_fixture_zip name do |f| 26 | yield Rack::Test::UploadedFile.new(f.path, mime_type, false) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/integration/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/test/integration/.keep -------------------------------------------------------------------------------- /test/mailers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/test/mailers/.keep -------------------------------------------------------------------------------- /test/models/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/test/models/.keep -------------------------------------------------------------------------------- /test/models/anomaly_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class AnomalyTest < ActiveSupport::TestCase 6 | test 'factory creates valid anomaly' do 7 | assert build(:anomaly).valid? 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/models/course_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class CourseTest < ActiveSupport::TestCase 6 | test 'course factory builds valid course' do 7 | assert build(:course).valid? 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/models/message_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class MessageTest < ActiveSupport::TestCase 6 | test 'factory creates valid messages' do 7 | reg = create(:registration) 8 | msg = build(:message, registration: reg) 9 | assert msg.valid? 10 | assert msg.save 11 | assert msg.sender.sent_messages.include? msg 12 | assert msg.registration.messages.include? msg 13 | end 14 | 15 | test 'should not save message without sender' do 16 | msg = build(:message, sender: nil) 17 | assert_not msg.save 18 | assert_match(/Sender must exist/, msg.errors.full_messages.to_sentence) 19 | end 20 | 21 | test 'students cannot send messages to other students' do 22 | ev = create(:exam_version) 23 | reg = build(:registration, exam_version: ev) 24 | reg2 = build(:registration, exam_version: ev) 25 | msg = build( 26 | :message, 27 | { 28 | sender: reg.user, 29 | registration: reg2, 30 | body: 'hi', 31 | }, 32 | ) 33 | assert_not msg.valid? 34 | assert_match(/must be a proctor or professor/, msg.errors[:sender].first) 35 | assert_not msg.save 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/models/question_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class QuestionTest < ActiveSupport::TestCase 6 | test 'factory builds valid question' do 7 | assert build(:student_question).valid? 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/models/room_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class RoomTest < ActiveSupport::TestCase 6 | test 'factory creates valid room' do 7 | assert build(:room).valid? 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/models/snapshot_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class SnapshotTest < ActiveSupport::TestCase 6 | # test "the truth" do 7 | # assert true 8 | # end 9 | end 10 | -------------------------------------------------------------------------------- /test/models/upload_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class UploadTest < ActiveSupport::TestCase 6 | test 'confirm process_marks doesnt trim too much' do 7 | text = 'text' 8 | marked_code = "~ro:1:s~#{text}~ro:1:e~" 9 | 4.times do |num_trailing_lines| 10 | test_string = marked_code + ("\n" * num_trailing_lines) 11 | marks = MarksProcessor.process_marks(test_string) 12 | assert_equal [ 13 | { 14 | from: { line: 0, ch: 0 }, 15 | to: { line: 0, ch: text.length }, 16 | options: { inclusiveLeft: true, inclusiveRight: num_trailing_lines < 2 }, 17 | }, 18 | ], marks[:marks] 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/models/user_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class UserTest < ActiveSupport::TestCase 6 | test 'standard user not an admin' do 7 | assert_not build(:user).admin? 8 | end 9 | 10 | test 'admin factory builds admins' do 11 | assert build(:admin).admin? 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/models/version_announcement_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class VersionAnnouncementTest < ActiveSupport::TestCase 6 | def setup 7 | @version = create(:exam_version) 8 | end 9 | test 'factory creates valid version announcement' do 10 | assert build(:version_announcement, exam_version: @version).valid? 11 | end 12 | 13 | test 'should save valid announcement' do 14 | announcement = build(:version_announcement, exam_version: @version) 15 | assert announcement.save 16 | end 17 | 18 | test 'should not save announcement without body' do 19 | announcement = build(:version_announcement, exam_version: @version, body: '') 20 | assert_not announcement.save 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/system/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/test/system/.keep -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/tmp/.keep -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": false, 4 | "emitDecoratorMetadata": true, 5 | "experimentalDecorators": true, 6 | "module": "esnext", 7 | "moduleResolution": "node", 8 | "sourceMap": true, 9 | "target": "esnext", 10 | "jsx": "preserve", 11 | "allowSyntheticDefaultImports": true, 12 | "baseUrl": "app/packs/components", 13 | "skipLibCheck": true, 14 | "paths": { 15 | "@hourglass/*": ["./*"], 16 | "@grading/*": ["./workflows/grading/*"], 17 | "@student/*": ["./workflows/student/*"], 18 | "@proctor/*": ["./workflows/proctor/*"], 19 | "@professor/*": ["./workflows/professor/*"] 20 | } 21 | }, 22 | "exclude": ["**/*.spec.ts", "node_modules", "vendor", "public"], 23 | "compileOnSave": true 24 | } 25 | -------------------------------------------------------------------------------- /vendor/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeGrade/hourglass/12b7f02381b8445505bf9eadf0e48a4bb4bdfc50/vendor/.keep --------------------------------------------------------------------------------