├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE.txt ├── Makefile ├── README.md ├── alembic.ini ├── alembic ├── README ├── env.py ├── script.py.mako └── versions │ ├── 014281cc823f_add_column_top_answer_to_answer_table.py │ ├── 04529827d9af_add_global_unique_identifier.py │ ├── 0a0d9eab68a3_rename_lti_namespaced_columns.py │ ├── 0a1ad609fc0a_add_saml_to_third_party_type.py │ ├── 0e88581a5e5b_remove_course_description.py │ ├── 0f36b3ad81fc_add_third_party_user_and_third_party_.py │ ├── 10cceff97b06_add_adaptive_min_delta_pairing_algorithm.py │ ├── 12167f268066_add_position_to_assignment_criterion.py │ ├── 1301ba274487_enforce_unique_course_and_assignment_.py │ ├── 144167b8fb35_add_draft_column_to_answer_and_answer_.py │ ├── 14eb50b4c37_refactor_group_members_table.py │ ├── 153384f69a82_expand_kaltura_upload_session_key_length.py │ ├── 17b7bd2e218c_add_rank_limit_to_assignment.py │ ├── 1a1082f21b88_add_peer_feedback_prompt_to_assignment.py │ ├── 1c7acaadfcfc_add_activity_table.py │ ├── 1e4cb64dd9b5_remove_group_name_unique_constraint.py │ ├── 20381bf0bd54_add_uuid_to_lti_user_lti_context_and_.py │ ├── 23944543dc01_replace_sqlalchemy-enum34_with_sqlalchemy_stock_enum.py │ ├── 24bd55036bca_add_comparison_example_table.py │ ├── 2561c39ac4d9_allow_instructor_answers_to_be_included_.py │ ├── 265dc6402cf8_add_scoring_algorithm_to_assignment_.py │ ├── 2a19cb1ab324_added_custom_context_memberships_url_.py │ ├── 2b8a3bb24e9a_create_default_criteria_attribute.py │ ├── 2ba873cb8692_add_educators_can_compare_to_assignment_.py │ ├── 2fe3d8183c34_remove_rounds_from_answerpairings.py │ ├── 3112cccea8d5_add_weight_to_assignment_criterion.py │ ├── 316f3b73962c_modified_criteria_tables.py │ ├── 31fc9a032aa8_add_uuid_to_comparison_table.py │ ├── 346c3877ffae_add_lis_result_sourcedid_to_lti_.py │ ├── 36c9fd392e33_0_9_x_database_redesign.py │ ├── 380522bcdac1_refactor_self_evaluation.py │ ├── 3b053548b60f_created_self_evaluation_types.py │ ├── 3ddca7ab950a_added_group_tables.py │ ├── 3f27a2b13b82_add_pairing_algorithm_to_assignment_and_.py │ ├── 3f3dd5a97fc7_added_practice_column_to_answers.py │ ├── 4593a30102c1_remove_assignment_comment_table.py │ ├── 4667f38426eb_added_rounds_answers.py │ ├── 4670092712d7_add_lti_course_offering_and_section_ids.py │ ├── 472780bc3c62_added_selfeval_flags_to_comments.py │ ├── 485ff3eedf19_add_lti_tables.py │ ├── 48e3b9d1750b_add_answer_id_and_criteriaquestion_id_.py │ ├── 4c4e55aabae6_remove_displayname_constraints.py │ ├── 4f56a1ca6ff7_create_answer_comment_type_field.py │ ├── 5964d2ad22c_add_caliper_log_table.py │ ├── 5a1981173d9_add_criteria_question_table.py │ ├── 622121ae2f36_modify_comparison_table.py │ ├── 6802122e6f53_add_uuid_to_lti_consumer_table.py │ ├── 6b69e8e22dfb_merge_4593a30102c1_and_a91d59c2cfd5.py │ ├── 82ed8fc51745_add_course_sandbox_flag.py │ ├── 852abaee3a25_add_email_notify_method_to_user_table.py │ ├── 895826c6c1ce_merge_4670092712d7_and_1e4cb64dd9b5.py │ ├── 8b4e3c1d8c41_create_unique_index_for_attachment_file_.py │ ├── 907993a4de86_merge_d8f2aa162e1f_and_265dc6402cf8.py │ ├── a4833ca3fd42_cleanup_0_9_0.py │ ├── a91d59c2cfd5_add_student_number_to_lti_user.py │ ├── aa532b17a272_modify_score_table.py │ ├── aafd2a91e3a_add_score_columns.py │ ├── ac2bd4b2c95_added_criteria_answerpairing.py │ ├── ad3d3931f78b_add_assignment_grade_and_course_grade.py │ ├── anchor.txt │ ├── b492a8f2132c_make_course_group_names_unique.py │ ├── b7b941f61291_added_uuid.py │ ├── bb705e95c6dc_add_custom_param_regex_sanitizer_to_lti_.py │ ├── bbf7d7f7da06_add_assignment_self_eval_dates_and_.py │ ├── c8d5a5c16f59_migrate_user_course_groups_to_groups_.py │ ├── c9ad3551e18_added_student_number.py │ ├── d2cfb4363907_add_kaltura_media_table.py │ ├── d402c96606ce_revised_lti_consumer_by_adding_user_id_.py │ ├── d415a61d15ca_update_third_party_user_table_to_more_.py │ ├── d6c88adfe909_add_submission_date_to_answer.py │ ├── d8f2aa162e1f_remove_answer_flag_columns.py │ ├── deafd926294b_add_lti_nonce_table.py │ ├── e453164951b5_add_group_table.py │ ├── e74cf0affe74_allow_user_username_and_password.py │ ├── ed763e759c2a_remove_file_active_column.py │ ├── f6145781f130_merge_add_uuid_and_min_delta_algo.py │ ├── fd7aab93104b_migrate_from_utf8_to_utf8mb4.py │ └── fff3fc7f636a_add_year_and_term_columns_to_course_.py ├── bower.json ├── celery_worker.py ├── compair ├── __init__.py ├── activity.py ├── algorithms │ ├── __init__.py │ ├── comparison_pair.py │ ├── comparison_winner.py │ ├── exceptions.py │ ├── pair │ │ ├── __init__.py │ │ ├── adaptive │ │ │ ├── __init__.py │ │ │ ├── core.py │ │ │ └── pair_generator.py │ │ ├── adaptive_min_delta │ │ │ ├── __init__.py │ │ │ ├── core.py │ │ │ └── pair_generator.py │ │ ├── core.py │ │ ├── pair_generator.py │ │ └── random │ │ │ ├── __init__.py │ │ │ ├── core.py │ │ │ └── pair_generator.py │ ├── score │ │ ├── __init__.py │ │ ├── comparative_judgement │ │ │ ├── __init__.py │ │ │ ├── core.py │ │ │ └── score_algorithm.py │ │ ├── core.py │ │ ├── elo_rating │ │ │ ├── __init__.py │ │ │ ├── core.py │ │ │ └── score_algorithm.py │ │ ├── score_algorithm_base.py │ │ └── true_skill_rating │ │ │ ├── __init__.py │ │ │ ├── core.py │ │ │ └── score_algorithm.py │ └── scored_object.py ├── api │ ├── __init__.py │ ├── answer.py │ ├── answer_comment.py │ ├── assignment.py │ ├── assignment_attachment.py │ ├── assignment_search_enddate.py │ ├── classlist.py │ ├── common.py │ ├── comparison.py │ ├── comparison_example.py │ ├── course.py │ ├── criterion.py │ ├── dataformat │ │ └── __init__.py │ ├── demo.py │ ├── file.py │ ├── gradebook.py │ ├── group.py │ ├── group_user.py │ ├── healthz.py │ ├── impersonation.py │ ├── learning_records.py │ ├── login.py │ ├── lti_consumers.py │ ├── lti_course.py │ ├── lti_launch.py │ ├── report.py │ ├── statements.py │ ├── users.py │ └── util │ │ └── __init__.py ├── authorization.py ├── cas.py ├── configuration.py ├── core.py ├── impersonation.py ├── kaltura │ ├── __init__.py │ ├── core.py │ ├── kaltura.py │ ├── kaltura_session.py │ ├── media.py │ └── upload_token.py ├── learning_records │ ├── __init__.py │ ├── caliper │ │ ├── __init__.py │ │ ├── actor.py │ │ ├── entities.py │ │ ├── event.py │ │ └── sensor.py │ ├── capture_events.py │ ├── learning_record.py │ ├── resource_iri.py │ └── xapi │ │ ├── __init__.py │ │ ├── activity.py │ │ ├── actor.py │ │ ├── context.py │ │ ├── object.py │ │ ├── result.py │ │ ├── statement.py │ │ ├── verb.py │ │ └── xapi.py ├── manage │ ├── __init__.py │ ├── database.py │ ├── grades.py │ ├── kaltura.py │ ├── report.py │ ├── score.py │ ├── user.py │ └── utils.py ├── models │ ├── __init__.py │ ├── activity_log.py │ ├── answer.py │ ├── answer_comment.py │ ├── answer_criterion_score.py │ ├── answer_score.py │ ├── assignment.py │ ├── assignment_criterion.py │ ├── assignment_grade.py │ ├── comparison.py │ ├── comparison_criterion.py │ ├── comparison_example.py │ ├── course.py │ ├── course_grade.py │ ├── criterion.py │ ├── custom_types │ │ ├── __init__.py │ │ ├── answer_comment_type.py │ │ ├── course_role.py │ │ ├── email_notification_method.py │ │ ├── pairing_algorithm.py │ │ ├── scoring_algorithm.py │ │ ├── system_role.py │ │ ├── third_party_type.py │ │ └── winning_answer.py │ ├── file.py │ ├── group.py │ ├── kaltura_models │ │ ├── __init__.py │ │ └── kaltura_media.py │ ├── learning_records │ │ ├── __init__.py │ │ ├── caliper_log.py │ │ └── xapi_log.py │ ├── lti_models │ │ ├── __init__.py │ │ ├── exceptions.py │ │ ├── helpers │ │ │ ├── __init__.py │ │ │ └── lti_membership_service_oauth_client.py │ │ ├── lti_consumer.py │ │ ├── lti_context.py │ │ ├── lti_membership.py │ │ ├── lti_nonce.py │ │ ├── lti_outcome.py │ │ ├── lti_resource_link.py │ │ ├── lti_user.py │ │ └── lti_user_resource_link.py │ ├── mixins │ │ ├── __init__.py │ │ ├── active_mixin.py │ │ ├── attempt_mixin.py │ │ ├── create_tracking_mixin.py │ │ ├── default_table_mixin.py │ │ ├── modify_tracking_mixin.py │ │ ├── uuid_mixin.py │ │ └── write_tracking_mixin.py │ ├── third_party_user.py │ ├── user.py │ └── user_course.py ├── notifications │ ├── __init__.py │ ├── capture_events.py │ └── notification.py ├── saml.py ├── security.py ├── settings.py ├── static │ ├── compair-config.js │ ├── img │ │ ├── black-favicon-16x16.png │ │ ├── black-favicon-32x32.png │ │ ├── black-favicon.ico │ │ ├── compair-favicon-black.png │ │ ├── compair-favicon-green.png │ │ ├── compair-logo-large.png │ │ ├── compair-logo-scale-flipped.png │ │ ├── compair-logo-scale.png │ │ ├── compair-logo-small.png │ │ ├── white-scale-icon-16x16.png │ │ └── white-scale-icon-flipped-16x16.png │ ├── less │ │ ├── assignment.less │ │ ├── compair.less │ │ ├── comparison.less │ │ ├── course.less │ │ ├── email.less │ │ ├── home.less │ │ ├── image-viewer.less │ │ ├── login.less │ │ ├── lti_manage.less │ │ ├── mobile.less │ │ ├── navbar.less │ │ ├── overall.less │ │ ├── print.less │ │ ├── rich-content.less │ │ ├── toast.less │ │ ├── user_manage.less │ │ ├── users.less │ │ └── variables_mixins.less │ ├── lib_extension │ │ └── ckeditor │ │ │ └── plugins │ │ │ ├── autolink │ │ │ └── plugin.js │ │ │ └── combinedmath │ │ │ ├── dialogs │ │ │ └── combinedmath.js │ │ │ ├── icons │ │ │ └── combinedmath.png │ │ │ ├── iframe │ │ │ └── mathjax.html │ │ │ └── plugin.js │ ├── modules │ │ ├── answer │ │ │ ├── answer-form-partial.html │ │ │ ├── answer-modal-partial.html │ │ │ └── answer-module.js │ │ ├── assignment │ │ │ ├── assignment-form-partial.html │ │ │ ├── assignment-module.js │ │ │ ├── assignment-module_spec.js │ │ │ ├── assignment-search-partial.html │ │ │ ├── assignment-view-partial.html │ │ │ ├── preview-inline-template-comparison.html │ │ │ └── preview-inline-template-self-eval.html │ │ ├── attachment │ │ │ ├── attachment-module.js │ │ │ └── image-viewer-directive.js │ │ ├── authentication │ │ │ └── authentication-service.js │ │ ├── authorization │ │ │ └── authorization-module.js │ │ ├── classlist │ │ │ ├── classlist-enrol-partial.html │ │ │ ├── classlist-import-partial.html │ │ │ ├── classlist-import-results-partial.html │ │ │ ├── classlist-module.js │ │ │ └── classlist-view-partial.html │ │ ├── comment │ │ │ ├── answer-content.html │ │ │ ├── answer.html │ │ │ ├── comment-answer-modal-partial.html │ │ │ ├── comment-assignment-modal-partial.html │ │ │ ├── comment-form-partial.html │ │ │ └── comment-module.js │ │ ├── common │ │ │ ├── avatar-directive.js │ │ │ ├── avatar-directive_spec.js │ │ │ ├── common-module.js │ │ │ ├── demo-warning-template.html │ │ │ ├── element-button-template.html │ │ │ ├── element-metadata-template.html │ │ │ ├── element-text-template.html │ │ │ ├── form-directive.js │ │ │ ├── form-field-with-feedback-template.html │ │ │ ├── http-interceptor-service.js │ │ │ ├── impersonation-visual-cue-template.html │ │ │ ├── logo-directive.js │ │ │ ├── modal-cancel-button-directive.js │ │ │ └── timer-module.js │ │ ├── comparison │ │ │ ├── comparison-form-partial.html │ │ │ ├── comparison-modal-partial.html │ │ │ ├── comparison-module.js │ │ │ ├── comparison-module_spec.js │ │ │ ├── comparison-self_evaluation-partial.html │ │ │ └── comparison-view-partial.html │ │ ├── course │ │ │ ├── course-assignments-partial.html │ │ │ ├── course-duplicate-modal-partial.html │ │ │ ├── course-duplicate-partial.html │ │ │ ├── course-module.js │ │ │ ├── course-module_spec.js │ │ │ ├── course-partial.html │ │ │ └── course-select-partial.html │ │ ├── criterion │ │ │ ├── criterion-assignment-partial.html │ │ │ ├── criterion-form-partial.html │ │ │ ├── criterion-modal-partial.html │ │ │ ├── criterion-module.js │ │ │ └── criterion-module_spec.js │ │ ├── gradebook │ │ │ ├── gradebook-module.js │ │ │ └── gradebook-partial.html │ │ ├── group │ │ │ ├── group-form-partial.html │ │ │ ├── group-modal-partial.html │ │ │ ├── group-module.js │ │ │ └── groups-manage-modal-partial.html │ │ ├── home │ │ │ ├── home-module.js │ │ │ └── home-partial.html │ │ ├── learning_records │ │ │ ├── learning-record-caliper-module.js │ │ │ ├── learning-record-module.js │ │ │ ├── learning-record-module_spec.js │ │ │ └── learning-record-xapi-module.js │ │ ├── login │ │ │ ├── login-directive.js │ │ │ ├── login-module.js │ │ │ └── login-partial.html │ │ ├── lti │ │ │ ├── lti-module.js │ │ │ └── lti-setup-partial.html │ │ ├── lti_consumer │ │ │ ├── lti-consumer-form-partial.html │ │ │ ├── lti-consumer-module.js │ │ │ ├── lti-consumer-view-partial.html │ │ │ └── lti-consumers-list-partial.html │ │ ├── lti_context │ │ │ ├── lti-context-module.js │ │ │ └── lti-contexts-list-partial.html │ │ ├── navbar │ │ │ ├── navbar-module.js │ │ │ └── navbar-partial.html │ │ ├── report │ │ │ ├── report-create-partial.html │ │ │ └── report-module.js │ │ ├── rich-content │ │ │ ├── hljs-directive.js │ │ │ ├── mathjax-directive.js │ │ │ ├── rich-content-attachment-modal-template.html │ │ │ ├── rich-content-attachment-template.html │ │ │ ├── rich-content-directive.js │ │ │ ├── rich-content-embeddable-modal-template.html │ │ │ ├── rich-content-embeddable-template.html │ │ │ ├── rich-content-template.html │ │ │ └── twttr-directive.js │ │ ├── route │ │ │ ├── route-error-partial.html │ │ │ └── route-provider_spec.js │ │ ├── session │ │ │ ├── session-service.js │ │ │ └── session-service_spec.js │ │ ├── student_view │ │ │ ├── student-view-form-partial.html │ │ │ ├── student-view-modal-partial.html │ │ │ └── student-view-module.js │ │ ├── toaster │ │ │ └── toaster-module.js │ │ └── user │ │ │ ├── user-create-partial.html │ │ │ ├── user-edit-partial.html │ │ │ ├── user-form-partial.html │ │ │ ├── user-list-partial.html │ │ │ ├── user-manage-partial.html │ │ │ ├── user-module.js │ │ │ ├── user-module_spec.js │ │ │ ├── user-password-modal-partial.html │ │ │ ├── user-password-partial.html │ │ │ └── user-view-partial.html │ ├── script-assignment-search.js │ └── test │ │ ├── config │ │ ├── karma-e2e-dev.conf.js │ │ ├── karma-e2e.conf.js │ │ ├── karma.conf.js │ │ ├── protractor.js │ │ ├── protractor_cucumber.js │ │ ├── protractor_saucelab.js │ │ └── protractor_saucelab_local.js │ │ ├── e2e │ │ ├── e2e_dsl_addon.js │ │ ├── runner.html │ │ ├── test_e2e_student.js │ │ └── test_e2e_teacher.js │ │ ├── env.js │ │ ├── factories │ │ ├── answer_factory.js │ │ ├── assignment_factory.js │ │ ├── assignment_status_factory.js │ │ ├── comparison_example_factory.js │ │ ├── course_factory.js │ │ ├── criterion_factory.js │ │ ├── group_factory.js │ │ ├── http_backend_mocks.js │ │ ├── lti_consumer_factory.js │ │ ├── lti_context_factory.js │ │ ├── lti_user_factory.js │ │ ├── page_factory.js │ │ ├── session_factory.js │ │ ├── third_party_user_factory.js │ │ └── user_factory.js │ │ ├── features │ │ ├── create_assignment.feature │ │ ├── create_course.feature │ │ ├── create_lti_consumer.feature │ │ ├── create_user.feature │ │ ├── edit_assignment.feature │ │ ├── edit_course.feature │ │ ├── edit_course_user.feature │ │ ├── edit_lti_consumer.feature │ │ ├── edit_user.feature │ │ ├── step_definitions │ │ │ ├── common.js │ │ │ ├── create_assignment.js │ │ │ ├── edit_assignment.js │ │ │ ├── edit_course_user.js │ │ │ ├── edit_user.js │ │ │ ├── setup_users.js │ │ │ ├── view_course.js │ │ │ ├── view_home.js │ │ │ ├── view_lti_consumer.js │ │ │ ├── view_lti_consumers.js │ │ │ ├── view_navbar.js │ │ │ ├── view_user.js │ │ │ ├── view_user_manage.js │ │ │ └── view_users.js │ │ ├── support │ │ │ └── hooks.js │ │ ├── view_course.feature │ │ ├── view_home.feature │ │ ├── view_lti_consumer.feature │ │ ├── view_lti_consumers.feature │ │ ├── view_navbar.feature │ │ ├── view_user.feature │ │ ├── view_user_manage.feature │ │ └── view_users.feature │ │ ├── fixtures │ │ ├── admin │ │ │ ├── default_fixture.js │ │ │ └── saml_fixture.js │ │ ├── instructor │ │ │ ├── default_fixture.js │ │ │ └── saml_fixture.js │ │ └── student │ │ │ ├── default_fixture.js │ │ │ └── saml_fixture.js │ │ ├── helpers │ │ └── matcher.js │ │ ├── page_objects │ │ ├── assignment.js │ │ ├── course.js │ │ ├── create_assignment.js │ │ ├── create_course.js │ │ ├── create_lti_consumer.js │ │ ├── create_user.js │ │ ├── edit_assignment.js │ │ ├── edit_course.js │ │ ├── edit_course_user.js │ │ ├── edit_lti_consumer.js │ │ ├── edit_user.js │ │ ├── home.js │ │ ├── login.js │ │ ├── lti_consumer.js │ │ ├── manage_lti.js │ │ ├── user.js │ │ ├── user_manage.js │ │ └── users.js │ │ ├── specs │ │ ├── home_spec.js │ │ └── spec.js │ │ ├── test_e2e-dev.sh │ │ ├── test_e2e.sh │ │ └── test_unit.sh ├── tasks │ ├── __init__.py │ ├── demo.py │ ├── emit_learning_record.py │ ├── lti_membership.py │ ├── lti_outcomes.py │ ├── send_mail.py │ └── user_password.py ├── templates │ ├── index-dev.html │ ├── index.html │ ├── notification_base.html │ ├── notification_base.txt │ ├── notification_new_answer_comment.html │ ├── notification_new_answer_comment.txt │ ├── pdf-viewer.html │ └── static │ │ ├── anchor.txt │ │ ├── email.css │ │ └── viewer.html ├── tests │ ├── __init__.py │ ├── alembic.ini │ ├── algorithms │ │ ├── __init__.py │ │ ├── test_pair.py │ │ ├── test_pair_adaptive.py │ │ ├── test_pair_adaptive_min_delta.py │ │ ├── test_pair_random.py │ │ ├── test_score.py │ │ ├── test_score_comparitive_judgement.py │ │ ├── test_score_elo_rating.py │ │ ├── test_score_true_skill_rating.py │ │ └── test_validity.py │ ├── api │ │ ├── __init__.py │ │ ├── test_answer_comments.py │ │ ├── test_answers.py │ │ ├── test_assignment.py │ │ ├── test_assignment_attachment.py │ │ ├── test_classlist.py │ │ ├── test_comparison_examples.py │ │ ├── test_comparisons.py │ │ ├── test_courses.py │ │ ├── test_criteria.py │ │ ├── test_demo.py │ │ ├── test_file.py │ │ ├── test_gradebook.py │ │ ├── test_group_users.py │ │ ├── test_groups.py │ │ ├── test_impersonation.py │ │ ├── test_learning_records.py │ │ ├── test_login.py │ │ ├── test_lti_consumers.py │ │ ├── test_lti_course.py │ │ ├── test_lti_launch.py │ │ ├── test_report.py │ │ └── test_users.py │ ├── learning_records │ │ ├── __init__.py │ │ ├── test_actor.py │ │ ├── test_answer_comment_events.py │ │ ├── test_answer_events.py │ │ ├── test_assignment_events.py │ │ ├── test_authentication_events.py │ │ ├── test_comparison_events.py │ │ ├── test_course_events.py │ │ ├── test_criterion_events.py │ │ ├── test_file_events.py │ │ ├── test_remote.py │ │ └── test_user_events.py │ ├── test_compair.py │ ├── test_configuration.py │ ├── test_migration.py │ ├── test_models.py │ └── test_util.py └── util │ ├── __init__.py │ └── sql_utcnow.py ├── data ├── __init__.py ├── factories.py └── fixtures │ ├── __init__.py │ └── test_data.py ├── deploy ├── aws │ ├── README.md │ └── compair.template.json ├── development │ ├── compair_dev_saml_metadata.xml │ └── dev_saml_settings.json ├── docker │ ├── README.md │ ├── docker-compose.yml │ ├── docker-entrypoint.sh │ ├── nginx.conf │ └── uwsgi.ini ├── gce_kube │ ├── README.md │ ├── compair-deployment.yaml │ ├── compair-worker-deployment.yaml │ ├── gce-pd-storageclass.yaml │ ├── mysql-deployment.yaml │ ├── nfs-deployment.yaml │ ├── password.txt │ ├── persistent-storage.yaml │ └── redis-deployment.yaml └── linux_server │ ├── README.md │ ├── compair.service │ ├── nginx.conf │ ├── uwsgi.ini │ └── worker.service ├── docker-compose.yml ├── docs ├── dev.md └── install.md ├── gulpfile.js ├── lti ├── README.md ├── __init__.py ├── contentitem_response.py ├── contrib │ ├── __init__.py │ └── flask │ │ ├── __init__.py │ │ └── flask_tool_provider.py ├── launch_params.py ├── outcome_request.py ├── outcome_response.py ├── tool_base.py ├── tool_config.py ├── tool_consumer.py ├── tool_outbound.py ├── tool_provider.py ├── tool_proxy.py └── utils.py ├── main.py ├── manage.py ├── package-lock.json ├── package.json ├── persistent ├── report │ └── anchor.txt └── tmp │ └── anchor.txt ├── requirements.dev.txt ├── requirements.txt └── scripts ├── __init__.py ├── extract-answers.awk ├── generate_correlation_reports.py ├── generate_correlation_reports_direct.py ├── generate_correlation_reports_score_drift.py └── run_score_algorithm.py /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | compair/static/lib 3 | persistent/attachment/* 4 | persistent/report/* 5 | persistent/tmp/* 6 | 7 | node_modules 8 | bower_components 9 | 10 | .data 11 | docker-compose.overrides.yml -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | 2 | version: 2 3 | updates: 4 | # Configuration for npm 5 | - package-ecosystem: "npm" 6 | directory: "/" 7 | schedule: 8 | interval: "daily" 9 | # Disable all pull requests for Docker dependencies 10 | # open-pull-requests-limit: 0 11 | allow: 12 | # Allow updates 13 | - dependency-name: "lodash*" 14 | - dependency-name: "karma*" 15 | - dependency-name: "minimist" 16 | - dependency-name: "less" 17 | - dependency-name: "gulp*" 18 | - dependency-name: "bower" 19 | - dependency-name: "proxy-middleware" 20 | - dependency-name: "streamqueue" 21 | - dependency-name: "wiredep" 22 | - dependency-name: "chokidar" 23 | - dependency-name: "angular*" 24 | - dependency-name: "jspri*" 25 | - dependency-name: "json-schema" 26 | - dependency-name: "phantomjs*" 27 | - dependency-name: "glob-parent*" 28 | - dependency-name: "meow*" 29 | - dependency-name: "glob-stream*" 30 | - dependency-name: "trim-newlines" 31 | ignore: 32 | # Ignore updates to packages that start with 'aws' 33 | # Wildcards match zero or more arbitrary characters 34 | - dependency-name: "aws*" 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | 29 | # Translations 30 | *.mo 31 | 32 | # Mr Developer 33 | .mr.developer.cfg 34 | .project 35 | .pydevproject 36 | 37 | *.swp 38 | 39 | tmp/* 40 | tmpUpload/* 41 | 42 | # idea project file 43 | .idea 44 | .ropeproject/ 45 | *.db 46 | *.db-journal 47 | *.csv 48 | config.py 49 | 50 | # compiled and generated files 51 | compair/static/compair.css 52 | bower_components 53 | node_modules 54 | compair/static/index.html 55 | 56 | .env 57 | .python-version 58 | 59 | # copied fonts 60 | compair/static/fonts 61 | 62 | # uploaded and generated report files 63 | persistent/attachment/* 64 | persistent/report/* 65 | persistent/tmp/* 66 | 67 | # google analytics file 68 | compair/static/tracking.js 69 | 70 | # docker generated data 71 | .data 72 | 73 | celerybeat-schedule 74 | celerybeat.pid -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # max length of characters to apply to the 11 | # "slug" field 12 | #truncate_slug_length = 40 13 | 14 | # set to 'true' to run the environment during 15 | # the 'revision' command, regardless of autogenerate 16 | # revision_environment = false 17 | 18 | # set to 'true' to allow .pyc and .pyo files without 19 | # a source .py file to be detected as revisions in the 20 | # versions/ directory 21 | # sourceless = false 22 | 23 | # this is not being used. It's using compair.configuration 24 | # for the database config. This option should not be enalbed. 25 | #sqlalchemy.url = sqlite:///app.db 26 | 27 | 28 | # Logging configuration 29 | [loggers] 30 | keys = root,sqlalchemy,alembic 31 | 32 | [handlers] 33 | keys = console 34 | 35 | [formatters] 36 | keys = generic 37 | 38 | [logger_root] 39 | level = WARN 40 | handlers = console 41 | qualname = 42 | 43 | [logger_sqlalchemy] 44 | level = WARN 45 | handlers = 46 | qualname = sqlalchemy.engine 47 | 48 | [logger_alembic] 49 | level = INFO 50 | handlers = 51 | qualname = alembic 52 | 53 | [handler_console] 54 | class = StreamHandler 55 | args = (sys.stderr,) 56 | level = NOTSET 57 | formatter = generic 58 | 59 | [formatter_generic] 60 | format = %(levelname)-5.5s [%(name)s] %(message)s 61 | datefmt = %H:%M:%S 62 | -------------------------------------------------------------------------------- /alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = ${repr(up_revision)} 11 | down_revision = ${repr(down_revision)} 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | ${imports if imports else ""} 16 | 17 | def upgrade(): 18 | ${upgrades if upgrades else "pass"} 19 | 20 | 21 | def downgrade(): 22 | ${downgrades if downgrades else "pass"} 23 | -------------------------------------------------------------------------------- /alembic/versions/014281cc823f_add_column_top_answer_to_answer_table.py: -------------------------------------------------------------------------------- 1 | """Add column notable to answer table 2 | 3 | Revision ID: 014281cc823f 4 | Revises: ad3d3931f78b 5 | Create Date: 2016-10-05 21:13:52.232095 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '014281cc823f' 11 | down_revision = 'ad3d3931f78b' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | def upgrade(): 19 | with op.batch_alter_table('answer', naming_convention=convention) as batch_op: 20 | batch_op.add_column(sa.Column('top_answer', sa.Boolean(), default=False, server_default='0', nullable=False)) 21 | op.create_index(op.f('ix_answer_top_answer'), 'answer', ['top_answer'], unique=False) 22 | 23 | def downgrade(): 24 | with op.batch_alter_table('answer', naming_convention=convention) as batch_op: 25 | batch_op.drop_index('ix_answer_top_answer') 26 | batch_op.drop_column('top_answer') 27 | -------------------------------------------------------------------------------- /alembic/versions/0a1ad609fc0a_add_saml_to_third_party_type.py: -------------------------------------------------------------------------------- 1 | """Add SAML to third_party_type 2 | 3 | Revision ID: 0a1ad609fc0a 4 | Revises: 2561c39ac4d9 5 | Create Date: 2017-12-07 00:43:05.761509 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '0a1ad609fc0a' 11 | down_revision = '2561c39ac4d9' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | from sqlalchemy_enum34 import EnumType 18 | from enum import Enum 19 | 20 | class NewThirdPartyType(Enum): 21 | cas = "CAS" 22 | saml = "SAML" 23 | 24 | class OldThirdPartyType(Enum): 25 | cas = "CAS" 26 | 27 | def upgrade(): 28 | with op.batch_alter_table('third_party_user', naming_convention=convention) as batch_op: 29 | batch_op.alter_column('third_party_type', type_=EnumType(NewThirdPartyType), existing_type=EnumType(OldThirdPartyType)) 30 | 31 | 32 | def downgrade(): 33 | with op.batch_alter_table('third_party_user', naming_convention=convention) as batch_op: 34 | batch_op.alter_column('third_party_type', type_=EnumType(OldThirdPartyType), existing_type=EnumType(NewThirdPartyType)) -------------------------------------------------------------------------------- /alembic/versions/0e88581a5e5b_remove_course_description.py: -------------------------------------------------------------------------------- 1 | """Remove course description 2 | 3 | Revision ID: 0e88581a5e5b 4 | Revises: ed763e759c2a 5 | Create Date: 2017-02-06 20:47:07.952542 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '0e88581a5e5b' 11 | down_revision = 'ed763e759c2a' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | def upgrade(): 19 | with op.batch_alter_table('course', naming_convention=convention) as batch_op: 20 | batch_op.drop_column('description') 21 | 22 | def downgrade(): 23 | with op.batch_alter_table('course', naming_convention=convention) as batch_op: 24 | batch_op.add_column(sa.Column('description', sa.Text, nullable=True)) 25 | -------------------------------------------------------------------------------- /alembic/versions/12167f268066_add_position_to_assignment_criterion.py: -------------------------------------------------------------------------------- 1 | """Add position to assignment_criterion 2 | 3 | Revision ID: 12167f268066 4 | Revises: 31fc9a032aa8 5 | Create Date: 2016-11-30 23:49:11.830461 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '12167f268066' 11 | down_revision = '31fc9a032aa8' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | from compair.models import convention 16 | 17 | def upgrade(): 18 | op.add_column('assignment_criterion', sa.Column('position', sa.Integer(), nullable=True)) 19 | 20 | def downgrade(): 21 | with op.batch_alter_table('assignment_criterion', naming_convention=convention) as batch_op: 22 | batch_op.drop_column('position') 23 | -------------------------------------------------------------------------------- /alembic/versions/1301ba274487_enforce_unique_course_and_assignment_.py: -------------------------------------------------------------------------------- 1 | """enforce unique course and assignment grade constraints 2 | 3 | Revision ID: 1301ba274487 4 | Revises: d415a61d15ca 5 | Create Date: 2016-11-23 18:35:02.187013 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '1301ba274487' 11 | down_revision = 'd415a61d15ca' 12 | 13 | from alembic import op 14 | 15 | from compair.models import convention 16 | 17 | def upgrade(): 18 | with op.batch_alter_table('assignment_grade', naming_convention=convention) as batch_op: 19 | batch_op.create_unique_constraint('_unique_user_and_assignment', ['assignment_id', 'user_id']) 20 | 21 | with op.batch_alter_table('course_grade', naming_convention=convention) as batch_op: 22 | batch_op.create_unique_constraint('_unique_user_and_course', ['course_id', 'user_id']) 23 | 24 | def downgrade(): 25 | with op.batch_alter_table('assignment_grade', naming_convention=convention) as batch_op: 26 | batch_op.drop_constraint('_unique_user_and_assignment', type_='unique') 27 | 28 | with op.batch_alter_table('course_grade', naming_convention=convention) as batch_op: 29 | batch_op.drop_constraint('_unique_user_and_course', type_='unique') -------------------------------------------------------------------------------- /alembic/versions/144167b8fb35_add_draft_column_to_answer_and_answer_.py: -------------------------------------------------------------------------------- 1 | """Add draft column to answer and answer_comment tables 2 | 3 | Revision ID: 144167b8fb35 4 | Revises: aafd2a91e3a 5 | Create Date: 2016-07-05 12:09:24.279648 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '144167b8fb35' 11 | down_revision = 'aafd2a91e3a' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | def upgrade(): 19 | with op.batch_alter_table('answer', naming_convention=convention) as batch_op: 20 | batch_op.add_column(sa.Column('draft', sa.Boolean(), nullable=False, default=False, server_default='0')) 21 | with op.batch_alter_table('answer_comment', naming_convention=convention) as batch_op: 22 | batch_op.add_column(sa.Column('draft', sa.Boolean(), nullable=False, default=False, server_default='0')) 23 | 24 | def downgrade(): 25 | with op.batch_alter_table('answer_comment', naming_convention=convention) as batch_op: 26 | batch_op.drop_column('draft') 27 | with op.batch_alter_table('answer', naming_convention=convention) as batch_op: 28 | batch_op.drop_column('draft') 29 | -------------------------------------------------------------------------------- /alembic/versions/153384f69a82_expand_kaltura_upload_session_key_length.py: -------------------------------------------------------------------------------- 1 | """Expand Kaltura upload session key length 2 | 3 | Revision ID: 153384f69a82 4 | Revises: 895826c6c1ce 5 | Create Date: 2020-02-10 20:37:17.126127 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '153384f69a82' 11 | down_revision = '895826c6c1ce' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | def upgrade(): 19 | with op.batch_alter_table('kaltura_media', naming_convention=convention) as batch_op: 20 | batch_op.alter_column('upload_ks', 21 | existing_type=sa.String(length=255), 22 | type_=sa.String(length=1024)) 23 | 24 | def downgrade(): 25 | with op.batch_alter_table('kaltura_media', naming_convention=convention) as batch_op: 26 | batch_op.alter_column('upload_ks', 27 | existing_type=sa.String(length=1024), 28 | type_=sa.String(length=255)) 29 | -------------------------------------------------------------------------------- /alembic/versions/17b7bd2e218c_add_rank_limit_to_assignment.py: -------------------------------------------------------------------------------- 1 | """Add rank_limit to assignment 2 | 3 | Revision ID: 17b7bd2e218c 4 | Revises: 3f27a2b13b82 5 | Create Date: 2016-08-10 12:39:04.501928 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '17b7bd2e218c' 11 | down_revision = '3f27a2b13b82' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | def upgrade(): 19 | op.add_column('assignment', sa.Column('rank_display_limit', sa.Integer(), nullable=True)) 20 | 21 | def downgrade(): 22 | with op.batch_alter_table('assignment', naming_convention=convention) as batch_op: 23 | batch_op.drop_column('rank_display_limit') -------------------------------------------------------------------------------- /alembic/versions/1a1082f21b88_add_peer_feedback_prompt_to_assignment.py: -------------------------------------------------------------------------------- 1 | """Add peer_feedback_prompt to assignment 2 | 3 | Revision ID: 1a1082f21b88 4 | Revises: 6802122e6f53 5 | Create Date: 2017-01-23 20:18:33.525798 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '1a1082f21b88' 11 | down_revision = '6802122e6f53' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | 19 | def upgrade(): 20 | op.add_column('assignment', sa.Column('peer_feedback_prompt', sa.Text(), nullable=True)) 21 | 22 | def downgrade(): 23 | with op.batch_alter_table('assignment', naming_convention=convention) as batch_op: 24 | batch_op.drop_column('peer_feedback_prompt') 25 | -------------------------------------------------------------------------------- /alembic/versions/1c7acaadfcfc_add_activity_table.py: -------------------------------------------------------------------------------- 1 | """Add activity table 2 | 3 | Revision ID: 1c7acaadfcfc 4 | Revises: None 5 | Create Date: 2014-09-08 00:46:46.207694 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '1c7acaadfcfc' 11 | down_revision = None 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | op.create_table( 19 | 'Activities', 20 | sa.Column('id', sa.Integer(), nullable=False), 21 | sa.Column('users_id', sa.Integer(), nullable=True), 22 | sa.Column('courses_id', sa.Integer(), nullable=True), 23 | sa.Column('timestamp', sa.TIMESTAMP(), nullable=False), 24 | sa.Column('event', sa.String(length=50), nullable=True), 25 | sa.Column('data', sa.Text(), nullable=True), 26 | sa.Column('status', sa.String(length=20), nullable=True), 27 | sa.Column('message', sa.Text(), nullable=True), 28 | sa.Column('session_id', sa.String(length=100), nullable=True), 29 | sa.ForeignKeyConstraint(['courses_id'], ['Courses.id'], ondelete='CASCADE'), 30 | sa.ForeignKeyConstraint(['users_id'], ['Users.id'], ), 31 | sa.PrimaryKeyConstraint('id'), 32 | mysql_charset='utf8', 33 | mysql_collate='utf8_unicode_ci', 34 | mysql_engine='InnoDB' 35 | ) 36 | 37 | 38 | def downgrade(): 39 | op.drop_table('Activities') 40 | -------------------------------------------------------------------------------- /alembic/versions/2a19cb1ab324_added_custom_context_memberships_url_.py: -------------------------------------------------------------------------------- 1 | """Added custom_context_memberships_url column to lti_context table 2 | 3 | Revision ID: 2a19cb1ab324 4 | Revises: 852abaee3a25 5 | Create Date: 2017-06-23 19:10:05.295553 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '2a19cb1ab324' 11 | down_revision = '852abaee3a25' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | def upgrade(): 19 | with op.batch_alter_table('lti_consumer', naming_convention=convention) as batch_op: 20 | batch_op.add_column(sa.Column('canvas_consumer', sa.Boolean(), nullable=False, default=False, server_default='0')) 21 | batch_op.add_column(sa.Column('canvas_api_token', sa.String(255), nullable=True)) 22 | 23 | with op.batch_alter_table('lti_context', naming_convention=convention) as batch_op: 24 | batch_op.add_column(sa.Column('custom_context_memberships_url', sa.Text(), nullable=True)) 25 | 26 | def downgrade(): 27 | with op.batch_alter_table('lti_context', naming_convention=convention) as batch_op: 28 | batch_op.drop_column('custom_context_memberships_url') 29 | 30 | with op.batch_alter_table('lti_consumer', naming_convention=convention) as batch_op: 31 | batch_op.drop_column('canvas_api_token') 32 | batch_op.drop_column('canvas_consumer') -------------------------------------------------------------------------------- /alembic/versions/2b8a3bb24e9a_create_default_criteria_attribute.py: -------------------------------------------------------------------------------- 1 | """create default criteria attribute 2 | 3 | Revision ID: 2b8a3bb24e9a 4 | Revises: 316f3b73962c 5 | Create Date: 2014-10-02 13:34:25.313161 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '2b8a3bb24e9a' 11 | down_revision = '316f3b73962c' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | from sqlalchemy import UniqueConstraint 16 | 17 | from compair.models import convention 18 | 19 | 20 | def upgrade(): 21 | with op.batch_alter_table('Criteria', naming_convention=convention) as batch_op: 22 | batch_op.add_column(sa.Column('default', sa.Boolean(), default=True, server_default='1', nullable=False)) 23 | 24 | 25 | def downgrade(): 26 | with op.batch_alter_table( 27 | 'Criteria', naming_convention=convention, 28 | table_args=(UniqueConstraint('name'))) as batch_op: 29 | batch_op.drop_column('default') 30 | -------------------------------------------------------------------------------- /alembic/versions/2ba873cb8692_add_educators_can_compare_to_assignment_.py: -------------------------------------------------------------------------------- 1 | """add educators_can_compare to assignment table 2 | 3 | Revision ID: 2ba873cb8692 4 | Revises: b7b941f61291 5 | Create Date: 2016-09-20 17:50:04.644507 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '2ba873cb8692' 11 | down_revision = 'b7b941f61291' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | 19 | def upgrade(): 20 | with op.batch_alter_table('assignment', naming_convention=convention) as batch_op: 21 | batch_op.add_column(sa.Column('educators_can_compare', sa.Boolean(), nullable=False, server_default='0')) 22 | 23 | def downgrade(): 24 | with op.batch_alter_table('assignment', naming_convention=convention) as batch_op: 25 | batch_op.drop_column('educators_can_compare') 26 | -------------------------------------------------------------------------------- /alembic/versions/2fe3d8183c34_remove_rounds_from_answerpairings.py: -------------------------------------------------------------------------------- 1 | """Remove rounds from AnswerPairings 2 | 3 | Revision ID: 2fe3d8183c34 4 | Revises: 1c7acaadfcfc 5 | Create Date: 2014-09-09 14:13:29.979783 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '2fe3d8183c34' 11 | down_revision = '1c7acaadfcfc' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | 19 | def upgrade(): 20 | with op.batch_alter_table('AnswerPairings', naming_convention=convention) as batch_op: 21 | batch_op.drop_column('round') 22 | 23 | 24 | def downgrade(): 25 | with op.batch_alter_table('AnswerPairings', naming_convention=convention) as batch_op: 26 | batch_op.add_column( 27 | sa.Column('round', sa.Integer, default=1) 28 | ) 29 | -------------------------------------------------------------------------------- /alembic/versions/3112cccea8d5_add_weight_to_assignment_criterion.py: -------------------------------------------------------------------------------- 1 | """Add weight to assignment_criterion 2 | 3 | Revision ID: 3112cccea8d5 4 | Revises: 1a1082f21b88 5 | Create Date: 2017-01-11 23:40:31.058719 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '3112cccea8d5' 11 | down_revision = '1a1082f21b88' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | 19 | def upgrade(): 20 | with op.batch_alter_table('assignment_criterion', naming_convention=convention) as batch_op: 21 | batch_op.add_column(sa.Column('weight', sa.Integer(), server_default='1', nullable=False)) 22 | 23 | 24 | def downgrade(): 25 | with op.batch_alter_table('assignment_criterion', naming_convention=convention) as batch_op: 26 | batch_op.drop_column('weight') 27 | -------------------------------------------------------------------------------- /alembic/versions/346c3877ffae_add_lis_result_sourcedid_to_lti_.py: -------------------------------------------------------------------------------- 1 | """Add lis_result_sourcedid to lti_membership 2 | 3 | Revision ID: 346c3877ffae 4 | Revises: d402c96606ce 5 | Create Date: 2017-08-15 23:30:27.653010 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '346c3877ffae' 11 | down_revision = 'd402c96606ce' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | 19 | def upgrade(): 20 | with op.batch_alter_table('lti_membership', naming_convention=convention) as batch_op: 21 | batch_op.add_column(sa.Column('lis_result_sourcedids', sa.Text(), nullable=True)) 22 | 23 | 24 | def downgrade(): 25 | with op.batch_alter_table('lti_membership', naming_convention=convention) as batch_op: 26 | batch_op.drop_column('lis_result_sourcedids') 27 | -------------------------------------------------------------------------------- /alembic/versions/3f27a2b13b82_add_pairing_algorithm_to_assignment_and_.py: -------------------------------------------------------------------------------- 1 | """Add pairing_algorithm to assignment and comparison tables 2 | 3 | Revision ID: 3f27a2b13b82 4 | Revises: 24bd55036bca 5 | Create Date: 2016-07-15 14:00:31.190921 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '3f27a2b13b82' 11 | down_revision = '24bd55036bca' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | from sqlalchemy.dialects import mysql 16 | from sqlalchemy_enum34 import EnumType 17 | from enum import Enum 18 | from compair.models import convention 19 | 20 | class PairingAlgorithm(Enum): 21 | adaptive = "adaptive" 22 | random = "random" 23 | 24 | def upgrade(): 25 | with op.batch_alter_table('comparison', naming_convention=convention) as batch_op: 26 | batch_op.add_column(sa.Column('pairing_algorithm', EnumType(PairingAlgorithm), nullable=True)) 27 | with op.batch_alter_table('assignment', naming_convention=convention) as batch_op: 28 | batch_op.add_column(sa.Column('pairing_algorithm', EnumType(PairingAlgorithm), nullable=True)) 29 | 30 | 31 | def downgrade(): 32 | with op.batch_alter_table('assignment', naming_convention=convention) as batch_op: 33 | batch_op.drop_column('pairing_algorithm') 34 | with op.batch_alter_table('comparison', naming_convention=convention) as batch_op: 35 | batch_op.drop_column('pairing_algorithm') 36 | -------------------------------------------------------------------------------- /alembic/versions/4667f38426eb_added_rounds_answers.py: -------------------------------------------------------------------------------- 1 | """added rounds answers 2 | 3 | Revision ID: 4667f38426eb 4 | Revises: 380522bcdac1 5 | Create Date: 2014-12-18 14:56:56.064146 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '4667f38426eb' 11 | down_revision = '380522bcdac1' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | from sqlalchemy.sql import text 16 | 17 | from compair.models import convention 18 | 19 | 20 | def upgrade(): 21 | op.add_column('Answers', sa.Column('round', sa.Integer(), nullable=False, default='0', server_default='0')) 22 | populate = text( 23 | "UPDATE Answers SET round =" 24 | "(SELECT custom.id FROM " 25 | "(SELECT a.id, sum(jcount) AS count " 26 | "FROM Answers a, " 27 | "(SELECT ap.id, ap.answers_id1, ap.answers_id2, count(*) AS jcount " 28 | "FROM AnswerPairings ap " 29 | "JOIN Judgements j " 30 | "ON j.answerpairings_id = ap.id " 31 | "GROUP BY ap.id) AS ap " 32 | "WHERE a.id = ap.answers_id1 OR a.id = ap.answers_id2 " 33 | "GROUP BY a.id) custom " 34 | "WHERE Answers.id = custom.id)" 35 | ) 36 | op.get_bind().execute(populate) 37 | 38 | 39 | def downgrade(): 40 | with op.batch_alter_table('Answers', naming_convention=convention) as batch_op: 41 | batch_op.drop_column('round') 42 | -------------------------------------------------------------------------------- /alembic/versions/4670092712d7_add_lti_course_offering_and_section_ids.py: -------------------------------------------------------------------------------- 1 | """Add LTI course offering and section ids 2 | 3 | Revision ID: 4670092712d7 4 | Revises: 59646d2ad22c 5 | Create Date: 2018-10-05 19:10:51.882637 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '4670092712d7' 11 | down_revision = '59646d2ad22c' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | def upgrade(): 19 | with op.batch_alter_table('lti_context', naming_convention=convention) as batch_op: 20 | batch_op.add_column(sa.Column('lis_course_offering_sourcedid', sa.String(length=255), nullable=True)) 21 | batch_op.add_column(sa.Column('lis_course_section_sourcedid', sa.String(length=255), nullable=True)) 22 | 23 | with op.batch_alter_table('lti_user', naming_convention=convention) as batch_op: 24 | batch_op.add_column(sa.Column('lis_person_sourcedid', sa.String(length=255), nullable=True)) 25 | 26 | 27 | def downgrade(): 28 | with op.batch_alter_table('lti_user', naming_convention=convention) as batch_op: 29 | batch_op.drop_column('lis_person_sourcedid') 30 | 31 | with op.batch_alter_table('lti_context', naming_convention=convention) as batch_op: 32 | batch_op.drop_column('lis_course_section_sourcedid') 33 | batch_op.drop_column('lis_course_offering_sourcedid') -------------------------------------------------------------------------------- /alembic/versions/472780bc3c62_added_selfeval_flags_to_comments.py: -------------------------------------------------------------------------------- 1 | """added selfeval flags to comments 2 | 3 | Revision ID: 472780bc3c62 4 | Revises: 3b053548b60f 5 | Create Date: 2014-10-30 16:31:09.871401 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '472780bc3c62' 11 | down_revision = '3b053548b60f' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | 19 | def upgrade(): 20 | with op.batch_alter_table('AnswersAndComments', naming_convention=convention) as batch_op: 21 | batch_op.add_column(sa.Column('evaluation', sa.Boolean(), nullable=False, server_default='0', default=False)) 22 | batch_op.add_column(sa.Column('selfeval', sa.Boolean(), nullable=False, server_default='0', default=False)) 23 | 24 | 25 | def downgrade(): 26 | with op.batch_alter_table('AnswersAndComments', naming_convention=convention) as batch_op: 27 | batch_op.drop_column('selfeval') 28 | batch_op.drop_column('evaluation') 29 | -------------------------------------------------------------------------------- /alembic/versions/4f56a1ca6ff7_create_answer_comment_type_field.py: -------------------------------------------------------------------------------- 1 | """create answer comment type field 2 | 3 | Revision ID: 4f56a1ca6ff7 4 | Revises: 4c4e55aabae6 5 | Create Date: 2015-06-29 15:31:00.833331 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | 11 | revision = '4f56a1ca6ff7' 12 | down_revision = '4c4e55aabae6' 13 | 14 | from alembic import op 15 | import sqlalchemy as sa 16 | from sqlalchemy.sql import text 17 | 18 | from compair.models import convention 19 | 20 | 21 | def upgrade(): 22 | op.add_column( 23 | 'AnswersAndComments', 24 | sa.Column('type', sa.SmallInteger(), nullable=False, server_default='0', default=0)) 25 | # now we need to upgrade data 26 | update = text( 27 | # rewrite into subquery to support SQLite. need more testing to verify 28 | "UPDATE AnswersAndComments " 29 | "SET type = 1 " 30 | "WHERE evaluation = 0 AND selfeval = 0" 31 | ) 32 | op.get_bind().execute(update) 33 | 34 | def downgrade(): 35 | with op.batch_alter_table('AnswersAndComments', naming_convention=convention) as batch_op: 36 | batch_op.drop_column('type') 37 | -------------------------------------------------------------------------------- /alembic/versions/6b69e8e22dfb_merge_4593a30102c1_and_a91d59c2cfd5.py: -------------------------------------------------------------------------------- 1 | """merge 4593a30102c1 and a91d59c2cfd5 2 | 3 | Revision ID: 6b69e8e22dfb 4 | Revises: ('4593a30102c1', 'a91d59c2cfd5') 5 | Create Date: 2018-03-27 19:59:35.023166 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '6b69e8e22dfb' 11 | down_revision = ('4593a30102c1', 'a91d59c2cfd5') 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | pass 19 | 20 | 21 | def downgrade(): 22 | pass 23 | -------------------------------------------------------------------------------- /alembic/versions/82ed8fc51745_add_course_sandbox_flag.py: -------------------------------------------------------------------------------- 1 | """add course sandbox flag 2 | 3 | Revision ID: 82ed8fc51745 4 | Revises: 346c3877ffae 5 | Create Date: 2017-10-10 11:55:58.012318 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '82ed8fc51745' 11 | down_revision = '346c3877ffae' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | def upgrade(): 19 | with op.batch_alter_table('course', naming_convention=convention) as batch_op: 20 | batch_op.add_column(sa.Column('sandbox', sa.Boolean(), nullable=False, server_default='0', default=False)) 21 | op.create_index(op.f('ix_course_sandbox'), 'course', ['sandbox'], unique=False) 22 | 23 | def downgrade(): 24 | with op.batch_alter_table('course', naming_convention=convention) as batch_op: 25 | batch_op.drop_index('ix_course_sandbox') 26 | batch_op.drop_column('sandbox') -------------------------------------------------------------------------------- /alembic/versions/852abaee3a25_add_email_notify_method_to_user_table.py: -------------------------------------------------------------------------------- 1 | """Add email_notification_method to user table 2 | 3 | Revision ID: 852abaee3a25 4 | Revises: d2cfb4363907 5 | Create Date: 2017-04-27 18:34:51.728465 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '852abaee3a25' 11 | down_revision = 'd2cfb4363907' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | from sqlalchemy_enum34 import EnumType 16 | from enum import Enum 17 | 18 | from compair.models import convention 19 | 20 | class EmailNotificationMethod(Enum): 21 | enable = "enable" 22 | disable = "disable" 23 | 24 | def upgrade(): 25 | with op.batch_alter_table('user', naming_convention=convention) as batch_op: 26 | batch_op.add_column(sa.Column('email_notification_method', 27 | EnumType(EmailNotificationMethod), 28 | default=EmailNotificationMethod.enable, 29 | server_default=EmailNotificationMethod.enable.value, 30 | nullable=False)) 31 | op.create_index(op.f('ix_user_email_notification_method'), 'user', ['email_notification_method'], unique=False) 32 | 33 | 34 | def downgrade(): 35 | with op.batch_alter_table('user', naming_convention=convention) as batch_op: 36 | batch_op.drop_index('ix_user_email_notification_method') 37 | batch_op.drop_column('email_notification_method') -------------------------------------------------------------------------------- /alembic/versions/895826c6c1ce_merge_4670092712d7_and_1e4cb64dd9b5.py: -------------------------------------------------------------------------------- 1 | """Merge 4670092712d7 and 1e4cb64dd9b5 2 | 3 | Revision ID: 895826c6c1ce 4 | Revises: ('4670092712d7', '1e4cb64dd9b5') 5 | Create Date: 2019-07-23 20:50:38.916720 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '895826c6c1ce' 11 | down_revision = ('4670092712d7', '1e4cb64dd9b5') 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | pass 19 | 20 | 21 | def downgrade(): 22 | pass 23 | -------------------------------------------------------------------------------- /alembic/versions/8b4e3c1d8c41_create_unique_index_for_attachment_file_.py: -------------------------------------------------------------------------------- 1 | """Create unique index for attachment file name 2 | 3 | Revision ID: 8b4e3c1d8c41 4 | Revises: bbf7d7f7da06 5 | Create Date: 2018-07-23 16:36:20.186728 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '8b4e3c1d8c41' 11 | down_revision = 'bbf7d7f7da06' 12 | 13 | import logging 14 | from alembic import op 15 | 16 | from compair.models import convention 17 | 18 | def upgrade(): 19 | # create unique index for file.name 20 | with op.batch_alter_table('file', naming_convention=convention) as batch_op: 21 | batch_op.create_unique_constraint(op.f('uq_file_name'), ['name']) 22 | 23 | def downgrade(): 24 | # drop the unique index 25 | with op.batch_alter_table('file', naming_convention=convention) as batch_op: 26 | batch_op.drop_constraint('uq_file_name', type_='unique') 27 | -------------------------------------------------------------------------------- /alembic/versions/907993a4de86_merge_d8f2aa162e1f_and_265dc6402cf8.py: -------------------------------------------------------------------------------- 1 | """merge d8f2aa162e1f and 265dc6402cf8 2 | 3 | Revision ID: 907993a4de86 4 | Revises: ('d8f2aa162e1f', '265dc6402cf8') 5 | Create Date: 2018-05-04 16:32:28.445865 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '907993a4de86' 11 | down_revision = ('d8f2aa162e1f', '265dc6402cf8') 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | 19 | def upgrade(): 20 | pass 21 | 22 | 23 | def downgrade(): 24 | pass 25 | -------------------------------------------------------------------------------- /alembic/versions/a4833ca3fd42_cleanup_0_9_0.py: -------------------------------------------------------------------------------- 1 | """cleanup 0.9.0 2 | 3 | Revision ID: a4833ca3fd42 4 | Revises: e74cf0affe74 5 | Create Date: 2016-09-06 09:54:39.423881 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = 'a4833ca3fd42' 11 | down_revision = 'e74cf0affe74' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | def upgrade(): 19 | with op.batch_alter_table('score', naming_convention=convention) as batch_op: 20 | batch_op.drop_column('excepted_score') 21 | 22 | 23 | def downgrade(): 24 | op.add_column('score', sa.Column('excepted_score', sa.Integer(), nullable=True)) 25 | -------------------------------------------------------------------------------- /alembic/versions/a91d59c2cfd5_add_student_number_to_lti_user.py: -------------------------------------------------------------------------------- 1 | """Add student number to lti user 2 | 3 | Revision ID: a91d59c2cfd5 4 | Revises: 2561c39ac4d9 5 | Create Date: 2018-03-19 20:38:57.068934 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = 'a91d59c2cfd5' 11 | down_revision = '2561c39ac4d9' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | def upgrade(): 19 | with op.batch_alter_table('lti_consumer', naming_convention=convention) as batch_op: 20 | batch_op.add_column(sa.Column('student_number_param', sa.String(length=255), nullable=True)) 21 | 22 | with op.batch_alter_table('lti_user', naming_convention=convention) as batch_op: 23 | batch_op.add_column(sa.Column('student_number', sa.String(length=255), nullable=True)) 24 | 25 | 26 | def downgrade(): 27 | with op.batch_alter_table('lti_user', naming_convention=convention) as batch_op: 28 | batch_op.drop_column('student_number') 29 | 30 | with op.batch_alter_table('lti_consumer', naming_convention=convention) as batch_op: 31 | batch_op.drop_column('student_number_param') -------------------------------------------------------------------------------- /alembic/versions/aafd2a91e3a_add_score_columns.py: -------------------------------------------------------------------------------- 1 | """Add scoring_algorithm, variable1, variable2, and loses columns to score table 2 | 3 | Revision ID: aafd2a91e3a 4 | Revises: 36c9fd392e33 5 | Create Date: 2016-06-28 15:49:10.314622 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = 'aafd2a91e3a' 11 | down_revision = '36c9fd392e33' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | from sqlalchemy_enum34 import EnumType 16 | from enum import Enum 17 | 18 | from compair.models import convention 19 | 20 | class ScoringAlgorithm(Enum): 21 | comparative_judgement = "comparative_judgement" 22 | elo = "elo_rating" 23 | true_skill = "true_skill_rating" 24 | 25 | def upgrade(): 26 | with op.batch_alter_table('score', naming_convention=convention) as batch_op: 27 | batch_op.add_column(sa.Column('scoring_algorithm', EnumType(ScoringAlgorithm), nullable=True)) 28 | batch_op.add_column(sa.Column('variable1', sa.Float(), nullable=True)) 29 | batch_op.add_column(sa.Column('variable2', sa.Float(), nullable=True)) 30 | batch_op.add_column(sa.Column('loses', sa.Integer(), nullable=False, default='0', server_default='0')) 31 | 32 | def downgrade(): 33 | with op.batch_alter_table('score', naming_convention=convention) as batch_op: 34 | batch_op.drop_column('loses') 35 | batch_op.drop_column('variable2') 36 | batch_op.drop_column('variable1') 37 | batch_op.drop_column('scoring_algorithm') 38 | 39 | -------------------------------------------------------------------------------- /alembic/versions/anchor.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/alembic/versions/anchor.txt -------------------------------------------------------------------------------- /alembic/versions/bb705e95c6dc_add_custom_param_regex_sanitizer_to_lti_.py: -------------------------------------------------------------------------------- 1 | """Add custom_param_regex_sanitizer to lti_consumer 2 | 3 | Revision ID: bb705e95c6dc 4 | Revises: fd7aab93104b 5 | Create Date: 2021-07-09 16:51:06.901761 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = 'bb705e95c6dc' 11 | down_revision = 'fd7aab93104b' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | 19 | def upgrade(): 20 | with op.batch_alter_table('lti_consumer', naming_convention=convention) as batch_op: 21 | batch_op.add_column(sa.Column('custom_param_regex_sanitizer', sa.String(length=255), nullable=True)) 22 | 23 | 24 | def downgrade(): 25 | with op.batch_alter_table('lti_consumer', naming_convention=convention) as batch_op: 26 | batch_op.drop_column('custom_param_regex_sanitizer') 27 | -------------------------------------------------------------------------------- /alembic/versions/bbf7d7f7da06_add_assignment_self_eval_dates_and_.py: -------------------------------------------------------------------------------- 1 | """add assignment self-eval dates and instructional text 2 | 3 | Revision ID: bbf7d7f7da06 4 | Revises: c8d5a5c16f59 5 | Create Date: 2018-02-05 23:25:02.312998 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = 'bbf7d7f7da06' 11 | down_revision = 'c8d5a5c16f59' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | def upgrade(): 19 | with op.batch_alter_table('assignment', naming_convention=convention) as batch_op: 20 | batch_op.add_column(sa.Column('self_eval_start', sa.DateTime(), nullable=True)) 21 | batch_op.add_column(sa.Column('self_eval_end', sa.DateTime(), nullable=True)) 22 | batch_op.add_column(sa.Column('self_eval_instructions', sa.Text(), nullable=True)) 23 | 24 | def downgrade(): 25 | with op.batch_alter_table('assignment', naming_convention=convention) as batch_op: 26 | batch_op.drop_column('self_eval_start') 27 | batch_op.drop_column('self_eval_end') 28 | batch_op.drop_column('self_eval_instructions') 29 | -------------------------------------------------------------------------------- /alembic/versions/c9ad3551e18_added_student_number.py: -------------------------------------------------------------------------------- 1 | """added student number 2 | 3 | Revision ID: c9ad3551e18 4 | Revises: 3ddca7ab950a 5 | Create Date: 2014-10-09 09:20:43.891993 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | import logging 11 | 12 | revision = 'c9ad3551e18' 13 | down_revision = '3ddca7ab950a' 14 | 15 | from alembic import op 16 | import sqlalchemy as sa 17 | 18 | from compair.models import convention 19 | 20 | 21 | def upgrade(): 22 | op.add_column('Users', sa.Column('student_no', sa.String(length=50), nullable=True)) 23 | with op.batch_alter_table('Users', naming_convention=convention) as batch_op: 24 | batch_op.create_unique_constraint('uq_Users_student_no', ['student_no']) 25 | 26 | 27 | def downgrade(): 28 | try: 29 | with op.batch_alter_table('Users', naming_convention=convention) as batch_op: 30 | # it seems alembic couldn't drop unique constraint for sqlite 31 | batch_op.drop_constraint('uq_Users_student_no', type_='unique') 32 | except ValueError: 33 | logging.warning('Drop unique constraint is not support for SQLite, dropping uq_Users_student_no ignored!') 34 | 35 | with op.batch_alter_table('Users', naming_convention=convention) as batch_op: 36 | batch_op.drop_column('student_no') 37 | -------------------------------------------------------------------------------- /alembic/versions/d402c96606ce_revised_lti_consumer_by_adding_user_id_.py: -------------------------------------------------------------------------------- 1 | """Revised LTI consumer by adding user_id_override and removing canvas_consumer and canvas_api_token 2 | 3 | Revision ID: d402c96606ce 4 | Revises: 2a19cb1ab324 5 | Create Date: 2017-07-14 00:02:05.424665 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = 'd402c96606ce' 11 | down_revision = '2a19cb1ab324' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | def upgrade(): 19 | with op.batch_alter_table('lti_consumer', naming_convention=convention) as batch_op: 20 | batch_op.drop_column('canvas_api_token') 21 | batch_op.drop_column('canvas_consumer') 22 | batch_op.add_column(sa.Column('user_id_override', sa.String(255), nullable=True)) 23 | 24 | def downgrade(): 25 | with op.batch_alter_table('lti_consumer', naming_convention=convention) as batch_op: 26 | batch_op.add_column(sa.Column('canvas_consumer', sa.Boolean(), nullable=False, default=False, server_default='0')) 27 | batch_op.add_column(sa.Column('canvas_api_token', sa.String(255), nullable=True)) 28 | batch_op.drop_column('user_id_override') 29 | -------------------------------------------------------------------------------- /alembic/versions/d6c88adfe909_add_submission_date_to_answer.py: -------------------------------------------------------------------------------- 1 | """Add submission date to answer 2 | 3 | Revision ID: d6c88adfe909 4 | Revises: 8b4e3c1d8c41 5 | Create Date: 2018-08-09 03:25:24.549790 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = 'd6c88adfe909' 11 | down_revision = '8b4e3c1d8c41' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | def upgrade(): 19 | with op.batch_alter_table('answer', naming_convention=convention) as batch_op: 20 | batch_op.add_column(sa.Column('submission_date', sa.DateTime(), nullable=True)) 21 | op.execute('UPDATE answer SET submission_date=modified') 22 | 23 | def downgrade(): 24 | with op.batch_alter_table('answer', naming_convention=convention) as batch_op: 25 | batch_op.drop_column('submission_date') 26 | -------------------------------------------------------------------------------- /alembic/versions/d8f2aa162e1f_remove_answer_flag_columns.py: -------------------------------------------------------------------------------- 1 | """remove answer flag columns 2 | 3 | Revision ID: d8f2aa162e1f 4 | Revises: 6b69e8e22dfb 5 | Create Date: 2018-03-21 19:20:16.395313 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = 'd8f2aa162e1f' 11 | down_revision = '6b69e8e22dfb' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | from sqlalchemy import exc 16 | 17 | from compair.models import convention 18 | 19 | def upgrade(): 20 | try: 21 | # expected foreign key to follow naming conventions 22 | with op.batch_alter_table('answer', naming_convention=convention) as batch_op: 23 | batch_op.drop_constraint('fk_answer_flagger_user_id_user', 'foreignkey') 24 | except exc.InternalError: 25 | # if not, it is likely this name 26 | with op.batch_alter_table('answer') as batch_op: 27 | batch_op.drop_constraint('answer_ibfk_4', 'foreignkey') 28 | 29 | with op.batch_alter_table('answer', naming_convention=convention) as batch_op: 30 | batch_op.drop_column('flagger_user_id') 31 | batch_op.drop_column('flagged') 32 | 33 | 34 | def downgrade(): 35 | with op.batch_alter_table('answer', naming_convention=convention) as batch_op: 36 | batch_op.add_column(sa.Column('flagged', sa.Boolean(), nullable=False)) 37 | batch_op.add_column(sa.Column('flagger_user_id', sa.Integer(), nullable=True)) 38 | batch_op.create_foreign_key('fk_answer_flagger_user_id_user', 39 | 'user', ['flagger_user_id'], ['id'], ondelete="SET NULL") 40 | -------------------------------------------------------------------------------- /alembic/versions/deafd926294b_add_lti_nonce_table.py: -------------------------------------------------------------------------------- 1 | """Add lti_nonce table 2 | 3 | Revision ID: deafd926294b 4 | Revises: 485ff3eedf19 5 | Create Date: 2016-08-11 11:59:18.546501 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = 'deafd926294b' 11 | down_revision = '485ff3eedf19' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | def upgrade(): 17 | op.create_table('lti_nonce', 18 | sa.Column('id', sa.Integer(), nullable=False), 19 | sa.Column('lti_consumer_id', sa.Integer(), nullable=False), 20 | sa.Column('oauth_nonce', sa.String(length=255), nullable=False), 21 | sa.Column('oauth_timestamp', sa.TIMESTAMP(), nullable=False), 22 | sa.Column('modified_user_id', sa.Integer(), nullable=True), 23 | sa.Column('modified', sa.DateTime(), nullable=False), 24 | sa.Column('created_user_id', sa.Integer(), nullable=True), 25 | sa.Column('created', sa.DateTime(), nullable=False), 26 | sa.ForeignKeyConstraint(['created_user_id'], ['user.id'], ondelete='SET NULL'), 27 | sa.ForeignKeyConstraint(['lti_consumer_id'], ['lti_consumer.id'], ondelete='CASCADE'), 28 | sa.ForeignKeyConstraint(['modified_user_id'], ['user.id'], ondelete='SET NULL'), 29 | sa.PrimaryKeyConstraint('id'), 30 | sa.UniqueConstraint('lti_consumer_id', 'oauth_nonce', 'oauth_timestamp', name='_unique_lti_consumer_nonce_and_timestamp'), 31 | mysql_charset='utf8', 32 | mysql_collate='utf8_unicode_ci', 33 | mysql_engine='InnoDB' 34 | ) 35 | 36 | def downgrade(): 37 | op.drop_table('lti_nonce') -------------------------------------------------------------------------------- /alembic/versions/e74cf0affe74_allow_user_username_and_password.py: -------------------------------------------------------------------------------- 1 | """Allow user's username and password to be null 2 | 3 | Revision ID: e74cf0affe74 4 | Revises: 0f36b3ad81fc 5 | Create Date: 2016-08-26 10:34:27.420511 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = 'e74cf0affe74' 11 | down_revision = '0f36b3ad81fc' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | def upgrade(): 19 | with op.batch_alter_table('user', naming_convention=convention) as batch_op: 20 | batch_op.alter_column('_password', nullable=True, existing_type=sa.String(length=255)) 21 | batch_op.alter_column('username', nullable=True, existing_type=sa.String(length=255)) 22 | 23 | 24 | def downgrade(): 25 | with op.batch_alter_table('user', naming_convention=convention) as batch_op: 26 | batch_op.alter_column('_password', nullable=False, existing_type=sa.String(length=255)) 27 | batch_op.alter_column('username', nullable=False, existing_type=sa.String(length=255)) 28 | -------------------------------------------------------------------------------- /alembic/versions/ed763e759c2a_remove_file_active_column.py: -------------------------------------------------------------------------------- 1 | """Remove file active column 2 | 3 | Revision ID: ed763e759c2a 4 | Revises: 622121ae2f36 5 | Create Date: 2017-02-08 21:07:33.579874 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = 'ed763e759c2a' 11 | down_revision = '622121ae2f36' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | from compair.models import convention 17 | 18 | def upgrade(): 19 | with op.batch_alter_table('file', naming_convention=convention) as batch_op: 20 | batch_op.drop_index('ix_file_active') 21 | batch_op.drop_column('active') 22 | 23 | op.create_index(op.f('ix_answer_draft'), 'answer', ['draft'], unique=False) 24 | op.create_index(op.f('ix_answer_comment_draft'), 'answer_comment', ['draft'], unique=False) 25 | 26 | 27 | def downgrade(): 28 | with op.batch_alter_table('answer_comment', naming_convention=convention) as batch_op: 29 | batch_op.drop_index('ix_answer_comment_draft') 30 | 31 | with op.batch_alter_table('answer', naming_convention=convention) as batch_op: 32 | batch_op.drop_index('ix_answer_draft') 33 | 34 | with op.batch_alter_table('file', naming_convention=convention) as batch_op: 35 | batch_op.add_column(sa.Column('active', sa.Boolean(), default=True, server_default='1', nullable=False)) 36 | op.create_index(op.f('ix_file_active'), 'file', ['active'], unique=False) 37 | -------------------------------------------------------------------------------- /alembic/versions/f6145781f130_merge_add_uuid_and_min_delta_algo.py: -------------------------------------------------------------------------------- 1 | """merge add uuid and min delta algo 2 | 3 | Revision ID: f6145781f130 4 | Revises: ('20381bf0bd54', '10cceff97b06') 5 | Create Date: 2017-11-28 21:23:20.410400 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = 'f6145781f130' 11 | down_revision = ('20381bf0bd54', '10cceff97b06') 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | pass 19 | 20 | 21 | def downgrade(): 22 | pass 23 | -------------------------------------------------------------------------------- /celery_worker.py: -------------------------------------------------------------------------------- 1 | import os 2 | from compair import create_app, celery 3 | 4 | app = create_app(skip_endpoints=True, skip_assets=True) 5 | 6 | # needed to get flask debugging working with uwsgi in docker 7 | # (since it ignores FLASK_DEBUG & DEBUG) 8 | if os.environ.get('DEV') == '1': 9 | app.debug = True 10 | 11 | TaskBase = celery.Task 12 | class ContextTask(TaskBase): 13 | abstract = True 14 | def __call__(self, *args, **kwargs): 15 | with app.app_context(): 16 | return TaskBase.__call__(self, *args, **kwargs) 17 | celery.Task = ContextTask -------------------------------------------------------------------------------- /compair/algorithms/__init__.py: -------------------------------------------------------------------------------- 1 | from .comparison_pair import ComparisonPair 2 | from .comparison_winner import ComparisonWinner 3 | from .scored_object import ScoredObject 4 | from .exceptions import InsufficientObjectsForPairException, \ 5 | UserComparedAllObjectsException, UnknownPairGeneratorException, \ 6 | InvalidWinnerException -------------------------------------------------------------------------------- /compair/algorithms/comparison_pair.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | ComparisonPair = namedtuple('ComparisonPair', 4 | ['key1', 'key2', 'winner'] 5 | ) -------------------------------------------------------------------------------- /compair/algorithms/comparison_winner.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class ComparisonWinner(Enum): 4 | key1 = "key1" 5 | key2 = "key2" 6 | draw = "draw" -------------------------------------------------------------------------------- /compair/algorithms/exceptions.py: -------------------------------------------------------------------------------- 1 | class InsufficientObjectsForPairException(Exception): 2 | pass 3 | 4 | class UserComparedAllObjectsException(Exception): 5 | pass 6 | 7 | class UnknownPairGeneratorException(Exception): 8 | pass 9 | 10 | class InvalidWinnerException(Exception): 11 | pass -------------------------------------------------------------------------------- /compair/algorithms/pair/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import generate_pair -------------------------------------------------------------------------------- /compair/algorithms/pair/adaptive/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import generate_pair -------------------------------------------------------------------------------- /compair/algorithms/pair/adaptive/core.py: -------------------------------------------------------------------------------- 1 | from .pair_generator import AdaptivePairGenerator 2 | 3 | def generate_pair(scored_objects=[], comparison_pairs=[], log=None): 4 | pair_algorithm = AdaptivePairGenerator() 5 | pair_algorithm.log = log 6 | return pair_algorithm.generate_pair(scored_objects, comparison_pairs) -------------------------------------------------------------------------------- /compair/algorithms/pair/adaptive_min_delta/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import generate_pair 2 | -------------------------------------------------------------------------------- /compair/algorithms/pair/adaptive_min_delta/core.py: -------------------------------------------------------------------------------- 1 | from .pair_generator import AdaptiveMinDeltaPairGenerator 2 | 3 | def generate_pair(scored_objects=[], comparison_pairs=[], criterion_scores={}, criterion_weights={}, log=None): 4 | pair_algorithm = AdaptiveMinDeltaPairGenerator() 5 | pair_algorithm.log = log 6 | return pair_algorithm.generate_pair(scored_objects, comparison_pairs, criterion_scores, criterion_weights) 7 | -------------------------------------------------------------------------------- /compair/algorithms/pair/core.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | 3 | def generate_pair(package_name="random", **kargs): 4 | package = import_module("compair.algorithms.pair."+package_name) 5 | return package.generate_pair(**kargs) 6 | -------------------------------------------------------------------------------- /compair/algorithms/pair/random/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import generate_pair -------------------------------------------------------------------------------- /compair/algorithms/pair/random/core.py: -------------------------------------------------------------------------------- 1 | from .pair_generator import RandomPairGenerator 2 | 3 | def generate_pair(scored_objects=[], comparison_pairs=[], log=None): 4 | pair_algorithm = RandomPairGenerator() 5 | pair_algorithm.log = log 6 | return pair_algorithm.generate_pair(scored_objects, comparison_pairs) -------------------------------------------------------------------------------- /compair/algorithms/score/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import calculate_score, calculate_score_1vs1 -------------------------------------------------------------------------------- /compair/algorithms/score/comparative_judgement/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import calculate_score, calculate_score_1vs1 -------------------------------------------------------------------------------- /compair/algorithms/score/comparative_judgement/core.py: -------------------------------------------------------------------------------- 1 | from .score_algorithm import ComparativeJudgementScoreAlgorithm 2 | 3 | def calculate_score(comparison_pairs=[], log=None): 4 | score_algorithm = ComparativeJudgementScoreAlgorithm() 5 | score_algorithm.log = log 6 | return score_algorithm.calculate_score(comparison_pairs) 7 | 8 | def calculate_score_1vs1(key1_scored_object, key2_scored_object, winner, other_comparison_pairs=[], log=None): 9 | score_algorithm = ComparativeJudgementScoreAlgorithm() 10 | score_algorithm.log = log 11 | return score_algorithm.calculate_score_1vs1(key1_scored_object, key2_scored_object, winner, other_comparison_pairs) -------------------------------------------------------------------------------- /compair/algorithms/score/core.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | 3 | def calculate_score(package_name="elo_rating", **kargs): 4 | package = import_module("compair.algorithms.score."+package_name) 5 | return package.calculate_score(**kargs) 6 | 7 | def calculate_score_1vs1(package_name="elo_rating", **kargs): 8 | package = import_module("compair.algorithms.score."+package_name) 9 | return package.calculate_score_1vs1(**kargs) -------------------------------------------------------------------------------- /compair/algorithms/score/elo_rating/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import calculate_score, calculate_score_1vs1 -------------------------------------------------------------------------------- /compair/algorithms/score/elo_rating/core.py: -------------------------------------------------------------------------------- 1 | from .score_algorithm import EloAlgorithmWrapper 2 | 3 | def calculate_score(comparison_pairs=[], log=None): 4 | score_algorithm = EloAlgorithmWrapper() 5 | score_algorithm.log = log 6 | return score_algorithm.calculate_score(comparison_pairs) 7 | 8 | def calculate_score_1vs1(key1_scored_object, key2_scored_object, winner, other_comparison_pairs=[], log=None): 9 | score_algorithm = EloAlgorithmWrapper() 10 | score_algorithm.log = log 11 | return score_algorithm.calculate_score_1vs1(key1_scored_object, key2_scored_object, winner, other_comparison_pairs) -------------------------------------------------------------------------------- /compair/algorithms/score/score_algorithm_base.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod, abstractproperty 2 | 3 | 4 | class ScoreAlgorithmBase: 5 | __metaclass__ = ABCMeta 6 | 7 | def __init__(self): 8 | self.log = None 9 | 10 | def _debug(self, message): 11 | if self.log != None: 12 | self.log.debug(message) 13 | 14 | def get_keys_from_comparison_pairs(self, comparison_pairs): 15 | keys = set() 16 | 17 | for comparison_pair in comparison_pairs: 18 | keys.add(comparison_pair.key1) 19 | keys.add(comparison_pair.key2) 20 | 21 | return keys 22 | 23 | 24 | @abstractmethod 25 | def calculate_score(self, comparison_pairs): 26 | pass 27 | 28 | @abstractmethod 29 | def calculate_score_1vs1(self, key1_score_parameters, key2_score_parameters, winner, other_comparison_pairs): 30 | pass 31 | -------------------------------------------------------------------------------- /compair/algorithms/score/true_skill_rating/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import calculate_score, calculate_score_1vs1 -------------------------------------------------------------------------------- /compair/algorithms/score/true_skill_rating/core.py: -------------------------------------------------------------------------------- 1 | from .score_algorithm import TrueSkillAlgorithmWrapper 2 | 3 | def calculate_score(comparison_pairs=[], log=None): 4 | score_algorithm = TrueSkillAlgorithmWrapper() 5 | score_algorithm.log = log 6 | return score_algorithm.calculate_score(comparison_pairs) 7 | 8 | def calculate_score_1vs1(key1_scored_object, key2_scored_object, winner, other_comparison_pairs=[], log=None): 9 | score_algorithm = TrueSkillAlgorithmWrapper() 10 | score_algorithm.log = log 11 | return score_algorithm.calculate_score_1vs1(key1_scored_object, key2_scored_object, winner, other_comparison_pairs) -------------------------------------------------------------------------------- /compair/algorithms/scored_object.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | ScoredObject = namedtuple('ScoredObject', 3 | ['key', 'score', 'variable1', 'variable2', 'rounds', 'opponents', 'wins', 'loses'] 4 | ) -------------------------------------------------------------------------------- /compair/api/common.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from flask import Blueprint 4 | from flask_restful import Resource 5 | from flask_login import login_required 6 | 7 | from .util import new_restful_api 8 | 9 | timer_api = Blueprint('timer_api', __name__) 10 | 11 | class TimerAPI(Resource): 12 | @login_required 13 | def get(self): 14 | return {'date': int(round(time.time() * 1000))} 15 | 16 | api = new_restful_api(timer_api) 17 | 18 | api.add_resource(TimerAPI, '') -------------------------------------------------------------------------------- /compair/api/healthz.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify 2 | 3 | healthz_api = Blueprint("healthz_api", __name__, url_prefix='/api') 4 | 5 | 6 | @healthz_api.route('/healthz', methods=['GET']) 7 | def healthz(): 8 | return jsonify({'status': 'OK'}) 9 | -------------------------------------------------------------------------------- /compair/api/statements.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | from flask_restful import Resource, reqparse 3 | from flask_login import login_required, current_user 4 | 5 | from compair.core import db, event, abort 6 | from compair.xapi import XAPI, XAPIStatement 7 | 8 | from .util import new_restful_api 9 | 10 | statement_api = Blueprint('statement_api', __name__) 11 | api = new_restful_api(statement_api) 12 | 13 | 14 | statement_parser = reqparse.RequestParser() 15 | statement_parser.add_argument('verb', type=dict, location='json', required=True, nullable=False) 16 | statement_parser.add_argument('object', type=dict, location='json', required=True, nullable=False) 17 | statement_parser.add_argument('context', type=dict, location='json', required=False) 18 | statement_parser.add_argument('result', type=dict, location='json', required=False) 19 | statement_parser.add_argument('timestamp', required=False) 20 | 21 | class StatementAPI(Resource): 22 | @login_required 23 | def post(self): 24 | if not XAPI.enabled(): 25 | # this should silently fail 26 | abort(404) 27 | 28 | params = statement_parser.parse_args() 29 | 30 | statement = XAPIStatement.generate_from_params(current_user, params) 31 | XAPI.send_statement(statement) 32 | 33 | return { 'success': True } 34 | 35 | api.add_resource(StatementAPI, '') -------------------------------------------------------------------------------- /compair/kaltura/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import KalturaCore 2 | from .kaltura_session import KalturaSession 3 | from .media import Media 4 | from .upload_token import UploadToken 5 | 6 | from .kaltura import KalturaAPI -------------------------------------------------------------------------------- /compair/kaltura/core.py: -------------------------------------------------------------------------------- 1 | from flask import current_app 2 | from flask_login import current_user 3 | from compair.core import abort 4 | 5 | from compair.core import db 6 | 7 | from . import * 8 | 9 | class KalturaCore(object): 10 | API_VERSION = 'api_v3' 11 | SESSION_TYPE_USER = 0 12 | SESSION_TYPE_ADMIN = 2 13 | 14 | @classmethod 15 | def enabled(cls): 16 | return current_app.config.get('KALTURA_ENABLED') 17 | 18 | @classmethod 19 | def service_url(cls): 20 | return current_app.config.get('KALTURA_SERVICE_URL') 21 | 22 | @classmethod 23 | def base_url(cls): 24 | return cls.service_url()+'/'+cls.API_VERSION 25 | 26 | @classmethod 27 | def enforce_ssl(cls): 28 | return current_app.config.get('ENFORCE_SSL', True) 29 | 30 | @classmethod 31 | def partner_id(cls): 32 | return current_app.config.get('KALTURA_PARTNER_ID') 33 | 34 | @classmethod 35 | def user_id(cls): 36 | return current_app.config.get('KALTURA_USER_ID') 37 | 38 | @classmethod 39 | def secret(cls): 40 | return current_app.config.get('KALTURA_SECRET') 41 | 42 | @classmethod 43 | def player_id(cls): 44 | return current_app.config.get('KALTURA_PLAYER_ID') 45 | 46 | @classmethod 47 | def video_extensions(cls): 48 | return current_app.config.get('KALTURA_VIDEO_EXTENSIONS') 49 | 50 | @classmethod 51 | def audio_extensions(cls): 52 | return current_app.config.get('KALTURA_AUDIO_EXTENSIONS') 53 | -------------------------------------------------------------------------------- /compair/learning_records/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from .xapi import XAPIActivity, XAPIActor, XAPIContext, \ 3 | XAPIObject, XAPIResult, XAPIStatement, XAPIVerb, XAPI 4 | 5 | from .caliper import CaliperActor, CaliperEntities, \ 6 | CaliperSensor, CaliperEvent 7 | 8 | from .capture_events import capture_events 9 | from .learning_record import LearningRecord 10 | from .resource_iri import ResourceIRI 11 | -------------------------------------------------------------------------------- /compair/learning_records/caliper/__init__.py: -------------------------------------------------------------------------------- 1 | from .actor import CaliperActor 2 | from .entities import CaliperEntities 3 | from .event import CaliperEvent 4 | from .sensor import CaliperSensor 5 | -------------------------------------------------------------------------------- /compair/learning_records/xapi/__init__.py: -------------------------------------------------------------------------------- 1 | from .activity import XAPIActivity 2 | from .actor import XAPIActor 3 | from .context import XAPIContext 4 | from .object import XAPIObject 5 | from .result import XAPIResult 6 | from .statement import XAPIStatement 7 | from .verb import XAPIVerb 8 | from .xapi import XAPI 9 | -------------------------------------------------------------------------------- /compair/learning_records/xapi/activity.py: -------------------------------------------------------------------------------- 1 | from tincan import Activity, ActivityDefinition 2 | 3 | class XAPIActivity(object): 4 | activity_types = { 5 | 'page': 'http://activitystrea.ms/schema/1.0/page', 6 | 'comment': 'http://activitystrea.ms/schema/1.0/comment', 7 | 8 | 'attempt': 'http://adlnet.gov/expapi/activities/attempt', 9 | 'course': 'http://adlnet.gov/expapi/activities/course', 10 | 'group': 'http://activitystrea.ms/schema/1.0/group', 11 | 'assessment': 'http://adlnet.gov/expapi/activities/assessment', 12 | 'question': 'http://adlnet.gov/expapi/activities/question', 13 | 'solution': 'http://id.tincanapi.com/activitytype/solution', 14 | 'file': 'http://activitystrea.ms/schema/1.0/file', 15 | 'review': 'http://activitystrea.ms/schema/1.0/review', 16 | 'section': 'http://id.tincanapi.com/activitytype/section', 17 | 'modal': 'http://xapi.learninganalytics.ubc.ca/activitytype/modal', 18 | 'user profile': 'http://id.tincanapi.com/activitytype/user-profile', 19 | 'service': 'http://activitystrea.ms/schema/1.0/service' 20 | } 21 | 22 | @classmethod 23 | def compair_source(cls): 24 | return Activity( 25 | id='http://xapi.learninganalytics.ubc.ca/category/compair', 26 | definition=ActivityDefinition( 27 | type='http://id.tincanapi.com/activitytype/source' 28 | ) 29 | ) -------------------------------------------------------------------------------- /compair/learning_records/xapi/actor.py: -------------------------------------------------------------------------------- 1 | from tincan import Agent, AgentAccount 2 | from flask import current_app 3 | from compair.models import ThirdPartyType 4 | 5 | from compair.learning_records.resource_iri import ResourceIRI 6 | 7 | class XAPIActor(object): 8 | @classmethod 9 | def _generate_compair_account(cls, user): 10 | return AgentAccount( 11 | name=user.uuid, 12 | home_page=ResourceIRI.actor_homepage() 13 | ) 14 | 15 | @classmethod 16 | def _generate_global_unique_identifier_account(cls, user): 17 | name = user.global_unique_identifier 18 | homepage = current_app.config.get('LRS_ACTOR_ACCOUNT_GLOBAL_UNIQUE_IDENTIFIER_HOMEPAGE') 19 | if not name or not homepage: 20 | return None 21 | 22 | if not homepage.endswith('/'): 23 | homepage += '/' 24 | 25 | return AgentAccount( 26 | name=name, 27 | home_page=homepage 28 | ) 29 | 30 | @classmethod 31 | def generate_actor(cls, user): 32 | actor = Agent( 33 | name=user.fullname 34 | ) 35 | 36 | if current_app.config.get('LRS_ACTOR_ACCOUNT_USE_GLOBAL_UNIQUE_IDENTIFIER'): 37 | account = cls._generate_global_unique_identifier_account(user) 38 | if account: 39 | actor.account = account 40 | 41 | # set account to compair account by default 42 | if not actor.account: 43 | actor.account = cls._generate_compair_account(user) 44 | 45 | return actor -------------------------------------------------------------------------------- /compair/manage/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Manager package, includes all the manager scripts to manage application 3 | """ 4 | -------------------------------------------------------------------------------- /compair/manage/grades.py: -------------------------------------------------------------------------------- 1 | """ 2 | Recalculate Grades 3 | """ 4 | 5 | from flask_script import Manager 6 | from compair.models import Course, Assignment 7 | 8 | manager = Manager(usage="Generate Grades") 9 | 10 | @manager.command 11 | def generate(course_id=None, all=False): 12 | courses = [] 13 | if course_id != None: 14 | course = Course.query.get(course_id) 15 | if course and course.active: 16 | courses = [course] 17 | else: 18 | print("No course found with that ID") 19 | return 20 | elif all: 21 | courses = Course.query.all() 22 | else: 23 | print("Please enter a course_id or use the all flag") 24 | return 25 | 26 | for course in courses: 27 | if course.active: 28 | print("Generating grades for course: " + course.name) 29 | for assignment in course.assignments: 30 | if assignment.active: 31 | print("--- Generating grades for assignment: " + assignment.name) 32 | assignment.calculate_grades() 33 | course.calculate_grades() 34 | print("") 35 | print("Done.") -------------------------------------------------------------------------------- /compair/manage/score.py: -------------------------------------------------------------------------------- 1 | """ 2 | Recalculate Scores 3 | """ 4 | 5 | from flask_script import Manager, prompt_bool 6 | from compair.models import Comparison, Assignment 7 | 8 | 9 | manager = Manager(usage="Recalculate Assignment Answer Scores") 10 | 11 | 12 | @manager.option('-a', '--assignment', dest='assignment_id', help='Specify a Assignment ID to generate report from.') 13 | def recalculate(assignment_id): 14 | if not assignment_id: 15 | raise RuntimeError("Assignment with ID {} is not found.".format(assignment_id)) 16 | 17 | assignment = Assignment.query.filter_by(id=assignment_id).first() 18 | if not assignment: 19 | raise RuntimeError("Assignment with ID {} is not found.".format(assignment_id)) 20 | 21 | if prompt_bool("""All current answer scores and answer criterion scores will be overwritten. 22 | Final scores may differ slightly due to floating point rounding (especially if recalculating on different systems). 23 | Are you sure?"""): 24 | print ('Recalculating scores...') 25 | Comparison.calculate_scores(assignment.id) 26 | print ('Recalculate scores successful.') 27 | -------------------------------------------------------------------------------- /compair/models/activity_log.py: -------------------------------------------------------------------------------- 1 | # sqlalchemy 2 | from sqlalchemy import func, select, and_, or_ 3 | from sqlalchemy.ext.hybrid import hybrid_property 4 | 5 | from . import * 6 | 7 | from compair.core import db 8 | 9 | class ActivityLog(DefaultTableMixin): 10 | __tablename__ = 'activity_log' 11 | 12 | # table columns 13 | user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete="SET NULL"), 14 | nullable=True) 15 | course_id = db.Column(db.Integer, db.ForeignKey('course.id', ondelete="SET NULL"), 16 | nullable=True) 17 | timestamp = db.Column( 18 | db.TIMESTAMP, 19 | default=func.current_timestamp(), 20 | nullable=False 21 | ) 22 | event = db.Column(db.String(50)) 23 | data = db.Column(db.Text) 24 | status = db.Column(db.String(20)) 25 | message = db.Column(db.Text) 26 | session_id = db.Column(db.String(100)) 27 | -------------------------------------------------------------------------------- /compair/models/custom_types/__init__.py: -------------------------------------------------------------------------------- 1 | from .answer_comment_type import AnswerCommentType 2 | from .course_role import CourseRole 3 | from .email_notification_method import EmailNotificationMethod 4 | from .pairing_algorithm import PairingAlgorithm 5 | from .scoring_algorithm import ScoringAlgorithm 6 | from .system_role import SystemRole 7 | from .third_party_type import ThirdPartyType 8 | from .winning_answer import WinningAnswer -------------------------------------------------------------------------------- /compair/models/custom_types/answer_comment_type.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class AnswerCommentType(Enum): 4 | public = "Public" 5 | private = "Private" 6 | evaluation = "Evaluation" 7 | self_evaluation = "Self Evaluation" 8 | 9 | -------------------------------------------------------------------------------- /compair/models/custom_types/course_role.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class CourseRole(Enum): 4 | dropped = "Dropped" 5 | instructor = "Instructor" 6 | teaching_assistant = "Teaching Assistant" 7 | student = "Student" 8 | 9 | -------------------------------------------------------------------------------- /compair/models/custom_types/email_notification_method.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class EmailNotificationMethod(Enum): 4 | enable = "enable" 5 | disable = "disable" 6 | #digest = "digest" -------------------------------------------------------------------------------- /compair/models/custom_types/pairing_algorithm.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class PairingAlgorithm(Enum): 4 | adaptive = "adaptive" 5 | random = "random" 6 | adaptive_min_delta = "adaptive_min_delta" 7 | -------------------------------------------------------------------------------- /compair/models/custom_types/scoring_algorithm.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class ScoringAlgorithm(Enum): 4 | comparative_judgement = "comparative_judgement" 5 | elo = "elo_rating" 6 | true_skill = "true_skill_rating" 7 | -------------------------------------------------------------------------------- /compair/models/custom_types/system_role.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class SystemRole(Enum): 4 | student = "Student" 5 | instructor = "Instructor" 6 | sys_admin = "System Administrator" 7 | -------------------------------------------------------------------------------- /compair/models/custom_types/third_party_type.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class ThirdPartyType(Enum): 4 | cas = "CAS" 5 | saml = "SAML" 6 | -------------------------------------------------------------------------------- /compair/models/custom_types/winning_answer.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class WinningAnswer(Enum): 4 | answer1 = "answer1" 5 | answer2 = "answer2" 6 | draw = "draw" -------------------------------------------------------------------------------- /compair/models/kaltura_models/__init__.py: -------------------------------------------------------------------------------- 1 | # mixins 2 | from compair.models.mixins import DefaultTableMixin, WriteTrackingMixin 3 | 4 | # import models 5 | from compair.models import User, File 6 | 7 | # models 8 | from .kaltura_media import KalturaMedia -------------------------------------------------------------------------------- /compair/models/learning_records/__init__.py: -------------------------------------------------------------------------------- 1 | # mixins 2 | from compair.models.mixins import ActiveMixin, UUIDMixin, DefaultTableMixin, WriteTrackingMixin 3 | 4 | from .caliper_log import CaliperLog 5 | from .xapi_log import XAPILog -------------------------------------------------------------------------------- /compair/models/learning_records/caliper_log.py: -------------------------------------------------------------------------------- 1 | # sqlalchemy 2 | from sqlalchemy import func, select, and_, or_ 3 | from sqlalchemy.ext.hybrid import hybrid_property 4 | 5 | from . import * 6 | 7 | from compair.core import db 8 | 9 | class CaliperLog(DefaultTableMixin, WriteTrackingMixin): 10 | __tablename__ = 'caliper_log' 11 | 12 | # table columns 13 | event = db.Column(db.Text) 14 | transmitted = db.Column(db.Boolean(), default=False, nullable=False, index=True) 15 | 16 | @classmethod 17 | def __declare_last__(cls): 18 | super(cls, cls).__declare_last__() -------------------------------------------------------------------------------- /compair/models/learning_records/xapi_log.py: -------------------------------------------------------------------------------- 1 | # sqlalchemy 2 | from sqlalchemy import func, select, and_, or_ 3 | from sqlalchemy.ext.hybrid import hybrid_property 4 | 5 | from . import * 6 | 7 | from compair.core import db 8 | 9 | class XAPILog(DefaultTableMixin, WriteTrackingMixin): 10 | __tablename__ = 'xapi_log' 11 | 12 | # table columns 13 | statement = db.Column(db.Text) 14 | transmitted = db.Column(db.Boolean(), default=False, nullable=False, index=True) 15 | 16 | @classmethod 17 | def __declare_last__(cls): 18 | super(cls, cls).__declare_last__() -------------------------------------------------------------------------------- /compair/models/lti_models/__init__.py: -------------------------------------------------------------------------------- 1 | # mixins 2 | from compair.models.mixins import ActiveMixin, UUIDMixin, DefaultTableMixin, WriteTrackingMixin 3 | 4 | # import models 5 | from compair.models import UserCourse, Course, Assignment, User 6 | 7 | # import enums 8 | from compair.models import SystemRole, CourseRole 9 | 10 | # exceptions 11 | from .exceptions import MembershipNoValidContextsException, \ 12 | MembershipInvalidRequestException, MembershipNoResultsException 13 | 14 | # models 15 | from .lti_consumer import LTIConsumer 16 | from .lti_context import LTIContext 17 | from .lti_membership import LTIMembership 18 | from .lti_resource_link import LTIResourceLink 19 | from .lti_user import LTIUser 20 | from .lti_user_resource_link import LTIUserResourceLink 21 | from .lti_nonce import LTINonce 22 | from .lti_outcome import LTIOutcome -------------------------------------------------------------------------------- /compair/models/lti_models/exceptions.py: -------------------------------------------------------------------------------- 1 | class MembershipNoValidContextsException(Exception): 2 | pass 3 | 4 | class MembershipInvalidRequestException(Exception): 5 | pass 6 | 7 | class MembershipNoResultsException(Exception): 8 | pass -------------------------------------------------------------------------------- /compair/models/lti_models/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | from .lti_membership_service_oauth_client import LTIMemerbshipServiceOauthClient -------------------------------------------------------------------------------- /compair/models/lti_models/helpers/lti_membership_service_oauth_client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from oauthlib.oauth1 import Client 5 | import base64 6 | import hashlib 7 | 8 | class LTIMemerbshipServiceOauthClient(Client): 9 | def get_oauth_params(self, request): 10 | """Get the basic OAuth parameters to be used in generating a signature. 11 | """ 12 | params = super(LTIMemerbshipServiceOauthClient, self).get_oauth_params(request) 13 | 14 | # unlike parent class we need to include oauth_body_hash even if the body content is empty 15 | content_type = request.headers.get('Content-Type', None) 16 | content_type_eligible = content_type is None or content_type.find('application/x-www-form-urlencoded') < 0 17 | if content_type_eligible and request.body is None: 18 | params.append(('oauth_body_hash', base64.b64encode(hashlib.sha1("".encode('utf-8')).digest()).decode('utf-8'))) 19 | 20 | return params -------------------------------------------------------------------------------- /compair/models/mixins/__init__.py: -------------------------------------------------------------------------------- 1 | from .active_mixin import ActiveMixin 2 | from .attempt_mixin import AttemptMixin 3 | from .default_table_mixin import DefaultTableMixin 4 | from .write_tracking_mixin import WriteTrackingMixin 5 | from .uuid_mixin import UUIDMixin -------------------------------------------------------------------------------- /compair/models/mixins/active_mixin.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.declarative import declared_attr 2 | from sqlalchemy.orm import joinedload 3 | 4 | from compair.core import db, abort 5 | 6 | class ActiveMixin(db.Model): 7 | __abstract__ = True 8 | 9 | @declared_attr 10 | def active(cls): 11 | return db.Column(db.Boolean(), default=True, nullable=False, index=True) 12 | 13 | @classmethod 14 | def get_active_by_uuid_or_404(cls, model_uuid, joinedloads=[], title=None, message=None): 15 | query = cls.query 16 | # load relationships if needed 17 | for load_string in joinedloads: 18 | query.options(joinedload(load_string)) 19 | 20 | model = query.filter_by(uuid=model_uuid).one_or_none() 21 | if model is None or not model.active: 22 | abort(404, title=title, message=message) 23 | return model -------------------------------------------------------------------------------- /compair/models/mixins/create_tracking_mixin.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy.ext.declarative import declared_attr 4 | 5 | from compair.core import db 6 | 7 | class CreateTrackingMixin(db.Model): 8 | __abstract__ = True 9 | 10 | @declared_attr 11 | def created(cls): 12 | return db.Column(db.DateTime, default=datetime.utcnow, nullable=False) 13 | 14 | @declared_attr 15 | def created_user_id(cls): 16 | return db.Column( 17 | db.Integer, 18 | db.ForeignKey('user.id', ondelete="SET NULL"), 19 | nullable=True 20 | ) -------------------------------------------------------------------------------- /compair/models/mixins/default_table_mixin.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.declarative import declared_attr 2 | 3 | from compair.core import db 4 | 5 | class DefaultTableMixin(db.Model): 6 | __abstract__ = True 7 | 8 | default_table_args = { 9 | 'mysql_charset': 'utf8mb4', 10 | 'mysql_engine': 'InnoDB', 11 | 'mysql_collate': 'utf8mb4_unicode_ci' 12 | } 13 | 14 | __table_args__ = default_table_args 15 | 16 | id = db.Column(db.Integer, primary_key=True, nullable=False) -------------------------------------------------------------------------------- /compair/models/mixins/modify_tracking_mixin.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy.ext.declarative import declared_attr 4 | 5 | from compair.core import db 6 | 7 | class ModifyTrackingMixin(db.Model): 8 | __abstract__ = True 9 | 10 | @declared_attr 11 | def modified(cls): 12 | return db.Column(db.DateTime, default=datetime.utcnow, nullable=False) 13 | 14 | @declared_attr 15 | def modified_user_id(cls): 16 | return db.Column( 17 | db.Integer, 18 | db.ForeignKey('user.id', ondelete="SET NULL"), 19 | nullable=True 20 | ) -------------------------------------------------------------------------------- /compair/models/mixins/uuid_mixin.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import base64 3 | 4 | from sqlalchemy.orm import joinedload 5 | 6 | from compair.core import db, abort 7 | 8 | class UUIDMixin(db.Model): 9 | __abstract__ = True 10 | 11 | uuid = db.Column(db.CHAR(22), nullable=False, unique=True, default=lambda: base64.urlsafe_b64encode(uuid.uuid4().bytes).decode('ascii').replace('=', '')) 12 | 13 | @classmethod 14 | def get_by_uuid_or_404(cls, model_uuid, joinedloads=[], title=None, message=None): 15 | query = cls.query 16 | # load relationships if needed 17 | for load_string in joinedloads: 18 | query.options(joinedload(load_string)) 19 | 20 | model = query.filter_by(uuid=model_uuid).one_or_none() 21 | if model is None: 22 | abort(404, title=title, message=message) 23 | return model -------------------------------------------------------------------------------- /compair/models/user_course.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | # sqlalchemy 4 | from sqlalchemy.ext.associationproxy import association_proxy 5 | from sqlalchemy import Enum, func, select, and_, or_ 6 | from sqlalchemy.ext.hybrid import hybrid_property 7 | 8 | from . import * 9 | 10 | from compair.core import db 11 | 12 | class UserCourse(DefaultTableMixin, WriteTrackingMixin): 13 | __tablename__ = 'user_course' 14 | 15 | # table columns 16 | user_id = db.Column(db.Integer, db.ForeignKey("user.id", ondelete="CASCADE"), 17 | nullable=False) 18 | course_id = db.Column(db.Integer, db.ForeignKey("course.id", ondelete="CASCADE"), 19 | nullable=False) 20 | group_id = db.Column(db.Integer, db.ForeignKey('group.id', ondelete="SET NULL"), 21 | nullable=True) 22 | course_role = db.Column(Enum(CourseRole), 23 | nullable=False, index=True) 24 | 25 | # relationships 26 | # user many-to-many course with association user_course 27 | user = db.relationship("User", foreign_keys=[user_id], back_populates="user_courses") 28 | course = db.relationship("Course", back_populates="user_courses") 29 | group = db.relationship("Group", back_populates="user_courses") 30 | 31 | # hybrid and other functions 32 | user_uuid = association_proxy('user', 'uuid') 33 | course_uuid = association_proxy('course', 'uuid') 34 | 35 | @classmethod 36 | def __declare_last__(cls): 37 | super(cls, cls).__declare_last__() 38 | 39 | __table_args__ = ( 40 | # prevent duplicate user in course 41 | db.UniqueConstraint('course_id', 'user_id', name='_unique_user_and_course'), 42 | DefaultTableMixin.default_table_args 43 | ) 44 | -------------------------------------------------------------------------------- /compair/notifications/__init__.py: -------------------------------------------------------------------------------- 1 | from .capture_events import capture_notification_events 2 | from .notification import Notification -------------------------------------------------------------------------------- /compair/notifications/capture_events.py: -------------------------------------------------------------------------------- 1 | from compair.models import AnswerCommentType, UserCourse, CourseRole 2 | from compair.api.answer_comment import on_answer_comment_create, on_answer_comment_modified 3 | from .notification import Notification 4 | 5 | def capture_notification_events(): 6 | # answer comment events 7 | on_answer_comment_create.connect(notification_on_answer_comment_create) 8 | on_answer_comment_modified.connect(notification_on_answer_comment_modified) 9 | 10 | # on_answer_comment_create 11 | def notification_on_answer_comment_create(sender, user, **extra): 12 | answer_comment = extra.get('answer_comment') 13 | 14 | # don't notify on drafts 15 | if answer_comment.draft: 16 | return 17 | 18 | # don't notify on comments to self 19 | if user.id == answer_comment.answer.user_id: 20 | return 21 | 22 | # don't notify on self evaluations 23 | if answer_comment.comment_type == AnswerCommentType.self_evaluation: 24 | return 25 | 26 | Notification.send_new_answer_comment(answer_comment) 27 | 28 | # on_answer_comment_modified 29 | def notification_on_answer_comment_modified(sender, user, **extra): 30 | answer_comment = extra.get('answer_comment') 31 | was_draft = extra.get('was_draft') 32 | 33 | # don't notify on drafts or updates to when wasn't previously a draft 34 | if answer_comment.draft or not was_draft: 35 | return 36 | 37 | # don't notify on comments to self 38 | if user.id == answer_comment.answer.user_id: 39 | return 40 | 41 | # don't notify on self evaluations 42 | if answer_comment.comment_type == AnswerCommentType.self_evaluation: 43 | return 44 | 45 | Notification.send_new_answer_comment(answer_comment) -------------------------------------------------------------------------------- /compair/security.py: -------------------------------------------------------------------------------- 1 | from passlib.apps import custom_app_context 2 | from passlib.context import CryptContext 3 | 4 | default = custom_app_context 5 | 6 | plaintext = CryptContext( 7 | schemes=["plaintext"], 8 | default="plaintext", 9 | ) 10 | -------------------------------------------------------------------------------- /compair/static/img/black-favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/compair/static/img/black-favicon-16x16.png -------------------------------------------------------------------------------- /compair/static/img/black-favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/compair/static/img/black-favicon-32x32.png -------------------------------------------------------------------------------- /compair/static/img/black-favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/compair/static/img/black-favicon.ico -------------------------------------------------------------------------------- /compair/static/img/compair-favicon-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/compair/static/img/compair-favicon-black.png -------------------------------------------------------------------------------- /compair/static/img/compair-favicon-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/compair/static/img/compair-favicon-green.png -------------------------------------------------------------------------------- /compair/static/img/compair-logo-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/compair/static/img/compair-logo-large.png -------------------------------------------------------------------------------- /compair/static/img/compair-logo-scale-flipped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/compair/static/img/compair-logo-scale-flipped.png -------------------------------------------------------------------------------- /compair/static/img/compair-logo-scale.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/compair/static/img/compair-logo-scale.png -------------------------------------------------------------------------------- /compair/static/img/compair-logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/compair/static/img/compair-logo-small.png -------------------------------------------------------------------------------- /compair/static/img/white-scale-icon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/compair/static/img/white-scale-icon-16x16.png -------------------------------------------------------------------------------- /compair/static/img/white-scale-icon-flipped-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/compair/static/img/white-scale-icon-flipped-16x16.png -------------------------------------------------------------------------------- /compair/static/less/compair.less: -------------------------------------------------------------------------------- 1 | 2 | 3 | // VARIABLES & MIXINS 4 | @import 'variables_mixins.less'; 5 | 6 | // OVERALL 7 | @import 'overall.less'; 8 | 9 | // NAVIGATION & BREADCRUMBS 10 | @import 'navbar.less'; 11 | 12 | // LOGIN SCREEN 13 | @import 'login.less'; 14 | 15 | // HOME SCREEN 16 | @import 'home.less'; 17 | 18 | // USERS SCREEN 19 | @import 'users.less'; 20 | 21 | // USER MANAGE SCREEN 22 | @import 'user_manage.less'; 23 | 24 | // COURSE SCREEN 25 | @import 'course.less'; 26 | 27 | // ASSIGNMENT SCREEN 28 | @import 'assignment.less'; 29 | 30 | // EVALUATE SCREEN 31 | @import 'comparison.less'; 32 | 33 | // LTI MANAGE SCREEN 34 | @import 'lti_manage.less'; 35 | 36 | // PDF DIRECTIVES 37 | @import 'rich-content.less'; 38 | 39 | // IMAGE VIEWER DIRECTIVES 40 | @import 'image-viewer.less'; 41 | 42 | // Toastr customizations 43 | @import 'toast.less'; 44 | 45 | // MOBILE STYLE 46 | @import 'mobile.less'; 47 | 48 | // PRINT STYLES 49 | @import 'print.less'; -------------------------------------------------------------------------------- /compair/static/less/home.less: -------------------------------------------------------------------------------- 1 | 2 | 3 | /***** HOME SCREEN *****/ 4 | 5 | .home-screen { 6 | 7 | .compair-logo { 8 | margin-top: 1.7em; 9 | } 10 | 11 | .search-courses { 12 | margin-top: 1.4em; 13 | 14 | .dropdown-menu > li > a { 15 | cursor: pointer; 16 | } 17 | 18 | } 19 | 20 | /* div surrounding list of courses */ 21 | .course-list { 22 | 23 | /* class for div surrounding each course */ 24 | .each-course { 25 | 26 | .item-list(); 27 | 28 | .course-metadata-list { 29 | .metadata(); 30 | margin-top: 2.5em; 31 | } 32 | 33 | }//closes each-course 34 | 35 | /* add border to first course */ 36 | .first-child { 37 | border-top: 1px solid @border-gray; 38 | } 39 | 40 | }//closes course-list 41 | 42 | }//closes home-screen 43 | 44 | -------------------------------------------------------------------------------- /compair/static/less/image-viewer.less: -------------------------------------------------------------------------------- 1 | .image-viewer { 2 | 3 | margin-top: .5em; 4 | padding: 1em; 5 | border: 1px solid #ccc; 6 | border-radius: 4px; 7 | background: #fff; 8 | 9 | .btn { 10 | margin-top: 0.5em; 11 | margin-bottom: 0.5em; 12 | margin-right: 0.5em; 13 | margin-left: 0em; 14 | } 15 | 16 | } 17 | 18 | -------------------------------------------------------------------------------- /compair/static/less/login.less: -------------------------------------------------------------------------------- 1 | 2 | 3 | /***** LOGIN SCREEN *****/ 4 | 5 | .login-screen { 6 | margin: 15px; 7 | 8 | fieldset { 9 | min-height: 200px; 10 | } 11 | 12 | /* application title */ 13 | h1 { 14 | margin-top: .2em; 15 | } 16 | 17 | /* override green input showing success */ 18 | .has-success .form-control { 19 | 20 | border-color: #ccc; 21 | 22 | &:focus { 23 | box-shadow: 0 1px 1px #ccc; 24 | } 25 | 26 | }//closes form-control 27 | 28 | /* add additional spacing to dt default */ 29 | dt { 30 | margin: .75em 0 .1em 0; 31 | } 32 | 33 | /* change default alert margin for bottom placement */ 34 | .alert { 35 | margin: 1em 0 0 0; 36 | } 37 | 38 | }//closes login-screen 39 | 40 | -------------------------------------------------------------------------------- /compair/static/less/lti_manage.less: -------------------------------------------------------------------------------- 1 | 2 | /***** USER MANAGE SCREEN *****/ 3 | 4 | .lti-contexts-screen { 5 | 6 | .search-contexts { 7 | margin-top: 1.7em; 8 | } 9 | }//closes user-manage-screen 10 | -------------------------------------------------------------------------------- /compair/static/less/navbar.less: -------------------------------------------------------------------------------- 1 | 2 | 3 | /***** NAVIGATION & BREADCRUMBS *****/ 4 | 5 | /* outermost containing div for the navigation */ 6 | .navbar { 7 | 8 | margin-bottom: 0.5em; 9 | 10 | /* left-hand logo */ 11 | .navbar-brand { 12 | padding: 5px 15px 0 15px; 13 | } 14 | 15 | }//cloases navbar 16 | 17 | /* ul for breadcrumbs */ 18 | .breadcrumb-trail { 19 | 20 | margin: 0 0 1em 1em; 21 | padding: 0; 22 | color: @breadcrumb-gray; 23 | 24 | /* breadcrumbs */ 25 | li { 26 | display: inline-block; 27 | list-style: none; 28 | } 29 | 30 | /* class for the last li */ 31 | .last-crumb { 32 | color: @breadcrumb-gray; 33 | } 34 | 35 | }//closes breadcrumb-trail 36 | 37 | /* for impersonation visual cue */ 38 | .impersonation_cue { 39 | background-color: #f2dede; 40 | color: #a94442; 41 | position: fixed; 42 | left: 0px; 43 | bottom: 0px; 44 | width: 100%; 45 | height: 40px; 46 | line-height: 40px; 47 | z-index: 999; 48 | font-weight: bold; 49 | text-align: center; 50 | } 51 | 52 | /* demo message for demo site */ 53 | .demo-msg { 54 | margin: 1em auto; 55 | width: 60%; 56 | } 57 | -------------------------------------------------------------------------------- /compair/static/less/rich-content.less: -------------------------------------------------------------------------------- 1 | .each-attachment-content, .each-embeddable-content { 2 | font-size: 0.9em; 3 | 4 | audio.content-item { 5 | width: 100%; 6 | } 7 | 8 | img.content-item { 9 | max-width: 100%; 10 | } 11 | 12 | .twttr-content-item { 13 | height: 100%; 14 | } 15 | } 16 | 17 | .modal-content { 18 | .each-embeddable-content { 19 | border-left: none; 20 | padding-left: 0em; 21 | } 22 | } -------------------------------------------------------------------------------- /compair/static/less/toast.less: -------------------------------------------------------------------------------- 1 | 2 | 3 | /***** NOTIFICATION MESSAGES *****/ 4 | 5 | 6 | /* Toastr customizations 7 | toastr's default placements are all either to the top of the screen or to the bottom of the screen, 8 | this adds additional placements to the center of the screen */ 9 | #toast-container.toast-top-full-width-opaque { 10 | 11 | top: 4%; 12 | right: 0; 13 | width: 100%; 14 | 15 | > div { 16 | margin: auto; 17 | width: 60%; 18 | max-width: 400px; 19 | opacity: 1; 20 | } 21 | 22 | }//closes toast-top-full-width-opaque 23 | -------------------------------------------------------------------------------- /compair/static/less/user_manage.less: -------------------------------------------------------------------------------- 1 | 2 | /***** USER MANAGE SCREEN *****/ 3 | 4 | .user-manage-screen { 5 | 6 | .search-courses { 7 | margin-top: 1.7em; 8 | 9 | .dropdown-menu > li > a { 10 | cursor: pointer; 11 | } 12 | 13 | } 14 | 15 | }//closes user-manage-screen 16 | 17 | -------------------------------------------------------------------------------- /compair/static/less/users.less: -------------------------------------------------------------------------------- 1 | 2 | /***** USERS SCREEN *****/ 3 | 4 | .users-screen { 5 | 6 | .search-users { 7 | margin-top: 1.7em; 8 | } 9 | 10 | }//closes users-screen 11 | 12 | -------------------------------------------------------------------------------- /compair/static/lib_extension/ckeditor/plugins/autolink/plugin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. 3 | * For licensing, see LICENSE.md or http://ckeditor.com/license 4 | */ 5 | 6 | ( function() { 7 | 'use strict'; 8 | 9 | // Regex by Imme Emosol. 10 | var validUrlRegex = /^(https?|ftp):\/\/(-\.)?([^\s\/?\.#-]+\.?)+(\/[^\s]*)?[^\s\.,]$/ig, 11 | doubleQuoteRegex = /"/g; 12 | 13 | CKEDITOR.plugins.add( 'autolink', { 14 | requires: 'clipboard', 15 | 16 | init: function( editor ) { 17 | editor.on( 'paste', function( evt ) { 18 | var data = evt.data.dataValue; 19 | 20 | if ( evt.data.dataTransfer.getTransferType( editor ) == CKEDITOR.DATA_TRANSFER_INTERNAL ) { 21 | return; 22 | } 23 | 24 | // If we found "<" it means that most likely there's some tag and we don't want to touch it. 25 | if ( data.indexOf( '<' ) > -1 ) { 26 | return; 27 | } 28 | 29 | // #13419 30 | data = data.replace( validUrlRegex , '$&' ); 31 | 32 | // If link was discovered, change the type to 'html'. This is important e.g. when pasting plain text in Chrome 33 | // where real type is correctly recognized. 34 | if ( data != evt.data.dataValue ) { 35 | evt.data.type = 'html'; 36 | } 37 | 38 | evt.data.dataValue = data; 39 | } ); 40 | } 41 | } ); 42 | } )(); -------------------------------------------------------------------------------- /compair/static/lib_extension/ckeditor/plugins/combinedmath/icons/combinedmath.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/compair/static/lib_extension/ckeditor/plugins/combinedmath/icons/combinedmath.png -------------------------------------------------------------------------------- /compair/static/lib_extension/ckeditor/plugins/combinedmath/iframe/mathjax.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | 14 | 15 |

16 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /compair/static/modules/answer/answer-modal-partial.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /compair/static/modules/assignment/preview-inline-template-comparison.html: -------------------------------------------------------------------------------- 1 |

2 | Preview Comparison 3 |

-------------------------------------------------------------------------------- /compair/static/modules/assignment/preview-inline-template-self-eval.html: -------------------------------------------------------------------------------- 1 |

2 | Preview Self-Evaluation 3 |

-------------------------------------------------------------------------------- /compair/static/modules/classlist/classlist-enrol-partial.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 |
6 | 7 | 10 |
11 | 12 |
13 | 17 |
18 |
19 |
20 | 21 |
22 |
23 |
24 |
-------------------------------------------------------------------------------- /compair/static/modules/classlist/classlist-import-results-partial.html: -------------------------------------------------------------------------------- 1 |

Results

2 |

« Back to Manage Users?

3 |
4 |

Users not enrolled or created due to errors

5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
{{header}}
{{invalid.user.username}}{{invalid.user.student_number}}{{invalid.user.firstname}}{{invalid.user.lastname}}{{invalid.user.email}}{{invalid.message}}
18 |
19 | -------------------------------------------------------------------------------- /compair/static/modules/comment/answer-content.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

{{ name }} 4 | 5 | 7 | Score: {{answer.score.normalized_score|number:0}}% 8 | 9 | 10 |

11 | 12 | 13 |
(This answer has been deleted.)
14 |
15 |
-------------------------------------------------------------------------------- /compair/static/modules/comment/answer.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | answered on {{ answer.submission_date | amDateFormat: 'MMM D @ h:mm a'}}: 4 | 5 |
6 |

(No answer has been submitted by this student.)

7 | -------------------------------------------------------------------------------- /compair/static/modules/comment/comment-answer-modal-partial.html: -------------------------------------------------------------------------------- 1 | 2 |

Add PrivatePublic Feedback

3 |

Edit PublicPrivate Feedback

4 | -------------------------------------------------------------------------------- /compair/static/modules/comment/comment-assignment-modal-partial.html: -------------------------------------------------------------------------------- 1 | 2 |

Add Comment

3 |

Edit Comment

4 | -------------------------------------------------------------------------------- /compair/static/modules/common/common-module.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('ubc.ctlt.compair.common', []); 4 | 5 | module.filter("emptyToEnd", function () { 6 | return function (array, key, reversed) { 7 | if(!angular.isArray(array)) return; 8 | reversed = reversed || false; 9 | var present = array.filter(function (item) { 10 | return item[key]; 11 | }); 12 | var empty = array.filter(function (item) { 13 | return !item[key] 14 | }); 15 | return reversed ? empty.concat(present) : present.concat(empty); 16 | }; 17 | }); 18 | 19 | // based on https://gist.github.com/yrezgui/5653591 20 | module.filter('filesize', function () { 21 | var units = [ 22 | 'bytes', 23 | 'KB', 24 | 'MB', 25 | 'GB', 26 | 'TB', 27 | 'PB' 28 | ]; 29 | 30 | return function( bytes, precision ) { 31 | precision = precision || 0; 32 | 33 | if (isNaN(parseFloat(bytes)) || !isFinite(bytes)) { 34 | return; 35 | } 36 | 37 | var unit = 0; 38 | while (bytes >= 1024 && unit < units.length) { 39 | bytes /= 1024; 40 | unit++; 41 | } 42 | return bytes.toFixed(precision) + units[unit]; 43 | }; 44 | }); 45 | 46 | module.filter('encodeURIComponent', function () { 47 | return window.encodeURIComponent; 48 | }); 49 | 50 | })(); 51 | -------------------------------------------------------------------------------- /compair/static/modules/common/demo-warning-template.html: -------------------------------------------------------------------------------- 1 |
2 | 7 |
-------------------------------------------------------------------------------- /compair/static/modules/common/element-button-template.html: -------------------------------------------------------------------------------- 1 | 7 | {{ button.label }} 8 | -------------------------------------------------------------------------------- /compair/static/modules/common/element-metadata-template.html: -------------------------------------------------------------------------------- 1 |
  • 2 | 11 | 17 | 20 |
  • -------------------------------------------------------------------------------- /compair/static/modules/common/element-text-template.html: -------------------------------------------------------------------------------- 1 |

    2 | 3 |

    -------------------------------------------------------------------------------- /compair/static/modules/common/form-field-with-feedback-template.html: -------------------------------------------------------------------------------- 1 |
    4 | 5 | 6 |
    7 | -------------------------------------------------------------------------------- /compair/static/modules/common/impersonation-visual-cue-template.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 | You are viewing ComPAIR as 5 | {{impersonate_as_user_name}} 6 | someone else 7 |
    8 |
    9 | 12 |
    13 |
    14 |
    -------------------------------------------------------------------------------- /compair/static/modules/common/logo-directive.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | angular.module('ubc.ctlt.compair.common').constant('logoSettings', { 5 | path: 'img/' 6 | }); 7 | 8 | angular.module('ubc.ctlt.compair.common').directive('compairLogo', 9 | ['logoSettings', 10 | function (logoSettings) { 11 | return { 12 | restrict: 'E', 13 | scope: { 14 | type: '=', 15 | }, 16 | template: '', 17 | link: function (scope, element, attrs) { 18 | scope.alt = scope.type == 'scale' ? "ComPAIR Scale" : "ComPAIR Logo"; 19 | scope.logo = logoSettings.path + 'compair-logo-'+scope.type+'.png'; 20 | } 21 | }; 22 | } 23 | ]); 24 | 25 | })(); 26 | -------------------------------------------------------------------------------- /compair/static/modules/common/modal-cancel-button-directive.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | angular.module('ubc.ctlt.compair.common').directive('modalCancelButton', 4 | function () { 5 | return { 6 | restrict: 'E', 7 | scope: { 8 | modalInstance: '=' 9 | }, 10 | template: '
    ' + 11 | '

    ' + 12 | 'Cancel ' + 13 | '

    '+ 14 | '
    ', 15 | link: function (scope, element, attrs) { 16 | scope.cancel = function() { 17 | scope.modalInstance.dismiss(); 18 | }; 19 | } 20 | }; 21 | }); 22 | 23 | })(); 24 | -------------------------------------------------------------------------------- /compair/static/modules/common/timer-module.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('ubc.ctlt.compair.common.timer', 4 | [ 5 | 'ngResource' 6 | ] 7 | ); 8 | 9 | /***** Providers *****/ 10 | module.factory( 11 | 'TimerResource', 12 | ['$resource', 13 | function($resource) 14 | { 15 | return $resource('/api/timer'); 16 | } 17 | ]); 18 | 19 | })(); -------------------------------------------------------------------------------- /compair/static/modules/comparison/comparison-modal-partial.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /compair/static/modules/comparison/comparison-self_evaluation-partial.html: -------------------------------------------------------------------------------- 1 | 2 |
    3 |

    Self-Evaluation

    4 | 5 |
    -------------------------------------------------------------------------------- /compair/static/modules/course/course-duplicate-modal-partial.html: -------------------------------------------------------------------------------- 1 |

    Cancel

    2 | 3 | -------------------------------------------------------------------------------- /compair/static/modules/criterion/criterion-modal-partial.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /compair/static/modules/group/group-modal-partial.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /compair/static/modules/group/groups-manage-modal-partial.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

    Manage Group Names

    4 |

    Edit or delete existing group names, provided they have not already been used in a group-based assignment. Deleting a group will not delete any students assigned to the group. These students will simply be assigned to no group.

    5 | 6 |
    7 | 8 | 9 | 10 | 11 | 14 | 15 | 16 | 17 | 18 | 24 | 25 | 26 | 27 |
    Actions 12 | Group Name 13 |
    19 | Edit 20 |  |  21 | Delete 22 |   —   23 | {{group.name}}
    28 |
    29 |
    30 |
    31 | 32 |
    -------------------------------------------------------------------------------- /compair/static/modules/lti/lti-setup-partial.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/compair/static/modules/lti/lti-setup-partial.html -------------------------------------------------------------------------------- /compair/static/modules/rich-content/hljs-directive.js: -------------------------------------------------------------------------------- 1 | // Directives to integrate highlight js into AngularJS 2 | (function() { 3 | 4 | var module = angular.module('ubc.ctlt.compair.rich.content.highlightjs', []); 5 | 6 | /***** Directives *****/ 7 | module.directive('hljs', function() { 8 | return function(scope, el, attrs, ctrl) { 9 | scope.$watch(attrs.hljs, function() { 10 | $(el[0]).find('pre code').each(function(i, block) { 11 | hljs.highlightBlock(block); 12 | }); 13 | }); 14 | }; 15 | }); 16 | 17 | // End anonymous function 18 | })(); 19 | -------------------------------------------------------------------------------- /compair/static/modules/rich-content/mathjax-directive.js: -------------------------------------------------------------------------------- 1 | // Directives to integrate mathjax into AngularJS 2 | // 3 | // Code from: 4 | // https://github.com/ViktorQvarfordt/AngularJS-MathJax-Directive 5 | // Unfortunately, it's not Bower enabled, so have to manually include it 6 | (function() { 7 | 8 | var module = angular.module('ubc.ctlt.compair.rich.content.mathjax', []); 9 | 10 | /***** Directives *****/ 11 | // add the css and elements required to show bootstrap's validation feedback 12 | // requires the parameter form-control, which passes in the input being validated 13 | module.directive('mathjax', function() { 14 | return function(scope, el, attrs, ctrl) { 15 | scope.$watch(attrs.mathjax, function() { 16 | MathJax.Hub.Queue(['Typeset', MathJax.Hub, el[0]]); 17 | }); 18 | }; 19 | }); 20 | 21 | // End anonymous function 22 | })(); 23 | -------------------------------------------------------------------------------- /compair/static/modules/rich-content/rich-content-attachment-modal-template.html: -------------------------------------------------------------------------------- 1 | 2 |

    {{downloadName}}

    3 |

    Attachment

    4 | 5 |
    6 | 7 |
    -------------------------------------------------------------------------------- /compair/static/modules/rich-content/rich-content-embeddable-modal-template.html: -------------------------------------------------------------------------------- 1 | 2 |

    {{downloadName}}

    3 |

    Attachment

    4 | 5 |
    6 | 7 |
    -------------------------------------------------------------------------------- /compair/static/modules/rich-content/rich-content-embeddable-template.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |
    5 | 6 |
    7 |
    8 | 9 |
    10 |
    11 | 12 |
    13 |
    14 |
    15 |
    16 |
    17 |
    18 |
    19 |
    20 |
    21 |
    22 |
    23 |
    24 |
    25 |
    26 |
    27 |
    -------------------------------------------------------------------------------- /compair/static/modules/rich-content/rich-content-template.html: -------------------------------------------------------------------------------- 1 |
    2 | 3 |
    4 | {{attachmentContent.length}} Attachments: 5 |
    6 |

    7 | 9 | Open in Pop-up 10 | or 11 |
    12 | 13 | 14 | 15 | Show below 16 | 17 |

    18 |

    19 | 20 | Download 21 | 22 |

    23 | 24 | 25 |
    26 | 27 |
    28 |
    29 |
    30 |
    -------------------------------------------------------------------------------- /compair/static/modules/rich-content/twttr-directive.js: -------------------------------------------------------------------------------- 1 | // Directives to integrate highlight js into AngularJS 2 | (function() { 3 | 4 | var module = angular.module('ubc.ctlt.compair.rich.content.twttr', []); 5 | 6 | /***** Directives *****/ 7 | module.directive('twttr', 8 | ["$timeout", 9 | function($timeout) 10 | { 11 | return function(scope, el, attrs, ctrl) { 12 | scope.load = function() { 13 | $timeout(function () { 14 | twttr.widgets.load(); 15 | }, 1); 16 | } 17 | 18 | scope.$watch(attrs.twttr, function() { 19 | scope.load(); 20 | }); 21 | scope.load(); 22 | }; 23 | } 24 | ]); 25 | 26 | // End anonymous function 27 | })(); 28 | -------------------------------------------------------------------------------- /compair/static/modules/route/route-error-partial.html: -------------------------------------------------------------------------------- 1 |
    2 | 9 |
    -------------------------------------------------------------------------------- /compair/static/modules/student_view/student-view-modal-partial.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /compair/static/modules/toaster/toaster-module.js: -------------------------------------------------------------------------------- 1 | // Extends AngularJS-Toaster with default behaviours for info, success, 2 | // warning, and error messages. The default AngularJS-Toaster is 'toaster', the 3 | // customized version in this module is 'Toaster'. E.g. I want error messages 4 | // to stay on the screen instead of fading away, so I add a error() method with 5 | // 0 ms fade (0 disables it), and it can now be called with Toaster.error() 6 | 7 | (function() { 8 | 9 | var module = angular.module('ubc.ctlt.compair.toaster', 10 | [ 11 | 'toaster' 12 | ] 13 | ); 14 | 15 | /***** Providers *****/ 16 | module.factory('Toaster', ["toaster", function(toaster) { 17 | // should be short, so don't need that much time 18 | toaster.success = function(title, msg) { 19 | this.pop("success", title, msg, 5000); 20 | }; 21 | // give users more time to read these 22 | toaster.info = function(title, msg) { 23 | this.pop("info", title, msg, 10000); 24 | }; 25 | toaster.warning = function(title, msg) { 26 | this.pop("warning", title, msg, 10000); 27 | }; 28 | toaster.error = function(title, msg) { 29 | this.pop("error", title, msg, 10000); 30 | }; 31 | return toaster; 32 | }]); 33 | 34 | /***** Controllers *****/ 35 | 36 | // End anonymous function 37 | })(); 38 | -------------------------------------------------------------------------------- /compair/static/modules/user/user-create-partial.html: -------------------------------------------------------------------------------- 1 |
    2 |

    Add User

    3 |

    You can create individual users in the application here. To create and enrol multiple users in a course at the same time (for courses with manual management of users), it is faster to use the "Import Users" option found inside an individual course.

    4 | 5 |
    6 | -------------------------------------------------------------------------------- /compair/static/modules/user/user-edit-partial.html: -------------------------------------------------------------------------------- 1 |
    2 |

    Edit User

    3 | 4 |
    5 | -------------------------------------------------------------------------------- /compair/static/modules/user/user-password-modal-partial.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /compair/static/test/config/karma-e2e-dev.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function (config) { 2 | config.set({ 3 | basePath: '../', 4 | 5 | files: [ 6 | 'e2e/*.js' 7 | ], 8 | 9 | frameworks: ['ng-scenario', 'jasmine'], 10 | 11 | browsers: ['PhantomJS'], 12 | 13 | singleRun: true, 14 | 15 | proxies: { 16 | '/': 'http://127.0.0.1:5001/' 17 | }, 18 | 19 | urlRoot: '/_karma_/', 20 | 21 | reporters: ['dots', 'junit'], 22 | 23 | junitReporter: { 24 | outputFile: 'test_out/e2e.xml', 25 | suite: 'e2e' 26 | } 27 | }); 28 | }; -------------------------------------------------------------------------------- /compair/static/test/config/karma-e2e.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function (config) { 2 | config.set({ 3 | basePath: '../', 4 | 5 | files: [ 6 | 'e2e/*.js' 7 | ], 8 | 9 | frameworks: ['ng-scenario', 'jasmine'], 10 | 11 | browsers: ['PhantomJS'], 12 | 13 | singleRun: true, 14 | 15 | proxies: { 16 | '/': 'http://127.0.0.1:5000/' 17 | }, 18 | 19 | urlRoot: '/_karma_/', 20 | 21 | reporters: ['dots', 'junit'], 22 | 23 | junitReporter: { 24 | outputFile: 'test_out/e2e.xml', 25 | suite: 'e2e' 26 | } 27 | }); 28 | }; -------------------------------------------------------------------------------- /compair/static/test/config/karma.conf.js: -------------------------------------------------------------------------------- 1 | process.env.CHROME_BIN = require('puppeteer').executablePath() 2 | 3 | module.exports = function (config) { 4 | var wiredep = require('wiredep'); 5 | var bowerFiles = wiredep({devDependencies: true, cwd: __dirname + '/../../../..'})['js']; 6 | config.set({ 7 | basePath: '../../', 8 | 9 | preprocessors: { 10 | 'modules/**/*.html': ['ng-html2js'] 11 | }, 12 | 13 | files: bowerFiles.concat([ 14 | 'modules/**/*-module.js', 15 | 'modules/**/*.js', 16 | 'modules/**/*.html', 17 | 'compair-config.js', 18 | 'test/helpers/*.js' 19 | ]), 20 | 21 | frameworks: ['jasmine'], 22 | 23 | autoWatch: true, 24 | 25 | browsers: ['ChromeHeadless'], 26 | 27 | junitReporter: { 28 | outputFile: 'test_out/unit.xml', 29 | suite: 'unit' 30 | } 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /compair/static/test/config/protractor.js: -------------------------------------------------------------------------------- 1 | exports.config = { 2 | seleniumAddress: 'http://localhost:4444/wd/hub', 3 | specs: ['../specs/**/*spec.js'], 4 | jasmineNodeOpts: { 5 | showColors: true 6 | } 7 | }; -------------------------------------------------------------------------------- /compair/static/test/e2e/e2e_dsl_addon.js: -------------------------------------------------------------------------------- 1 | /* 2 | * angularjs e2e dsl doesn't work on contenteditable elements 3 | */ 4 | angular.scenario.dsl('contenteditable', function () { 5 | var chain = {}; 6 | chain.enter = function (value) { 7 | return this.addFutureAction("contenteditable '" + this.name + "' enter '" + value + "'", function ($window, $document, done) { 8 | var contenteditable = $document.elements(this.name); 9 | contenteditable.text(value); 10 | contenteditable.trigger('change'); 11 | done(); 12 | }); 13 | }; 14 | return function (name) { 15 | this.name = name; 16 | return chain; 17 | }; 18 | }); 19 | 20 | /** 21 | * Usage: confirmOK() sets window.confirm to return true when it is called in your application 22 | */ 23 | angular.scenario.dsl('confirmOK', function() { 24 | return function() { 25 | return this.addFutureAction('monkey patch window.confirm to return true', function($window, $document, done) { 26 | $window.confirm = function() {return true;}; 27 | done(); 28 | }); 29 | }; 30 | }); 31 | 32 | 33 | /** 34 | * Usage: confirmCancel() sets window.confirm to return false when it is called in your application 35 | */ 36 | angular.scenario.dsl('confirmCancel', function() { 37 | return function() { 38 | return this.addFutureAction('monkey patch window.confirm to return false', function($window, $document, done) { 39 | $window.confirm = function() {return false;}; 40 | done(); 41 | }); 42 | }; 43 | }); -------------------------------------------------------------------------------- /compair/static/test/e2e/runner.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | End2end Test Runner 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /compair/static/test/env.js: -------------------------------------------------------------------------------- 1 | // Common configuration files with defaults plus overrides from environment vars 2 | var webServerDefaultHost = 'localhost'; 3 | var webServerDefaultPort = 8700; 4 | 5 | module.exports = { 6 | // The address of a running selenium server. 7 | seleniumAddress: 8 | (process.env.SELENIUM_URL || 'http://localhost:4444/wd/hub'), 9 | 10 | // Capabilities to be passed to the webdriver instance. 11 | capabilities: { 12 | 'browserName': 13 | (process.env.TEST_BROWSER_NAME || 'chrome'), 14 | 'version': 15 | (process.env.TEST_BROWSER_VERSION || 'ANY') 16 | }, 17 | 18 | // Default http port to host the web server 19 | webServerDefaultPort: webServerDefaultPort, 20 | 21 | // A base URL for your application under test. 22 | baseUrl: 23 | 'http://' + (process.env.HTTP_HOST || webServerDefaultHost) + 24 | ':' + (process.env.HTTP_PORT || webServerDefaultPort) + '/app/' 25 | 26 | }; 27 | -------------------------------------------------------------------------------- /compair/static/test/factories/answer_factory.js: -------------------------------------------------------------------------------- 1 | var objectAssign = require('object-assign'); 2 | 3 | var answerTemplate = { 4 | "id": null, 5 | "course_id": null, 6 | "assignment_id": null, 7 | "user_id": null, 8 | "group_id": null, 9 | "content": null, 10 | "comment_count": 0, 11 | "private_comment_count": 0, 12 | "public_comment_count": 0, 13 | "file": null, 14 | "score": null, 15 | "user": null, 16 | "group": null, 17 | "top_answer": false, 18 | "created": "Fri, 22 Apr 2016 18:33:34 -0000", 19 | } 20 | 21 | function AnswerFactory() {}; 22 | 23 | AnswerFactory.prototype.generateAnswer = function (id, course_id, assignment_id, user, parameters) { 24 | var newAnswer = objectAssign({}, answerTemplate, parameters); 25 | newAnswer.id = id; 26 | newAnswer.course_id = course_id; 27 | newAnswer.assignment_id = assignment_id; 28 | newAnswer.user_id = user.id; 29 | newAnswer.user = { 30 | "id": user.id, 31 | "avatar": user.avatar, 32 | "displayname": user.displayname 33 | } 34 | 35 | return newAnswer; 36 | }; 37 | 38 | AnswerFactory.prototype.generateGroupAnswer = function (id, course_id, assignment_id, group, parameters) { 39 | var newAnswer = objectAssign({}, answerTemplate, parameters); 40 | newAnswer.id = id; 41 | newAnswer.course_id = course_id; 42 | newAnswer.assignment_id = assignment_id; 43 | newAnswer.group_id = group.id; 44 | newAnswer.group = { 45 | "id": group.id, 46 | "avatar": group.avatar, 47 | "name": group.name 48 | } 49 | 50 | return newAnswer; 51 | }; 52 | 53 | module.exports = AnswerFactory; 54 | -------------------------------------------------------------------------------- /compair/static/test/factories/assignment_status_factory.js: -------------------------------------------------------------------------------- 1 | var objectAssign = require('object-assign'); 2 | var deepcopy = require('deepcopy'); 3 | 4 | var assignmentStatusTemplate = { 5 | "answers": { 6 | "has_draft": false, 7 | "draft_ids": [], 8 | "answered": false, 9 | "feedback": false, 10 | "count": 0 11 | }, 12 | "comparisons": { 13 | "available": true, 14 | "has_draft": false, 15 | "count": 0, 16 | "left": 3, 17 | "self_evaluation_draft": false 18 | } 19 | } 20 | 21 | function AssignmentStatusFactory() {}; 22 | 23 | AssignmentStatusFactory.prototype.generateAssignmentStatus = function (assignmentId, user, parameters) { 24 | var newAssignmentStatus = { 25 | "answers": objectAssign({}, assignmentStatusTemplate.answers, parameters.answers), 26 | "comparisons": objectAssign({}, assignmentStatusTemplate.comparisons, parameters.comparisons) 27 | }; 28 | newAssignmentStatus.assignment_id = assignmentId; 29 | newAssignmentStatus.user_id = user.id 30 | newAssignmentStatus.user = { 31 | "id": user.id, 32 | "avatar": user.avatar, 33 | "displayname": user.displayname 34 | } 35 | 36 | return newAssignmentStatus; 37 | }; 38 | 39 | module.exports = AssignmentStatusFactory; 40 | -------------------------------------------------------------------------------- /compair/static/test/factories/comparison_example_factory.js: -------------------------------------------------------------------------------- 1 | var objectAssign = require('object-assign'); 2 | var deepcopy = require('deepcopy'); 3 | 4 | var comparisonExampleTemplate = { 5 | "id": null, 6 | "answer1": null, 7 | "answer1_id": null, 8 | "answer2": null, 9 | "answer2_id": null, 10 | "assignment_id": null, 11 | "course_id": null, 12 | "created": "Wed, 17 Aug 2016 16:38:27 -0000", 13 | "modified": "Wed, 17 Aug 2016 16:38:27 -0000" 14 | } 15 | 16 | function ComparisonExampleFactory() {}; 17 | 18 | ComparisonExampleFactory.prototype.generateComparisonExample = function (id, course_id, assignment_id, answer1, answer2, parameters) { 19 | var newComparisonExample = objectAssign({}, comparisonExampleTemplate, parameters); 20 | newComparisonExample.id = id; 21 | newComparisonExample.course_id = course_id; 22 | newComparisonExample.assignment_id = assignment_id; 23 | newComparisonExample.answer1_id = answer1.id; 24 | newComparisonExample.answer1 = deepcopy(answer1); 25 | newComparisonExample.answer2_id = answer2.id; 26 | newComparisonExample.answer2 = deepcopy(answer2); 27 | 28 | return newComparisonExample; 29 | }; 30 | 31 | module.exports = ComparisonExampleFactory; 32 | -------------------------------------------------------------------------------- /compair/static/test/factories/course_factory.js: -------------------------------------------------------------------------------- 1 | var objectAssign = require('object-assign'); 2 | 3 | var courseTemplate = { 4 | "id": null, 5 | "name": null, 6 | "year": 2015, 7 | "term": null, 8 | "sandbox": false, 9 | "start_time": null, 10 | "end_time": null, 11 | "available": true, 12 | "start_date": "Fri, 02 Jan 2015 00:00:00 -0000", 13 | "end_date": null, 14 | "assignment_count": 0, 15 | "student_assignment_count": 0, 16 | "student_count": 0, 17 | "modified": "Sun, 11 Jan 2015 08:44:46 -0000", 18 | "created": "Sun, 11 Jan 2015 08:44:46 -0000", 19 | } 20 | 21 | function CourseFactory() {}; 22 | 23 | CourseFactory.prototype.generateCourse = function (id, parameters) { 24 | var newCourse = objectAssign({}, courseTemplate, parameters); 25 | newCourse.id = id; 26 | 27 | return newCourse; 28 | }; 29 | 30 | module.exports = CourseFactory; 31 | -------------------------------------------------------------------------------- /compair/static/test/factories/criterion_factory.js: -------------------------------------------------------------------------------- 1 | var objectAssign = require('object-assign'); 2 | 3 | var criterionTemplate = { 4 | "id": null, 5 | "user_id": null, 6 | "name": null, 7 | "description": null, 8 | "default": true, 9 | "public": false, 10 | "compared": false, 11 | "created": "Mon, 18 Apr 2016 17:38:23 -0000", 12 | "modified": "Mon, 18 Apr 2016 17:38:23 -0000", 13 | } 14 | 15 | function CriterionFactory() {}; 16 | 17 | CriterionFactory.prototype.generateCriterion = function (id, user_id, parameters) { 18 | var newCriterion = objectAssign({}, criterionTemplate, parameters); 19 | newCriterion.id = id; 20 | newCriterion.user_id = user_id; 21 | 22 | return newCriterion; 23 | }; 24 | 25 | CriterionFactory.prototype.getDefaultCriterion = function () { 26 | return { 27 | "id": "abcABC123-abcABC123_Z", 28 | "user_id": "abcABC123-abcABC123_Z", 29 | "name": "Which is better?", 30 | "description": "

    Choose the response that you think is the better of the two.

    ", 31 | "default": true, 32 | "public": true, 33 | "compared": false, 34 | "created": "Mon, 18 Apr 2016 17:38:23 -0000", 35 | "modified": "Mon, 18 Apr 2016 17:38:23 -0000", 36 | }; 37 | }; 38 | 39 | module.exports = CriterionFactory; 40 | -------------------------------------------------------------------------------- /compair/static/test/factories/group_factory.js: -------------------------------------------------------------------------------- 1 | var objectAssign = require('object-assign'); 2 | 3 | var groupTemplate = { 4 | "id": null, 5 | "course_id": null, 6 | "name": null, 7 | "created": "Mon, 18 Apr 2016 17:38:23 -0000", 8 | "modified": "Mon, 18 Apr 2016 17:38:23 -0000", 9 | } 10 | 11 | function GroupFactory() {}; 12 | 13 | GroupFactory.prototype.generateGroup = function (id, course_id, parameters) { 14 | var newGroup = objectAssign({}, groupTemplate, parameters); 15 | newGroup.id = id; 16 | newGroup.course_id = course_id; 17 | 18 | return newGroup; 19 | }; 20 | 21 | module.exports = GroupFactory; 22 | -------------------------------------------------------------------------------- /compair/static/test/factories/lti_consumer_factory.js: -------------------------------------------------------------------------------- 1 | var objectAssign = require('object-assign'); 2 | 3 | var ltiConsumerTemplate = { 4 | "id": null, 5 | "oauth_consumer_key": null, 6 | "oauth_consumer_secret": null, 7 | "active": true, 8 | "global_unique_identifier_param": null, 9 | "custom_param_regex_sanitizer": null, 10 | "student_number_param": null, 11 | "created": "Mon, 18 Apr 2016 17:38:23 -0000", 12 | "modified": "Mon, 18 Apr 2016 17:38:23 -0000" 13 | } 14 | 15 | function LTIConsumerFactory() {}; 16 | 17 | LTIConsumerFactory.prototype.generateConsumer = function (id, oauth_consumer_key, oauth_consumer_secret, parameters) { 18 | var newConsumer = objectAssign({}, ltiConsumerTemplate, parameters); 19 | newConsumer.id = id; 20 | newConsumer.oauth_consumer_key = oauth_consumer_key; 21 | newConsumer.oauth_consumer_secret = oauth_consumer_secret; 22 | 23 | return newConsumer; 24 | }; 25 | 26 | module.exports = LTIConsumerFactory; 27 | -------------------------------------------------------------------------------- /compair/static/test/factories/lti_context_factory.js: -------------------------------------------------------------------------------- 1 | var objectAssign = require('object-assign'); 2 | 3 | var ltiContextTemplate = { 4 | "id": null, 5 | "compair_course_id": null, 6 | "compair_course_name": null, 7 | "oauth_consumer_key": null, 8 | "context_id": null, 9 | "context_title": null, 10 | 'modified': "Mon, 18 Apr 2016 17:38:23 -0000", 11 | 'created': "Mon, 18 Apr 2016 17:38:23 -0000" 12 | } 13 | 14 | function LTIContextFactory() {}; 15 | 16 | LTIContextFactory.prototype.generateContext = function (id, lti_consumer, course, parameters) { 17 | var newContext = objectAssign({}, ltiContextTemplate, parameters); 18 | newContext.id = id; 19 | newContext.compair_course_id = course.id; 20 | newContext.compair_course_name = course.name; 21 | newContext.oauth_consumer_key = lti_consumer.oauth_consumer_key; 22 | 23 | return newContext; 24 | }; 25 | 26 | module.exports = LTIContextFactory; 27 | -------------------------------------------------------------------------------- /compair/static/test/factories/lti_user_factory.js: -------------------------------------------------------------------------------- 1 | var objectAssign = require('object-assign'); 2 | 3 | var ltiUserTemplate = { 4 | "id": null, 5 | "lti_consumer_id": null, 6 | "compair_user_id": null, 7 | "lti_user_id": null, 8 | "lis_person_name_full": null, 9 | "oauth_consumer_key": null 10 | } 11 | 12 | function LTIUserFactory() {}; 13 | 14 | LTIUserFactory.prototype.generateLTIUser = function (id, lti_consumer_id, compair_user_id, parameters) { 15 | var newLTIUser = objectAssign({}, ltiUserTemplate, parameters); 16 | newLTIUser.id = id; 17 | newLTIUser.lti_consumer_id = lti_consumer_id; 18 | newLTIUser.compair_user_id = compair_user_id; 19 | 20 | return newLTIUser; 21 | }; 22 | 23 | module.exports = LTIUserFactory; 24 | -------------------------------------------------------------------------------- /compair/static/test/factories/page_factory.js: -------------------------------------------------------------------------------- 1 | function PageFactory() {} 2 | 3 | PageFactory.prototype.createPage = function(pageName) { 4 | var page = undefined; 5 | try { 6 | page = require('../page_objects/'+pageName.replace(/\s/g, '_')); 7 | } catch (e) { 8 | if (e.code == 'MODULE_NOT_FOUND') { 9 | e.message = 'Requested page "' + pageName + '" is not defined in page mapping!'; 10 | } 11 | throw e; 12 | } 13 | 14 | return new page; 15 | }; 16 | 17 | module.exports = PageFactory; -------------------------------------------------------------------------------- /compair/static/test/factories/third_party_user_factory.js: -------------------------------------------------------------------------------- 1 | var objectAssign = require('object-assign'); 2 | 3 | var thirdPartyUserTemplate = { 4 | "id": null, 5 | "user_id": null, 6 | "third_party_type": null, 7 | "unique_identifier": null, 8 | "_params": null, 9 | "system_role": null, 10 | "created": "Mon, 18 Apr 2016 17:38:23 -0000", 11 | "modified": "Mon, 18 Apr 2016 17:38:23 -0000" 12 | } 13 | 14 | function thirdPartyUserFactory() {}; 15 | 16 | thirdPartyUserFactory.prototype.generatethirdPartyUser = function (id, user_id, parameters) { 17 | var newThirdPartyUser = objectAssign({}, thirdPartyUserTemplate, parameters); 18 | newThirdPartyUser.id = id; 19 | newThirdPartyUser.user_id = user_id; 20 | 21 | return newThirdPartyUser; 22 | }; 23 | 24 | module.exports = thirdPartyUserFactory; 25 | -------------------------------------------------------------------------------- /compair/static/test/factories/user_factory.js: -------------------------------------------------------------------------------- 1 | var objectAssign = require('object-assign'); 2 | 3 | var userTempalte = { 4 | "id": null, 5 | "username": null, 6 | "displayname": null, 7 | "email": null, 8 | "firstname": null, 9 | "fullname": null, 10 | "fullname_sortable": null, 11 | "lastname": null, 12 | "student_number": null, 13 | "avatar": "63a9f0ea7bb98050796b649e85481845", 14 | "created": "Sat, 27 Dec 2014 20:13:11 -0000", 15 | "modified": "Sun, 11 Jan 2015 02:55:59 -0000", 16 | "last_online": "Sun, 11 Jan 2015 02:55:59 -0000", 17 | "system_role": null, 18 | "uses_compair_login": true, 19 | "email_notification_method": 'enable' 20 | } 21 | 22 | function UserFactory() {}; 23 | 24 | 25 | UserFactory.prototype.generateUser = function (id, system_role, parameters) { 26 | var newUser = objectAssign({}, userTempalte, parameters); 27 | newUser.id = id; 28 | newUser.system_role = system_role; 29 | 30 | return newUser; 31 | }; 32 | 33 | module.exports = UserFactory; -------------------------------------------------------------------------------- /compair/static/test/features/create_assignment.feature: -------------------------------------------------------------------------------- 1 | Feature: Create Assignment 2 | As user, I want to create assignments 3 | 4 | Scenario: Loading add assignment page by Add Assignment button as admin 5 | Given I'm a System Administrator 6 | And I'm on 'course' page for course with id '2abcABC123-abcABC123_Z' 7 | When I select the 'Add Assignment' button 8 | Then I should be on the 'create assignment' page 9 | 10 | Scenario: Loading add assignment page by Add Assignment button as instructor 11 | Given I'm an Instructor 12 | And I'm on 'course' page for course with id '2abcABC123-abcABC123_Z' 13 | When I select the 'Add Assignment' button 14 | Then I should be on the 'create assignment' page 15 | 16 | Scenario: Creating a assignment as instructor 17 | Given I'm an Instructor 18 | And I'm on 'create assignment' page for course with id '2abcABC123-abcABC123_Z' 19 | When I fill form item 'assignment.name' in with 'Test Assignment' 20 | And I fill form item 'date.astart.date' in with '10-October-2016' 21 | And I fill form item 'date.aend.date' in with '17-October-2016' 22 | And I submit form with the 'Save' button 23 | Then I should be on the 'course' page 24 | And I should see 'Test Assignment »' in 'h3' on the page 25 | -------------------------------------------------------------------------------- /compair/static/test/features/create_course.feature: -------------------------------------------------------------------------------- 1 | Feature: Create Course 2 | As user, I want to create courses 3 | 4 | Scenario: Loading add course page by Add Course button as admin 5 | Given I'm a System Administrator 6 | And I'm on 'home' page 7 | When I select the 'Add Course' button 8 | Then I should be on the 'create course' page 9 | 10 | Scenario: Loading add course page by add a course button as instructor 11 | Given I'm an Instructor 12 | And I'm on 'home' page 13 | When I select the 'Add Course' button 14 | Then I should be on the 'create course' page 15 | 16 | Scenario: Creating a course as instructor 17 | Given I'm an Instructor 18 | And I'm on 'create course' page 19 | When I fill form item 'course.name' in with 'Test Course 2' 20 | And I fill form item 'course.year' in with '2015' 21 | And I fill form item 'course.term' in with 'Winter' 22 | And I fill form item 'date.course_start.date' in with '21-July-2016' 23 | And I submit form with the 'Save' button 24 | Then I should be on the 'course' page 25 | And I should see 'Test Course 2 (2015 Winter)' in 'h1' on the page -------------------------------------------------------------------------------- /compair/static/test/features/create_lti_consumer.feature: -------------------------------------------------------------------------------- 1 | Feature: Create LTI Consumers 2 | As user, I want to create LTI consumers 3 | 4 | Scenario: Loading create LTI consumers page as admin 5 | Given I'm a System Administrator 6 | And I'm on 'manage lti' page 7 | When I select the 'Add LTI Consumer' button 8 | Then I should be on the 'create lti consumer' page 9 | 10 | Scenario: Creating a lti consumer as admin 11 | Given I'm a System Administrator 12 | And I'm on 'create lti consumer' page 13 | When I fill form item 'consumer.oauth_consumer_key' in with 'consumer_key_4' 14 | And I fill form item 'consumer.oauth_consumer_secret' in with 'consumer_secret_4' 15 | And I submit form with the 'Save' button 16 | Then I should be on the 'manage lti' page 17 | And I should see '4' consumers listed 18 | And I should see consumers with consumer keys: 19 | | oauth_consumer_key | 20 | | consumer_key_1 | 21 | | consumer_key_2 | 22 | | consumer_key_3 | 23 | | consumer_key_4 | 24 | -------------------------------------------------------------------------------- /compair/static/test/features/edit_course.feature: -------------------------------------------------------------------------------- 1 | Feature: Edit Course 2 | As user, I want to edit a course 3 | 4 | Scenario: Loading edit course page as admin 5 | Given I'm a System Administrator 6 | And I'm on 'course' page for course with id '1abcABC123-abcABC123_Z' 7 | When I select the 'Edit Course' button 8 | Then I should be on the 'edit course' page 9 | 10 | Scenario: Loading edit course page as instructor 11 | Given I'm an Instructor 12 | And I'm on 'course' page for course with id '1abcABC123-abcABC123_Z' 13 | When I select the 'Edit Course' button 14 | Then I should be on the 'edit course' page 15 | 16 | Scenario: Editing a course as instructor 17 | Given I'm an Instructor 18 | And I'm on 'edit course' page for course with id '1abcABC123-abcABC123_Z' 19 | When I fill form item 'course.name' in with 'New Name' 20 | And I fill form item 'course.year' in with '2020' 21 | And I fill form item 'course.term' in with 'Winter' 22 | And I submit form with the 'Save' button 23 | Then I should be on the 'course' page 24 | And I should see 'New Name (2020 Winter)' in 'h1' on the page 25 | -------------------------------------------------------------------------------- /compair/static/test/features/edit_lti_consumer.feature: -------------------------------------------------------------------------------- 1 | Feature: Edit LTI Consumers 2 | As user, I want to edit LTI consumers 3 | 4 | Scenario: Loading edit LTI consumer page as admin 5 | Given I'm a System Administrator 6 | And I'm on 'manage lti' page 7 | When I click the first consumer's Edit button 8 | Then I should be on the 'edit lti consumer' page 9 | 10 | Scenario: Editing a lti consumer as admin 11 | Given I'm a System Administrator 12 | And I'm on 'edit lti consumer' page for consumer with id '1abcABC123-abcABC123_Z' 13 | When I fill form item 'consumer.oauth_consumer_key' in with 'new_consumer_key_1' 14 | And I fill form item 'consumer.oauth_consumer_secret' in with 'new_consumer_secret_1' 15 | And I fill form item 'consumer.global_unique_identifier_param' in with 'new_global_unique_identifier_param' 16 | And I fill form item 'consumer.student_number_param' in with 'new_student_number_param' 17 | And I fill form item 'consumer.custom_param_regex_sanitizer' in with 'new_custom_param_regex_sanitizer' 18 | And I toggle the 'This consumer is actively being used' checkbox 19 | And I submit form with the 'Save' button 20 | Then I should be on the 'manage lti' page 21 | And I should see '3' consumers listed 22 | And I should see consumers with consumer keys: 23 | | oauth_consumer_key | 24 | | new_consumer_key_1 | 25 | | consumer_key_2 | 26 | | consumer_key_3 | 27 | -------------------------------------------------------------------------------- /compair/static/test/features/step_definitions/create_assignment.js: -------------------------------------------------------------------------------- 1 | // Use the external Chai As Promised to deal with resolving promises in 2 | // expectations 3 | var chai = require('chai'); 4 | var chaiAsPromised = require('chai-as-promised'); 5 | chai.use(chaiAsPromised); 6 | 7 | var expect = chai.expect; 8 | 9 | var createAssignmentStepDefinitionsWrapper = function () { 10 | this.Given("I fill in the assignment description with '$text'", function(text) { 11 | //load the ckeditor iframe 12 | browser.sleep(2000); 13 | browser.wait(browser.isElementPresent(element(by.css("#cke_assignmentDescription iframe"))), 1000); 14 | browser.driver.switchTo().frame(element(by.css("#cke_assignmentDescription iframe")).getWebElement()); 15 | // clear the content 16 | browser.driver.executeScript("document.body.innerHTML = '';") 17 | browser.driver.findElement(by.css("body")).click(); 18 | browser.driver.findElement(by.css("body")).sendKeys(text); 19 | browser.driver.switchTo().defaultContent(); 20 | browser.sleep(2000); 21 | return; 22 | // dont click on the body. it may accidentially click on any button (depending on the browser window size) 23 | //return element(by.css("body")).click(); 24 | }); 25 | }; 26 | module.exports = createAssignmentStepDefinitionsWrapper; -------------------------------------------------------------------------------- /compair/static/test/features/step_definitions/view_home.js: -------------------------------------------------------------------------------- 1 | // Use the external Chai As Promised to deal with resolving promises in 2 | // expectations 3 | var chai = require('chai'); 4 | var chaiAsPromised = require('chai-as-promised'); 5 | chai.use(chaiAsPromised); 6 | 7 | var expect = chai.expect; 8 | 9 | var viewHomeStepDefinitionsWrapper = function () { 10 | this.Then("I should see my courses with names:", function (data) { 11 | var list = data.hashes().map(function(item) { 12 | return item.name + " »"; 13 | }); 14 | 15 | return expect(element.all(by.css(".course-list a h3")).getText()).to.eventually.eql(list); 16 | }); 17 | 18 | this.When("I filter home page courses by '$filter'", function (filter) { 19 | element(by.css("form.search-courses input")).sendKeys(filter); 20 | // force blur 21 | //return element(by.css("body")).click(); 22 | // dont click on the body. it may accidentially click on any button (depending on the browser window size) 23 | return; 24 | }); 25 | 26 | this.Then("I should see '$numberString' courses", function (numberString) { 27 | var count = parseInt(numberString); 28 | return expect(element.all(by.css(".course-list a h3")).count()).to.eventually.equal(count); 29 | }); 30 | 31 | }; 32 | 33 | module.exports = viewHomeStepDefinitionsWrapper; -------------------------------------------------------------------------------- /compair/static/test/features/view_home.feature: -------------------------------------------------------------------------------- 1 | Feature: View Home 2 | As user, I want to view courses on the home page 3 | 4 | Scenario: Loading home page as admin 5 | Given I'm a System Administrator 6 | And I'm on 'home' page 7 | Then I should see my courses with names: 8 | | name | 9 | | CHEM 111 | 10 | | PHYS 101 | 11 | 12 | Scenario: Loading home page as instructor 13 | Given I'm an Instructor 14 | And I'm on 'home' page 15 | Then I should see my courses with names: 16 | | name | 17 | | CHEM 111 | 18 | | PHYS 101 | 19 | 20 | Scenario: Filtering home page courses as instructor 21 | Given I'm an Instructor 22 | And I'm on 'home' page 23 | When I filter home page courses by 'CHEM' 24 | Then I should see '1' courses 25 | And I should see my courses with names: 26 | | name | 27 | | CHEM 111 | 28 | 29 | Scenario: Loading home page as student 30 | Given I'm a Student 31 | And I'm on 'home' page 32 | Then I should see my courses with names: 33 | | name | 34 | | CHEM 111 | 35 | | PHYS 101 | -------------------------------------------------------------------------------- /compair/static/test/features/view_lti_consumer.feature: -------------------------------------------------------------------------------- 1 | Feature: Manage LTI Consumers 2 | As user, I want to manage LTI consumers 3 | 4 | Scenario: Loading view LTI consumer as admin 5 | Given I'm a System Administrator 6 | And I'm on 'manage lti' page 7 | When I click the first consumer's key 8 | Then I should be on the 'lti consumer' page 9 | 10 | Scenario: View LTI consumer as admin 11 | Given I'm a System Administrator 12 | And I'm on 'lti consumer' page for consumer with id '1abcABC123-abcABC123_Z' 13 | Then I should see consumer_key_1's information 14 | 15 | Scenario: View LTI inactive consumer as admin 16 | Given I'm a System Administrator 17 | And I'm on 'lti consumer' page for consumer with id '3abcABC123-abcABC123_Z' 18 | Then I should see consumer_key_3's information 19 | 20 | Scenario: Disable LTI consumer as admin 21 | Given I'm a System Administrator 22 | And I'm on 'lti consumer' page for consumer with id '1abcABC123-abcABC123_Z' 23 | When I toggle the 'consumer.active' form checkbox 24 | Then I should see a success message 25 | 26 | Scenario: Enable LTI consumer as admin 27 | Given I'm a System Administrator 28 | And I'm on 'lti consumer' page for consumer with id '3abcABC123-abcABC123_Z' 29 | When I toggle the 'consumer.active' form checkbox 30 | Then I should see a success message -------------------------------------------------------------------------------- /compair/static/test/features/view_lti_consumers.feature: -------------------------------------------------------------------------------- 1 | Feature: Manage LTI Consumers 2 | As user, I want to manage LTI consumers 3 | 4 | Scenario: Loading view LTI consumers page by LTI Consumers button as admin 5 | Given I'm a System Administrator 6 | And I'm on 'home' page 7 | When I select the 'Manage LTI' button 8 | Then I should be on the 'manage lti' page 9 | And I should see '3' consumers listed 10 | And I should see consumers with consumer keys: 11 | | oauth_consumer_key | 12 | | consumer_key_1 | 13 | | consumer_key_2 | 14 | | consumer_key_3 | 15 | And I should see '3' contexts listed 16 | And I should see contexts with titles: 17 | | context_title | 18 | | Canvas Course One | 19 | | Canvas Course Two | 20 | | Blackboard Course One | 21 | 22 | Scenario: Disable LTI consumer as admin 23 | Given I'm a System Administrator 24 | And I'm on 'manage lti' page 25 | When I set the first consumer's active status to 'Inactive' 26 | Then I should see a success message 27 | 28 | Scenario: Enable LTI consumer as admin 29 | Given I'm a System Administrator 30 | And I'm on 'manage lti' page 31 | When I set the third consumer's active status to 'Active' 32 | Then I should see a success message 33 | 34 | Scenario: Unlink LTI context as admin 35 | Given I'm a System Administrator 36 | And I'm on 'manage lti' page 37 | When I unlink the second lti context 38 | Then I should see '2' contexts listed 39 | And I should see contexts with titles: 40 | | context_title | 41 | | Canvas Course One | 42 | | Blackboard Course One | -------------------------------------------------------------------------------- /compair/static/test/features/view_navbar.feature: -------------------------------------------------------------------------------- 1 | Feature: View Navbar 2 | As user, I want to see my navigation bar 3 | 4 | Scenario: Loading navbar as admin 5 | Given I'm a System Administrator 6 | And I'm on 'home' page 7 | Then I should see the brand home link 8 | And I should see the admin navigation items 9 | And I should see the profile and logout links 10 | 11 | Scenario: Loading navbar as instructor 12 | Given I'm an Instructor 13 | And I'm on 'home' page 14 | Then I should see the brand home link 15 | And I should see the instructor navigation items 16 | And I should see the profile and logout links 17 | 18 | Scenario: Loading navbar as student 19 | Given I'm a Student 20 | And I'm on 'home' page 21 | Then I should see the brand home link 22 | And I should see the student navigation items 23 | And I should see the profile and logout links -------------------------------------------------------------------------------- /compair/static/test/features/view_users.feature: -------------------------------------------------------------------------------- 1 | Feature: Manage Users 2 | As user, I want to manage accounts 3 | 4 | Scenario: Loading view users page by Manage Users button as admin 5 | Given I'm a System Administrator 6 | And I'm on 'home' page 7 | When I select the 'Manage Users' button 8 | Then I should be on the 'users' page 9 | And I should see '4' users listed 10 | And I should users with displaynames: 11 | | displayname | 12 | | First Instructor | 13 | | First Student | 14 | | root | 15 | | Second Student | 16 | 17 | Scenario: Filter users page as admin 18 | Given I'm a System Administrator 19 | And I'm on 'users' page 20 | When I filter users page by 'Second' 21 | Then I should see '1' users listed 22 | And I should users with displaynames: 23 | | displayname | 24 | | Second Student | -------------------------------------------------------------------------------- /compair/static/test/fixtures/admin/saml_fixture.js: -------------------------------------------------------------------------------- 1 | var deepcopy = require('deepcopy'); 2 | var default_fixture = require('./default_fixture.js') 3 | 4 | var storage = deepcopy(default_fixture); 5 | 6 | for (userId in storage.users) { 7 | delete storage.users[userId].username; 8 | storage.users[userId].uses_compair_login = false; 9 | } 10 | 11 | delete storage.loginDetails.username; 12 | delete storage.loginDetails.password; 13 | 14 | module.exports = storage; -------------------------------------------------------------------------------- /compair/static/test/fixtures/instructor/saml_fixture.js: -------------------------------------------------------------------------------- 1 | var deepcopy = require('deepcopy'); 2 | var default_fixture = require('./default_fixture.js') 3 | 4 | var storage = deepcopy(default_fixture); 5 | 6 | for (userId in storage.users) { 7 | delete storage.users[userId].username; 8 | storage.users[userId].uses_compair_login = false; 9 | } 10 | 11 | delete storage.loginDetails.username; 12 | delete storage.loginDetails.password; 13 | 14 | module.exports = storage; -------------------------------------------------------------------------------- /compair/static/test/fixtures/student/saml_fixture.js: -------------------------------------------------------------------------------- 1 | var deepcopy = require('deepcopy'); 2 | var default_fixture = require('./default_fixture.js') 3 | 4 | var storage = deepcopy(default_fixture); 5 | 6 | for (userId in storage.users) { 7 | delete storage.users[userId].username; 8 | storage.users[userId].uses_compair_login = false; 9 | } 10 | 11 | delete storage.loginDetails.username; 12 | delete storage.loginDetails.password; 13 | 14 | module.exports = storage; -------------------------------------------------------------------------------- /compair/static/test/page_objects/assignment.js: -------------------------------------------------------------------------------- 1 | var AssignmentPage = function() { 2 | this.getLocation = function(courseId, assignmentId) { 3 | return 'course/' + courseId + '/assignment/' + assignmentId; 4 | }; 5 | 6 | this.clickButton = function(button) { 7 | switch (button) { 8 | case "Edit Assignment": 9 | return element(by.cssContainingText(".assignment-screen a", "Edit Assignment")).click(); 10 | } 11 | } 12 | }; 13 | 14 | module.exports = AssignmentPage; 15 | -------------------------------------------------------------------------------- /compair/static/test/page_objects/course.js: -------------------------------------------------------------------------------- 1 | var CoursePage = function() { 2 | this.getLocation = function(courseId) { 3 | return 'course/' + courseId; 4 | }; 5 | 6 | this.clickButton = function(button) { 7 | switch (button) { 8 | case "Add Assignment": 9 | return element(by.css('#add-assignment-btn')).click(); 10 | case "Edit Course": 11 | return element(by.css('#edit-course-btn')).click(); 12 | case "Manage Users": 13 | return element(by.css('#manage-users-btn')).click(); 14 | } 15 | } 16 | }; 17 | 18 | module.exports = CoursePage; 19 | -------------------------------------------------------------------------------- /compair/static/test/page_objects/create_assignment.js: -------------------------------------------------------------------------------- 1 | var CreateAssignmentPage = function() { 2 | this.getLocation = function(courseId) { 3 | return 'course/' + courseId + '/assignment/create'; 4 | }; 5 | }; 6 | 7 | module.exports = CreateAssignmentPage; -------------------------------------------------------------------------------- /compair/static/test/page_objects/create_course.js: -------------------------------------------------------------------------------- 1 | var CreateCoursePage = function() { 2 | this.getLocation = function() { 3 | return 'course/create'; 4 | }; 5 | }; 6 | 7 | module.exports = CreateCoursePage; 8 | -------------------------------------------------------------------------------- /compair/static/test/page_objects/create_lti_consumer.js: -------------------------------------------------------------------------------- 1 | var CreateLTIConsumerPage = function() { 2 | this.getLocation = function() { 3 | return 'lti/consumer/create'; 4 | }; 5 | }; 6 | 7 | module.exports = CreateLTIConsumerPage; 8 | -------------------------------------------------------------------------------- /compair/static/test/page_objects/create_user.js: -------------------------------------------------------------------------------- 1 | var CreateUserPage = function() { 2 | this.getLocation = function() { 3 | return 'user/create'; 4 | }; 5 | }; 6 | 7 | module.exports = CreateUserPage; 8 | -------------------------------------------------------------------------------- /compair/static/test/page_objects/edit_assignment.js: -------------------------------------------------------------------------------- 1 | var EditAssignmentPage = function() { 2 | this.getLocation = function(courseId, assignmentId) { 3 | return 'course/' + courseId + '/assignment/' + assignmentId + '/edit'; 4 | }; 5 | }; 6 | 7 | module.exports = EditAssignmentPage; 8 | -------------------------------------------------------------------------------- /compair/static/test/page_objects/edit_course.js: -------------------------------------------------------------------------------- 1 | var EditCoursePage = function() { 2 | this.getLocation = function(courseId) { 3 | return 'course/' + courseId + '/edit'; 4 | }; 5 | }; 6 | 7 | module.exports = EditCoursePage; 8 | -------------------------------------------------------------------------------- /compair/static/test/page_objects/edit_course_user.js: -------------------------------------------------------------------------------- 1 | var EditCourseUserPage = function() { 2 | this.getLocation = function(courseId) { 3 | return 'course/' + courseId + '/user'; 4 | }; 5 | }; 6 | 7 | module.exports = EditCourseUserPage; 8 | -------------------------------------------------------------------------------- /compair/static/test/page_objects/edit_lti_consumer.js: -------------------------------------------------------------------------------- 1 | var EditLTIConsumerPage = function() { 2 | this.getLocation = function(id) { 3 | return 'lti/consumer/'+id+'/edit'; 4 | }; 5 | }; 6 | 7 | module.exports = EditLTIConsumerPage; 8 | -------------------------------------------------------------------------------- /compair/static/test/page_objects/edit_user.js: -------------------------------------------------------------------------------- 1 | var EditUserPage = function() { 2 | this.getLocation = function(userId) { 3 | return 'user/' + userId + '/edit'; 4 | }; 5 | 6 | this.clickButton = function(button) { 7 | switch (button) { 8 | case "Edit Password": 9 | return element(by.css('#change-password-btn')).click(); 10 | } 11 | } 12 | }; 13 | 14 | module.exports = EditUserPage; -------------------------------------------------------------------------------- /compair/static/test/page_objects/home.js: -------------------------------------------------------------------------------- 1 | var HomePage = function() { 2 | this.getLocation = function() { 3 | return '/'; 4 | }; 5 | 6 | this.clickButton = function(button) { 7 | switch (button) { 8 | case "Add Course": 9 | return element(by.css('#create-course-btn')).click(); 10 | case "Create Account": 11 | return element(by.css('#create-user-btn')).click(); 12 | case "Manage Users": 13 | return element(by.css('#view-users-btn')).click(); 14 | case "Manage LTI": 15 | return element(by.css('#manage-lti-consumers-btn')).click(); 16 | case "Profile": 17 | element(by.css('#menu-dropdown')).click(); 18 | return element(by.css('#own-profile-link')).click(); 19 | } 20 | } 21 | }; 22 | 23 | module.exports = HomePage; 24 | -------------------------------------------------------------------------------- /compair/static/test/page_objects/lti_consumer.js: -------------------------------------------------------------------------------- 1 | var ViewLTIConsumerPage = function() { 2 | this.getLocation = function(id) { 3 | return 'lti/consumer/'+id; 4 | }; 5 | }; 6 | 7 | module.exports = ViewLTIConsumerPage; 8 | -------------------------------------------------------------------------------- /compair/static/test/page_objects/manage_lti.js: -------------------------------------------------------------------------------- 1 | var LTIConsumersPage = function() { 2 | this.getLocation = function() { 3 | return 'lti/consumer'; 4 | }; 5 | 6 | this.clickButton = function(button) { 7 | switch (button) { 8 | case "Add LTI Consumer": 9 | return element(by.css('#create-lti-consumer-btn')).click(); 10 | } 11 | } 12 | }; 13 | 14 | module.exports = LTIConsumersPage; 15 | -------------------------------------------------------------------------------- /compair/static/test/page_objects/user.js: -------------------------------------------------------------------------------- 1 | var UserPage = function() { 2 | this.getLocation = function(userId) { 3 | return 'user/' + userId; 4 | }; 5 | 6 | this.clickButton = function(button) { 7 | switch (button) { 8 | case "Edit": 9 | return element(by.css('#edit-profile-btn')).click(); 10 | } 11 | } 12 | }; 13 | 14 | module.exports = UserPage; 15 | -------------------------------------------------------------------------------- /compair/static/test/page_objects/user_manage.js: -------------------------------------------------------------------------------- 1 | var UserManagePage = function() { 2 | this.getLocation = function(userId) { 3 | return 'users/' + userId + '/manage'; 4 | }; 5 | }; 6 | 7 | module.exports = UserManagePage; 8 | -------------------------------------------------------------------------------- /compair/static/test/page_objects/users.js: -------------------------------------------------------------------------------- 1 | var UsersPage = function() { 2 | this.getLocation = function() { 3 | return 'users'; 4 | }; 5 | }; 6 | 7 | module.exports = UsersPage; 8 | -------------------------------------------------------------------------------- /compair/static/test/specs/home_spec.js: -------------------------------------------------------------------------------- 1 | var env = require('../env.js'); 2 | var loginDialog = require('../page_objects/login.js'); 3 | 4 | describe('home', function() { 5 | beforeEach(function() { 6 | loginDialog.login(); 7 | }); 8 | 9 | afterEach(function() { 10 | loginDialog.logout(); 11 | }); 12 | 13 | it('should show the home page ', function() { 14 | expect(element(by.css('div.home-screen h2')).getText()).toMatch('Select a course'); 15 | 16 | }); 17 | 18 | it('should not list course when no course available', function() { 19 | expect(element(by.css('div.home-screen div p')).getText()).toMatch('No courses currently available.'); 20 | addCourseButton = element(by.css('#create-course-btn')); 21 | }); 22 | }); -------------------------------------------------------------------------------- /compair/static/test/test_e2e-dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | BASE_DIR=`dirname $0` 4 | 5 | echo "" 6 | echo "Starting Karma Server (http://karma-runner.github.io)" 7 | echo "-------------------------------------------------------------------" 8 | 9 | karma start $BASE_DIR/config/karma-e2e-dev.conf.js $* 10 | -------------------------------------------------------------------------------- /compair/static/test/test_e2e.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | BASE_DIR=`dirname $0` 4 | 5 | echo "" 6 | echo "Starting Karma Server (http://karma-runner.github.io)" 7 | echo "-------------------------------------------------------------------" 8 | 9 | karma start $BASE_DIR/config/karma-e2e.conf.js $* 10 | -------------------------------------------------------------------------------- /compair/static/test/test_unit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | BASE_DIR=`dirname $0` 4 | 5 | echo "" 6 | echo "Starting Karma Server (http://karma-runner.github.io)" 7 | echo "-------------------------------------------------------------------" 8 | 9 | karma start $BASE_DIR/config/karma.conf.js $* 10 | -------------------------------------------------------------------------------- /compair/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | from .demo import reset_demo 2 | from .emit_learning_record import emit_lrs_xapi_statement, emit_lrs_caliper_event 3 | from .lti_membership import update_lti_course_membership 4 | from .lti_outcomes import update_lti_course_grades, update_lti_assignment_grades 5 | from .send_mail import send_message, send_messages 6 | from .user_password import set_passwords -------------------------------------------------------------------------------- /compair/tasks/demo.py: -------------------------------------------------------------------------------- 1 | from compair.core import celery, db 2 | from compair.manage.database import recreate 3 | 4 | # retry in 30 seconds, need to be fast since database might be in a bad state 5 | # if error occurred during database populate (since database drop/create cannot be rolled back) 6 | @celery.task(bind=True, autoretry_for=(Exception,), 7 | default_retry_delay=30.0, ignore_result=True, store_errors_even_if_ignored=True) 8 | def reset_demo(self): 9 | recreate(yes=True, default_data=True, sample_data=True) -------------------------------------------------------------------------------- /compair/tasks/send_mail.py: -------------------------------------------------------------------------------- 1 | from compair.core import celery, mail 2 | from flask_mail import Message 3 | from flask import current_app 4 | 5 | @celery.task(bind=True, ignore_result=True) 6 | def send_messages(self, messages): 7 | with mail.connect() as conn: 8 | for message in messages: 9 | msg = Message( 10 | recipients=message.get('recipients'), 11 | subject=message.get('subject'), 12 | html=message.get('html_body'), 13 | body=message.get('text_body') 14 | ) 15 | conn.send(msg) 16 | 17 | 18 | @celery.task(bind=True, ignore_result=True) 19 | def send_message(self, recipients, subject, html_body, text_body): 20 | # send message 21 | msg = Message( 22 | recipients=recipients, 23 | subject=subject, 24 | html=html_body, 25 | body=text_body 26 | ) 27 | mail.send(msg) -------------------------------------------------------------------------------- /compair/tasks/user_password.py: -------------------------------------------------------------------------------- 1 | from compair.core import celery, db 2 | from compair.models import User 3 | 4 | @celery.task(bind=True, autoretry_for=(Exception,), 5 | ignore_result=True, store_errors_even_if_ignored=True) 6 | def set_passwords(self, user_passwords): 7 | user_ids = user_passwords.keys() 8 | 9 | users = User.query \ 10 | .filter(User.id.in_(user_ids)) \ 11 | .all() 12 | 13 | for user in users: 14 | # note that user_passwords keys are strings, not ints, due to celery's 15 | # json serialization, so we need to match type 16 | user.password = user_passwords.get(str(user.id), None) 17 | 18 | db.session.add_all(users) 19 | db.session.commit() 20 | -------------------------------------------------------------------------------- /compair/templates/notification_base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{ subject }} 8 | 11 | 12 | 13 |
    14 | {% block body %} 15 | {% endblock %} 16 |
    17 | 22 | 23 | -------------------------------------------------------------------------------- /compair/templates/notification_base.txt: -------------------------------------------------------------------------------- 1 | {% block body %} 2 | {% endblock %} 3 | 4 | 5 | -- 6 | Unsubscribe from ComPAIR email notifications by using the following url: 7 | {{ url_for('route_app', _external=True, _anchor='/user/'+user.uuid+'/edit') }} -------------------------------------------------------------------------------- /compair/templates/notification_new_answer_comment.html: -------------------------------------------------------------------------------- 1 | {% extends "notification_base.html" %} 2 | {% block body %} 3 |
    4 | 5 |

    6 | You've received new answer feedback for the following assignment: {{assignment.name}} 7 |
    8 | View in ComPAIR 9 |

    10 |
    11 | 12 |
    13 | {{ author.displayname }} 14 | 15 | {% if instructor_label %}{{instructor_label}}{% endif %} 16 | 17 | {% if comment.comment_type in [answer_comment_types.private, answer_comment_types.evaluation] %} 18 | gave private feedback 19 | {% elif comment.comment_type == answer_comment_types.public %} 20 | replied Public 21 | {% endif %} 22 | on {{ comment.created.strftime('%b %d @ %I:%M %p') }}: 23 |
    24 | 25 |
    {{ clean_html(comment.content) | safe }}
    26 | {% endblock %} -------------------------------------------------------------------------------- /compair/templates/notification_new_answer_comment.txt: -------------------------------------------------------------------------------- 1 | {% extends "notification_base.txt" %} 2 | {% block body %} 3 | You've received new answer feedback for the following assignment: {{ assignment.name }} 4 | 5 | {{ author.displayname }} {% if instructor_label %}({{instructor_label}}) {% endif %}{% if comment.comment_type in [answer_comment_types.private, answer_comment_types.evaluation] %}gave private feedback{% elif comment.comment_type == answer_comment_types.public %}replied Public{% endif %} on {{ comment.created.strftime('%b %d @ %I:%M %p') }} 6 | 7 | You can view it at: 8 | {{ url_for('route_app', _external=True, _anchor='/course/'+course.uuid+'/assignment/'+assignment.uuid) }}?tab=your_feedback 9 | {% endblock %} -------------------------------------------------------------------------------- /compair/templates/pdf-viewer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PDF.js 6 | 7 | 8 | 9 | 21 | 22 | 23 |
    24 | {{ include_raw("static/viewer.html") }} 25 |
    26 | 27 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /compair/templates/static/anchor.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/compair/templates/static/anchor.txt -------------------------------------------------------------------------------- /compair/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | """ 4 | Test package, also includes default settings for test environment 5 | """ 6 | import json 7 | import os 8 | 9 | saml_settings = None 10 | with open(os.getcwd() +'/deploy/development/dev_saml_settings.json', 'r') as json_data_file: 11 | saml_settings = json.load(json_data_file) 12 | 13 | test_app_settings = { 14 | 'DEBUG': False, 15 | 'TESTING': True, 16 | #'PRESERVE_CONTEXT_ON_EXCEPTION': False, 17 | 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:', 18 | 'SQLALCHEMY_ECHO': False, 19 | 'CSRF_ENABLED': False, 20 | 'PASSLIB_CONTEXT': 'plaintext', 21 | 'ENFORCE_SSL': False, 22 | 'CELERY_TASK_ALWAYS_EAGER': True, 23 | 'XAPI_ENABLED': False, 24 | 'CALIPER_ENABLED': False, 25 | 'DEMO_INSTALLATION': False, 26 | 'EXPOSE_EMAIL_TO_INSTRUCTOR': False, 27 | 'EXPOSE_THIRD_PARTY_USERNAMES_TO_INSTRUCTOR': False, 28 | 'SAML_UNIQUE_IDENTIFIER': 'urn:oid:0.9.2342.19200300.100.1.1', 29 | 'SAML_SETTINGS': saml_settings, 30 | 'SAML_EXPOSE_METADATA_ENDPOINT': True, 31 | 'ALLOW_STUDENT_CHANGE_NAME': False, 32 | 'ALLOW_STUDENT_CHANGE_DISPLAY_NAME': False, 33 | 'ALLOW_STUDENT_CHANGE_STUDENT_NUMBER': False, 34 | 'ALLOW_STUDENT_CHANGE_EMAIL': False, 35 | 'MAIL_NOTIFICATION_ENABLED': True, 36 | 'MAIL_DEFAULT_SENDER': 'compair@example.com' 37 | } -------------------------------------------------------------------------------- /compair/tests/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration for tests 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # max length of characters to apply to the 11 | # "slug" field 12 | #truncate_slug_length = 40 13 | 14 | # set to 'true' to run the environment during 15 | # the 'revision' command, regardless of autogenerate 16 | # revision_environment = false 17 | 18 | # set to 'true' to allow .pyc and .pyo files without 19 | # a source .py file to be detected as revisions in the 20 | # versions/ directory 21 | # sourceless = false 22 | 23 | # this is not being used. It's using compair.configuration 24 | # for the database config. This option should not be enalbed. 25 | #sqlalchemy.url = sqlite:///app.db 26 | 27 | 28 | # Logging configuration 29 | [loggers] 30 | keys = root,sqlalchemy,alembic 31 | 32 | [handlers] 33 | keys = console 34 | 35 | [formatters] 36 | keys = generic 37 | 38 | [logger_root] 39 | level = ERROR 40 | handlers = console 41 | qualname = 42 | 43 | [logger_sqlalchemy] 44 | level = WARN 45 | handlers = 46 | qualname = sqlalchemy.engine 47 | 48 | [logger_alembic] 49 | level = WARN 50 | handlers = 51 | qualname = alembic 52 | 53 | [handler_console] 54 | class = StreamHandler 55 | args = (sys.stderr,) 56 | level = NOTSET 57 | formatter = generic 58 | 59 | [formatter_generic] 60 | format = %(levelname)-5.5s [%(name)s] %(message)s 61 | datefmt = %H:%M:%S 62 | -------------------------------------------------------------------------------- /compair/tests/algorithms/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/compair/tests/algorithms/__init__.py -------------------------------------------------------------------------------- /compair/tests/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/compair/tests/api/__init__.py -------------------------------------------------------------------------------- /compair/tests/learning_records/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/compair/tests/learning_records/__init__.py -------------------------------------------------------------------------------- /compair/tests/test_configuration.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | import unittest 4 | 5 | 6 | class TestConfiguration(unittest.TestCase): 7 | def setUp(self): 8 | pass 9 | 10 | def test_default(self): 11 | pass 12 | -------------------------------------------------------------------------------- /compair/tests/test_migration.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | from alembic.config import Config 4 | 5 | from alembic import command 6 | from compair import db 7 | from compair.tests.test_compair import ComPAIRTestCase 8 | 9 | 10 | class TestMigration(ComPAIRTestCase): 11 | def test_migration(self): 12 | # create config object 13 | alembic_cfg = Config("compair/tests/alembic.ini") 14 | # get connection from db object 15 | connection = db.engine.connect() 16 | alembic_cfg.connection = connection 17 | 18 | command.stamp(alembic_cfg, 'head') 19 | 20 | command.downgrade(alembic_cfg, "base") 21 | 22 | command.upgrade(alembic_cfg, "head") 23 | 24 | command.downgrade(alembic_cfg, "base") 25 | -------------------------------------------------------------------------------- /compair/tests/test_util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | from data.fixtures import AssignmentFactory 4 | 5 | from compair.core import db 6 | from compair.api.util import get_model_changes 7 | from compair.tests.test_compair import ComPAIRTestCase 8 | 9 | 10 | class DbUtilTests(ComPAIRTestCase): 11 | def test_get_model_changes(self): 12 | assignment = AssignmentFactory() 13 | 14 | db.session.commit() 15 | 16 | self.assertEqual(get_model_changes(assignment), None, 'shoul return None on no change model') 17 | 18 | oldname = assignment.name 19 | assignment.name = 'new name' 20 | 21 | self.assertDictEqual( 22 | get_model_changes(assignment), { 23 | 'name': {'before': oldname, 'after': 'new name'} 24 | }, 25 | 'should find the change when attribute changes') 26 | 27 | olddescription = assignment.description 28 | assignment.description = "new description" 29 | 30 | self.assertDictEqual( 31 | get_model_changes(assignment), { 32 | 'name': {'before': oldname, 'after': 'new name'}, 33 | 'description': {'before': olddescription, 'after': 'new description'} 34 | }, 35 | 'should find the change when attribute changes') 36 | -------------------------------------------------------------------------------- /compair/util/__init__.py: -------------------------------------------------------------------------------- 1 | from .sql_utcnow import sql_utcnow -------------------------------------------------------------------------------- /compair/util/sql_utcnow.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.sql import expression 2 | from sqlalchemy.ext.compiler import compiles 3 | from sqlalchemy.types import DateTime 4 | 5 | class sql_utcnow(expression.FunctionElement): 6 | """ Custom sqlalchemy function to get UTC timestamp. 7 | reference: https://docs.sqlalchemy.org/en/latest/core/compiler.html#utc-timestamp-function 8 | """ 9 | type = DateTime() 10 | 11 | @compiles(sql_utcnow, 'postgresql') 12 | def postgresql_utcnow(element, compiler, **kw): 13 | return "TIMEZONE('utc', CURRENT_TIMESTAMP)" 14 | 15 | @compiles(sql_utcnow, 'mssql') 16 | def mssql_utcnow(element, compiler, **kw): 17 | return "GETUTCDATE()" 18 | 19 | @compiles(sql_utcnow, 'sqlite') 20 | def sqlite_utcnow(element, compiler, **kw): 21 | return "DATETIME('NOW')" 22 | 23 | @compiles(sql_utcnow, 'mysql') 24 | def msql_utcnow(element, compiler, **kw): 25 | return "UTC_TIMESTAMP()" -------------------------------------------------------------------------------- /data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/data/__init__.py -------------------------------------------------------------------------------- /deploy/docker/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eo pipefail 3 | 4 | DEV=${DEV:-0} 5 | 6 | # if command starts with an option, prepend uwsgi 7 | if [ "${1:0:1}" = '-' ]; then 8 | set -- uwsgi "$@" 9 | fi 10 | 11 | if [ "$1" = 'uwsgi' ]; then 12 | # append autoreload option 13 | set -- "$@" --py-autoreload ${DEV} 14 | if [ $DEV -eq 1 ] 15 | then 16 | echo "Running in DEV mode..." 17 | export FLASK_DEBUG=True 18 | fi 19 | fi 20 | 21 | exec "$@" -------------------------------------------------------------------------------- /deploy/docker/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name localhost; 4 | client_max_body_size 250M; 5 | 6 | location / { 7 | add_header X-Content-Type-Options nosniff; 8 | 9 | root /compair; 10 | try_files $uri @compair; 11 | } 12 | 13 | location @compair { 14 | proxy_set_header Host $http_host; 15 | proxy_set_header X-Real-IP $remote_addr; 16 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 17 | proxy_set_header X-Forwarded-Proto $scheme; 18 | 19 | proxy_set_header Upgrade $http_upgrade; 20 | proxy_set_header Connection 'upgrade'; 21 | 22 | client_body_buffer_size 1m; 23 | 24 | proxy_pass http://app:3031; 25 | 26 | proxy_http_version 1.1; 27 | proxy_cache_bypass $http_upgrade; 28 | proxy_intercept_errors on; 29 | proxy_buffering on; 30 | proxy_buffer_size 128k; 31 | proxy_buffers 256 16k; 32 | proxy_busy_buffers_size 256k; 33 | proxy_temp_file_write_size 256k; 34 | proxy_max_temp_file_size 0; 35 | proxy_read_timeout 10m; 36 | proxy_send_timeout 10m; 37 | } 38 | } -------------------------------------------------------------------------------- /deploy/docker/uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | chdir=/code 3 | wsgi-file=/code/main.py 4 | http-socket = 0.0.0.0:3031 5 | #chown-socket = nginx:nginx 6 | #chmod-socket = 664 7 | master = true 8 | cheaper = 2 9 | # %k is detected cpu cores 10 | processes = %(%k * 2 + 1) 11 | module = main:app 12 | single-interpreter = true 13 | buffer-size=40000 14 | # disable logging for performance 15 | disable-logging = True 16 | -------------------------------------------------------------------------------- /deploy/gce_kube/compair-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: compair 5 | labels: 6 | app: compair 7 | spec: 8 | ports: 9 | - port: 80 10 | targetPort: 3031 11 | selector: 12 | app: compair 13 | tier: frontend 14 | type: LoadBalancer 15 | 16 | --- 17 | 18 | apiVersion: extensions/v1beta1 19 | kind: Deployment 20 | metadata: 21 | name: compair 22 | labels: 23 | app: compair 24 | spec: 25 | strategy: 26 | type: Recreate 27 | template: 28 | metadata: 29 | labels: 30 | app: compair 31 | tier: frontend 32 | spec: 33 | containers: 34 | - name: compair-app 35 | image: ubcctlt/compair-app 36 | env: 37 | - name: DB_HOST 38 | value: compair-mysql 39 | - name: DB_PORT 40 | value: "3306" 41 | - name: DB_USERNAME 42 | value: compair 43 | - name: DB_PASSWORD 44 | valueFrom: 45 | secretKeyRef: 46 | name: mysql-pass 47 | key: password.txt 48 | - name: DB_NAME 49 | value: compair 50 | - name: CELERY_BROKER_URL 51 | value: redis://compair-redis:6379 52 | ports: 53 | - containerPort: 3031 54 | name: compair-app 55 | volumeMounts: 56 | - name: nfs-persistent-storage 57 | mountPath: /code/persistent 58 | volumes: 59 | - name: nfs-persistent-storage 60 | persistentVolumeClaim: 61 | claimName: nfs-pv-claim -------------------------------------------------------------------------------- /deploy/gce_kube/compair-worker-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: extensions/v1beta1 2 | kind: Deployment 3 | metadata: 4 | name: compair-worker 5 | labels: 6 | app: compair 7 | spec: 8 | replicas: 1 9 | strategy: 10 | type: Recreate 11 | template: 12 | metadata: 13 | labels: 14 | app: compair 15 | tier: worker 16 | spec: 17 | containers: 18 | - image: ubcctlt/compair-app 19 | name: compair-worker 20 | command: ["celery", "--app=celery_worker.celery", "worker"] 21 | env: 22 | - name: DB_HOST 23 | value: compair-mysql 24 | - name: DB_PORT 25 | value: "3306" 26 | - name: DB_USERNAME 27 | value: compair 28 | - name: DB_PASSWORD 29 | valueFrom: 30 | secretKeyRef: 31 | name: mysql-pass 32 | key: password.txt 33 | - name: DB_NAME 34 | value: compair 35 | - name: CELERY_BROKER_URL 36 | value: redis://compair-redis:6379 37 | - name: C_FORCE_ROOT 38 | value: "1" 39 | volumeMounts: 40 | - name: nfs-persistent-storage 41 | mountPath: /code/persistent 42 | volumes: 43 | - name: nfs-persistent-storage 44 | persistentVolumeClaim: 45 | claimName: nfs-pv-claim 46 | -------------------------------------------------------------------------------- /deploy/gce_kube/gce-pd-storageclass.yaml: -------------------------------------------------------------------------------- 1 | kind: StorageClass 2 | apiVersion: storage.k8s.io/v1beta1 3 | metadata: 4 | name: gce-slow 5 | provisioner: kubernetes.io/gce-pd 6 | parameters: 7 | type: pd-standard 8 | -------------------------------------------------------------------------------- /deploy/gce_kube/mysql-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: compair-mysql 5 | labels: 6 | app: compair 7 | spec: 8 | ports: 9 | - port: 3306 10 | selector: 11 | app: compair 12 | tier: mysql 13 | 14 | --- 15 | 16 | apiVersion: extensions/v1beta1 17 | kind: Deployment 18 | metadata: 19 | name: compair-mysql 20 | labels: 21 | app: compair 22 | spec: 23 | strategy: 24 | type: Recreate 25 | template: 26 | metadata: 27 | labels: 28 | app: compair 29 | tier: mysql 30 | spec: 31 | containers: 32 | - image: mariadb:10.1 33 | name: mysql 34 | env: 35 | # $ kubectl create secret generic mysql-pass --from-file=password.txt 36 | # make sure password.txt does not have a trailing newline 37 | - name: MYSQL_ROOT_PASSWORD 38 | valueFrom: 39 | secretKeyRef: 40 | name: mysql-pass 41 | key: password.txt 42 | - name: MYSQL_DATABASE 43 | value: compair 44 | - name: MYSQL_USER 45 | value: compair 46 | - name: MYSQL_PASSWORD 47 | valueFrom: 48 | secretKeyRef: 49 | name: mysql-pass 50 | key: password.txt 51 | ports: 52 | - containerPort: 3306 53 | name: mysql 54 | volumeMounts: 55 | - name: mysql-persistent-storage 56 | mountPath: /var/lib/mysql 57 | volumes: 58 | - name: mysql-persistent-storage 59 | persistentVolumeClaim: 60 | claimName: mysql-pv-claim 61 | -------------------------------------------------------------------------------- /deploy/gce_kube/nfs-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: PersistentVolumeClaim 3 | metadata: 4 | name: nfs-server-pv-claim 5 | annotations: 6 | volume.beta.kubernetes.io/storage-class: gce-slow 7 | labels: 8 | app: compair 9 | spec: 10 | accessModes: 11 | - ReadWriteOnce 12 | resources: 13 | requests: 14 | storage: 10Gi 15 | 16 | --- 17 | 18 | apiVersion: v1 19 | kind: Service 20 | metadata: 21 | name: compair-nfs-server 22 | labels: 23 | app: compair 24 | spec: 25 | ports: 26 | - port: 2049 27 | name: nfs 28 | - port: 20048 29 | name: mountd 30 | - port: 111 31 | name: rpcbind 32 | selector: 33 | app: compair 34 | tier: nfs 35 | 36 | --- 37 | 38 | apiVersion: extensions/v1beta1 39 | kind: Deployment 40 | metadata: 41 | name: compair-nfs 42 | labels: 43 | app: compair 44 | spec: 45 | replicas: 1 46 | strategy: 47 | type: Recreate 48 | template: 49 | metadata: 50 | labels: 51 | app: compair 52 | tier: nfs 53 | spec: 54 | containers: 55 | - name: nfs-server 56 | image: gcr.io/google_containers/volume-nfs:0.8 57 | ports: 58 | - name: nfs 59 | containerPort: 2049 60 | - name: mountd 61 | containerPort: 20048 62 | - name: rpcbind 63 | containerPort: 111 64 | securityContext: 65 | privileged: true 66 | volumeMounts: 67 | - mountPath: /exports 68 | name: nfs-storage 69 | volumes: 70 | - name: nfs-storage 71 | persistentVolumeClaim: 72 | claimName: nfs-server-pv-claim -------------------------------------------------------------------------------- /deploy/gce_kube/password.txt: -------------------------------------------------------------------------------- 1 | thisiscompairpassword -------------------------------------------------------------------------------- /deploy/gce_kube/persistent-storage.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: PersistentVolume 3 | metadata: 4 | name: nfs-pv 5 | labels: 6 | app: compair 7 | tier: filestore 8 | spec: 9 | capacity: 10 | storage: 10Gi 11 | accessModes: 12 | - ReadWriteMany 13 | nfs: 14 | server: REPLACE_WITH_NFS_SERVICE_IP 15 | path: "/" 16 | 17 | --- 18 | 19 | apiVersion: v1 20 | kind: PersistentVolumeClaim 21 | metadata: 22 | name: nfs-pv-claim 23 | labels: 24 | app: compair 25 | tier: filestore 26 | spec: 27 | accessModes: 28 | - ReadWriteMany 29 | resources: 30 | requests: 31 | storage: 10Gi 32 | selector: 33 | matchLabels: 34 | app: compair 35 | tier: filestore 36 | 37 | --- 38 | 39 | apiVersion: v1 40 | kind: PersistentVolumeClaim 41 | metadata: 42 | name: mysql-pv-claim 43 | annotations: 44 | volume.beta.kubernetes.io/storage-class: gce-slow 45 | labels: 46 | app: compair 47 | spec: 48 | accessModes: 49 | - ReadWriteOnce 50 | resources: 51 | requests: 52 | storage: 2Gi 53 | 54 | --- 55 | 56 | apiVersion: v1 57 | kind: PersistentVolumeClaim 58 | metadata: 59 | name: redis-pv-claim 60 | annotations: 61 | volume.beta.kubernetes.io/storage-class: gce-slow 62 | labels: 63 | app: compair 64 | tier: redis 65 | spec: 66 | accessModes: 67 | - ReadWriteOnce 68 | resources: 69 | requests: 70 | storage: 1Gi -------------------------------------------------------------------------------- /deploy/gce_kube/redis-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: compair-redis 5 | labels: 6 | app: compair 7 | tier: redis 8 | spec: 9 | ports: 10 | - port: 6379 11 | selector: 12 | app: compair 13 | tier: redis 14 | 15 | --- 16 | 17 | apiVersion: extensions/v1beta1 18 | kind: Deployment 19 | metadata: 20 | name: compair-redis 21 | spec: 22 | replicas: 1 23 | strategy: 24 | type: Recreate 25 | template: 26 | metadata: 27 | labels: 28 | app: compair 29 | tier: redis 30 | spec: 31 | containers: 32 | - image: redis:5.0 33 | name: compair-redis 34 | command: ["redis-server", "--appendonly","yes"] 35 | ports: 36 | - containerPort: 6379 37 | name: redis 38 | volumeMounts: 39 | - name: redis-persistent-storage 40 | mountPath: /data 41 | volumes: 42 | - name: redis-persistent-storage 43 | persistentVolumeClaim: 44 | claimName: redis-pv-claim -------------------------------------------------------------------------------- /deploy/linux_server/compair.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=uWSGI instance to serve ComPAIR 3 | After=network.target 4 | 5 | [Service] 6 | WorkingDirectory=/www_data/compair 7 | Environment="PATH=/www_data/compair/.env/bin" 8 | 9 | Environment="DATABASE_URI=mysql+pymysql://compair:compair_password@localhost/compair" 10 | Environment="CELERY_BROKER_URL=redis://0.0.0.0:6379" 11 | Environment="ASSET_LOCATION=local" 12 | 13 | ExecStart=/www_data/compair/.env/bin/uwsgi --ini /etc/uwsgi/uwsgi.ini --logto /var/log/uwsgi.log 14 | 15 | [Install] 16 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /deploy/linux_server/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name SERVER_DOMAIN_OR_IP; 4 | client_max_body_size 250M; 5 | 6 | access_log /var/log/nginx/compair_access.log; 7 | error_log /var/log/nginx/compair_error.log; 8 | 9 | location / { 10 | add_header X-Content-Type-Options nosniff; 11 | 12 | root /www_data/compair/compair; 13 | try_files $uri @compair; 14 | } 15 | 16 | location @compair { 17 | proxy_set_header Host $http_host; 18 | proxy_set_header X-Real-IP $remote_addr; 19 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 20 | proxy_set_header X-Forwarded-Proto $scheme; 21 | 22 | proxy_set_header Upgrade $http_upgrade; 23 | proxy_set_header Connection 'upgrade'; 24 | 25 | client_body_buffer_size 1m; 26 | 27 | proxy_pass http://0.0.0.0:3031; 28 | 29 | proxy_http_version 1.1; 30 | proxy_cache_bypass $http_upgrade; 31 | proxy_intercept_errors on; 32 | proxy_buffering on; 33 | proxy_buffer_size 128k; 34 | proxy_buffers 256 16k; 35 | proxy_busy_buffers_size 256k; 36 | proxy_temp_file_write_size 256k; 37 | proxy_max_temp_file_size 0; 38 | proxy_read_timeout 10m; 39 | proxy_send_timeout 10m; 40 | } 41 | } -------------------------------------------------------------------------------- /deploy/linux_server/uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | chdir = /www_data/compair 3 | wsgi-file = /www_data/compair/main.py 4 | http-socket = 0.0.0.0:3031 5 | master = true 6 | processes = 4 7 | module = main:app 8 | single-interpreter = true 9 | buffer-size = 40000 10 | catch-exceptions = true 11 | die-on-term = true -------------------------------------------------------------------------------- /deploy/linux_server/worker.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Celery instance to perform delayed ComPAIR tasks 3 | After=network.target 4 | 5 | [Service] 6 | WorkingDirectory=/www_data/compair 7 | Environment="PATH=/www_data/compair/.env/bin" 8 | Environment="C_FORCE_ROOT=1" 9 | Environment="DATABASE_URI=mysql+pymysql://compair:compair_password@localhost/compair" 10 | Environment="CELERY_BROKER_URL=redis://0.0.0.0:6379" 11 | Environment="ASSET_LOCATION=local" 12 | ExecStart=/www_data/compair/.env/bin/celery -A celery_worker.celery worker --logfile=/var/log/worker.log 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | #Installation 2 | 3 | ## Deployment Options 4 | 5 | * [Docker](https://github.com/ubc/compair/tree/master/deploy/docker) 6 | * [Kubernetes/GCE](https://github.com/ubc/compair/tree/master/deploy/gce_kube) 7 | * [AWS](https://github.com/ubc/compair/tree/master/deploy/aws) 8 | -------------------------------------------------------------------------------- /lti/README.md: -------------------------------------------------------------------------------- 1 | Forked from pylti: https://github.com/pylti/lti 2 | 3 | We needed to send extra Canvas specific parameters in OutcomeRequest's 4 | post_repalce_result() so that Canvas knows the actual submitted time of student 5 | grades. 6 | -------------------------------------------------------------------------------- /lti/__init__.py: -------------------------------------------------------------------------------- 1 | DEFAULT_LTI_VERSION = 'LTI-1.0' 2 | 3 | # Classes 4 | from .launch_params import LaunchParams 5 | from .tool_base import ToolBase 6 | from .tool_config import ToolConfig 7 | from .tool_consumer import ToolConsumer 8 | from .tool_provider import ToolProvider 9 | from .outcome_request import OutcomeRequest 10 | from .outcome_response import OutcomeResponse 11 | from .contentitem_response import ContentItemResponse 12 | from .tool_proxy import ToolProxy 13 | 14 | # Exceptions 15 | from .utils import InvalidLTIConfigError, InvalidLTIRequestError 16 | -------------------------------------------------------------------------------- /lti/contentitem_response.py: -------------------------------------------------------------------------------- 1 | 2 | from .launch_params import CONTENT_PARAMS_REQUIRED 3 | 4 | from .tool_outbound import ToolOutbound 5 | 6 | class ContentItemResponse(ToolOutbound): 7 | 8 | def has_required_params(self): 9 | return all([ 10 | self.launch_params.get(x) for x in CONTENT_PARAMS_REQUIRED 11 | ]) 12 | -------------------------------------------------------------------------------- /lti/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/lti/contrib/__init__.py -------------------------------------------------------------------------------- /lti/contrib/flask/__init__.py: -------------------------------------------------------------------------------- 1 | from .flask_tool_provider import FlaskToolProvider 2 | -------------------------------------------------------------------------------- /lti/contrib/flask/flask_tool_provider.py: -------------------------------------------------------------------------------- 1 | from lti import ToolProvider 2 | 3 | 4 | class FlaskToolProvider(ToolProvider): 5 | ''' 6 | ToolProvider that works with Flask requests 7 | ''' 8 | @classmethod 9 | def from_flask_request(cls, secret=None, request=None): 10 | if request is None: 11 | raise ValueError('request must be supplied') 12 | 13 | params = request.form.copy() 14 | headers = dict(request.headers) 15 | url = request.url 16 | return cls.from_unpacked_request(secret, params, url, headers) 17 | -------------------------------------------------------------------------------- /lti/tool_consumer.py: -------------------------------------------------------------------------------- 1 | 2 | from .launch_params import LAUNCH_PARAMS_REQUIRED 3 | 4 | from .tool_outbound import ToolOutbound 5 | 6 | class ToolConsumer(ToolOutbound): 7 | 8 | def has_required_params(self): 9 | return all([ 10 | self.launch_params.get(x) for x in LAUNCH_PARAMS_REQUIRED 11 | ]) 12 | 13 | def set_config(self, config): 14 | ''' 15 | Set launch data from a ToolConfig. 16 | ''' 17 | if self.launch_url is None: 18 | self.launch_url = config.launch_url 19 | self.launch_params.update(config.custom_params) 20 | -------------------------------------------------------------------------------- /lti/tool_proxy.py: -------------------------------------------------------------------------------- 1 | from requests import Request 2 | from .tool_base import ToolBase 3 | import requests 4 | import json 5 | from requests_oauthlib import OAuth1 6 | from requests_oauthlib.oauth1_auth import SIGNATURE_TYPE_AUTH_HEADER 7 | 8 | class ToolProxy(ToolBase): 9 | def load_tc_profile(self): 10 | response = requests.get(self.tool_consumer_profile_url) 11 | 12 | self.tc_profile = json.loads(response.text) 13 | 14 | @property 15 | def tool_consumer_profile_url(self): 16 | return self.launch_params['tc_profile_url'] 17 | 18 | def find_registration_url(self): 19 | for service in self.tc_profile["service_offered"]: 20 | if "application/vnd.ims.lti.v2.toolproxy+json" in service["format"] and "POST" in service["action"]: 21 | return service["endpoint"] 22 | 23 | def register_proxy(self, tool_profile): 24 | register_url = self.find_registration_url() 25 | 26 | r = Request("POST", register_url, data=json.dumps(tool_profile, indent=4), headers={'Content-Type':'application/vnd.ims.lti.v2.toolproxy+json'}).prepare() 27 | sign = OAuth1(self.launch_params['reg_key'], self.launch_params['reg_password'], 28 | signature_type=SIGNATURE_TYPE_AUTH_HEADER, force_include_body=True) 29 | signed = sign(r) 30 | 31 | return signed 32 | 33 | 34 | -------------------------------------------------------------------------------- /lti/utils.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid1 2 | 3 | try: 4 | import urllib.parse as urlparse 5 | except ImportError: 6 | import urlparse # Python 2 7 | 8 | 9 | def parse_qs(qs, keep_blank_values=False): 10 | params = urlparse.parse_qs( 11 | qs, keep_blank_values=int(keep_blank_values)).items() 12 | return dict((k, v if len(v) > 1 else v[0]) for k, v in params) 13 | 14 | 15 | def generate_identifier(): 16 | return uuid1().__str__() 17 | 18 | 19 | class InvalidLTIConfigError(Exception): 20 | def __init__(self, value): 21 | self.value = value 22 | 23 | def __str__(self): 24 | return repr(self.value) 25 | 26 | 27 | class InvalidLTIRequestError(Exception): 28 | def __init__(self, value): 29 | self.value = value 30 | 31 | def __str__(self): 32 | return repr(self.value) 33 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import os 2 | from compair import create_app 3 | 4 | app = create_app() 5 | 6 | # needed to get flask debugging working with uwsgi in docker 7 | # (since it ignores FLASK_DEBUG & DEBUG) 8 | if os.environ.get('DEV') == '1': 9 | app.config['DEBUG'] = True 10 | app.debug = True -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from requests.utils import unquote 3 | 4 | from flask_script import Manager, Server 5 | 6 | from compair.manage.database import manager as database_manager 7 | from compair.manage.report import manager as report_generator 8 | from compair.manage.grades import manager as grades_generator 9 | from compair.manage.score import manager as score_generator 10 | from compair.manage.user import manager as user_manager 11 | from compair.manage.utils import manager as util_manager 12 | from compair.manage.kaltura import manager as kaltura_manager 13 | from compair import create_app 14 | 15 | manager = Manager(create_app(skip_assets=True)) 16 | # register sub-managers 17 | manager.add_command("database", database_manager) 18 | manager.add_command("report", report_generator) 19 | manager.add_command("grades", grades_generator) 20 | manager.add_command("score", score_generator) 21 | manager.add_command("runserver", Server(port=8080)) 22 | manager.add_command("user", user_manager) 23 | manager.add_command("util", util_manager) 24 | manager.add_command("kaltura", kaltura_manager) 25 | 26 | 27 | @manager.command 28 | def list_routes(): 29 | import urllib 30 | 31 | output = [] 32 | for rule in manager.app.url_map.iter_rules(): 33 | methods = ','.join(rule.methods) 34 | line = unquote("{:50s} {:20s} {}".format(rule.endpoint, methods, rule)) 35 | output.append(line) 36 | 37 | for line in sorted(output): 38 | print(line) 39 | 40 | 41 | if __name__ == "__main__": 42 | manager.run() 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ComPAIR", 3 | "version": "1.2.11", 4 | "license": "MIT", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/ubc/compair.git" 8 | }, 9 | "dependencies": { 10 | "bower": "^1.8.14", 11 | "gulp": "^4.0.2", 12 | "gulp-angular-templatecache": "^3.0.1", 13 | "gulp-bower": "^0.0.15", 14 | "gulp-clean-css": "^4.3.0", 15 | "gulp-cli": "^2.3.0", 16 | "gulp-concat": "^2.2.0", 17 | "gulp-concat-vendor": "^0.0.4", 18 | "gulp-connect": "^5.7.0", 19 | "gulp-html-replace": "^1.6.2", 20 | "gulp-inject": "^5.0.5", 21 | "gulp-less": "^5.0.0", 22 | "gulp-print": "^5.0.2", 23 | "gulp-rev": "^9.0.0", 24 | "gulp-sort": "^2.0.0", 25 | "gulp-uglify": "^3.0.1", 26 | "less": "~4.1.3", 27 | "main-bower-files": "^2.13.3", 28 | "proxy-middleware": "^0.15.0", 29 | "streamqueue": "^1.1.2", 30 | "wiredep": "^4.0.0" 31 | }, 32 | "devDependencies": { 33 | "chai": "~4.1", 34 | "chai-as-promised": "~7.1", 35 | "cucumber": "~1.3.3", 36 | "deepcopy": "~0.6.3", 37 | "del": "^3.0.0", 38 | "gulp-protractor": "~4.1.1", 39 | "jasmine-core": "^4.1.1", 40 | "karma": "^6.4.2", 41 | "karma-chrome-launcher": "~3.2", 42 | "karma-jasmine": "^5.0.1", 43 | "karma-junit-reporter": "^2.0.1", 44 | "karma-ng-html2js-preprocessor": "~1.0", 45 | "lodash": "^4.17.21", 46 | "object-assign": "~4.1", 47 | "protractor-cucumber-framework": "~4.1.1", 48 | "puppeteer": "^13.7.0", 49 | "sauce-connect-launcher": "^1.2.4", 50 | "webdriver-manager": "~12.1.8" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /persistent/report/anchor.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/persistent/report/anchor.txt -------------------------------------------------------------------------------- /persistent/tmp/anchor.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/persistent/tmp/anchor.txt -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | # scipy 1.8 seems to be missing packages for python 3.7, so we'll stay with 2 | # 1.7.3 until python 3.7 is dropped 3 | scipy==1.7.3 4 | # numpy 1.22 seems to be missing packages for python 3.7, so we'll stay with 5 | # 1.21.6 until python 3.7 is dropped 6 | numpy==1.22.0 7 | pillow==9.3.0 -------------------------------------------------------------------------------- /scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubc/compair/472accb76a754ba5e70f67218e4e78040984003a/scripts/__init__.py -------------------------------------------------------------------------------- /scripts/extract-answers.awk: -------------------------------------------------------------------------------- 1 | # extract-answers.awk - reads csv files of ComPAIR answers and generates txt files, one containing each answer's text 2 | # 3 | # Revisions: 4 | # 2019-03-08 - new 5 | # 6 | # Sample usage: awk -f extract-answers.awk answers.csv 7 | 8 | BEGIN { 9 | # note: make replacements to csv before running this script. 10 | # 1. \n to " " 11 | # 2. \r to \r\n 12 | # run first with debug on to check output before saving to txt files 13 | debug = 0; # debug level: 0=none 14 | assign = 2; # assignment number 15 | # columns 16 | if (assign == 1) 17 | { first = 5; 18 | last = 4; 19 | stnum = 6; 20 | ans = 8; 21 | } 22 | else if (assign == 2) 23 | { first = 2; 24 | last = 1; 25 | stnum = 3; 26 | ans = 5; 27 | } 28 | else 29 | { exit 1; 30 | } 31 | 32 | # defaults 33 | BINMODE = 3; # don't replace line endings 34 | RS = "\r\n"; # in-record linebreaks are only \n newlines so safe to separate records by [CR][LF] 35 | FS = ","; # comma-separated values 36 | FPAT = "([^,]+)|(\"[^\"]+\")"; # from https://www.gnu.org/software/gawk/manual/html_node/Splitting-By-Content.html. Requires Gawk v4+. 37 | } 38 | 39 | # test 40 | { # strip quotes from answer 41 | if (substr($ans, 1, 1) == "\"") 42 | { len = length($ans); 43 | $ans = substr($ans, 2, len - 2); # Get text within the two quotes 44 | } 45 | 46 | # generate filename 47 | fn = $first "_" $last "_" $stnum; 48 | gsub(/\W/,"_",fn); # replace all non- word-constituent characters 49 | fn = fn ".txt"; 50 | 51 | if (debug) 52 | { print fn ":"; 53 | print $ans; 54 | } 55 | else 56 | { print "Saving to " fn; 57 | print $ans > fn; 58 | } 59 | } 60 | --------------------------------------------------------------------------------