├── schema.png ├── test ├── fixtures │ ├── upload.txt │ ├── upload.pdf │ ├── upload.png │ ├── upload.wav │ ├── custom_cassettes │ │ ├── chatbot │ │ │ └── chat_conversation#1.json │ │ ├── adfs │ │ │ ├── authorise#5.json │ │ │ ├── authorise#4.json │ │ │ ├── authorise#3.json │ │ │ ├── authorise#2.json │ │ │ ├── authorise#1.json │ │ │ ├── authorise#6.json │ │ │ └── authorise#7.json │ │ └── aws │ │ │ ├── devices_get_cert#3.json │ │ │ ├── devices_get_ws_endpoint#2.json │ │ │ └── devices_get_endpoint_address#1.json │ └── vcr_cassettes │ │ └── aws │ │ ├── model_delete_asset#1.json │ │ ├── model_upload_asset#1.json │ │ └── model_list_assets#2.json ├── test_helper.exs ├── cadet_web │ ├── router_test.exs │ ├── controllers │ │ └── default_controller_test.exs │ ├── views │ │ ├── answer_view_test.exs │ │ ├── error_view_test.exs │ │ └── team_view_test.exs │ └── plug │ │ ├── assign_current_user_test.exs │ │ └── rate_limiter_test.exs ├── factories │ ├── accounts │ │ ├── team_factory.ex │ │ ├── notification_factory.ex │ │ ├── team_member_factory.ex │ │ ├── course_registration_factory.ex │ │ └── user_factory.ex │ ├── courses │ │ ├── group_factory.ex │ │ ├── sourcecast_factory.ex │ │ ├── assessment_config_factory.ex │ │ └── course_factory.ex │ ├── notifications │ │ ├── time_option_factory.ex │ │ ├── notification_type_factory.ex │ │ ├── notifcation_config_factory.ex │ │ └── notification_preference_factory.ex │ ├── devices │ │ └── device_factory.ex │ ├── assessments │ │ ├── submission_factory.ex │ │ ├── submission_vote_factory.ex │ │ ├── answer_factory.ex │ │ └── library_factory.ex │ ├── chatbot │ │ └── conversation_factory.ex │ ├── achievements │ │ ├── goal_factory.ex │ │ └── achievement_factory.ex │ ├── stories │ │ └── story_factory.ex │ └── factory.ex ├── cadet │ ├── assessments │ │ ├── external_library_test.exs │ │ ├── answer_types │ │ │ ├── programming_answer_test.exs │ │ │ └── mcq_answer_test.exs │ │ ├── question_types │ │ │ ├── mcq_question_test.exs │ │ │ ├── programming_question_test.exs │ │ │ ├── mcq_choice_test.exs │ │ │ ├── voting_question_test.exs │ │ │ └── programming_question_testcases_test.exs │ │ └── query_test.exs │ ├── auth │ │ ├── providers │ │ │ ├── saml │ │ │ │ ├── nusstf_assertion_extractor_test.exs │ │ │ │ └── nusstu_assertion_extractor_test.exs │ │ │ ├── openid │ │ │ │ ├── cognito_claim_extractor_test.exs │ │ │ │ ├── google_claim_extractor_test.exs │ │ │ │ └── auth0_claim_extractor_test.exs │ │ │ └── config_test.exs │ │ ├── empty_guardian_test.exs │ │ ├── provider_test.exs │ │ └── guardian_test.exs │ ├── incentives │ │ ├── goal_progress_test.exs │ │ ├── goal_test.exs │ │ ├── achievement_to_goal_test.exs │ │ ├── achievement_prerequisite_test.exs │ │ └── achievement_test.exs │ ├── accounts │ │ ├── user_test.exs │ │ └── team_members_test.exs │ ├── helpers │ │ └── display_helper_test.exs │ └── jobs │ │ └── log_test.exs └── support │ ├── assert_helper.ex │ └── test_entity_helper.ex ├── config └── releases.exs ├── .formatter.exs ├── deployment ├── terraform │ ├── versions.tf │ ├── s3.tf │ ├── main.tf │ ├── sm.tf │ ├── lambda.tf │ └── rds.tf └── cadet.service ├── lib ├── cadet_web │ ├── views │ │ ├── email_view.ex │ │ ├── layout_view.ex │ │ ├── answer_view.ex │ │ ├── auth_view.ex │ │ ├── error_view.ex │ │ ├── chat_view.ex │ │ ├── devices_view.ex │ │ ├── stories_view.ex │ │ ├── notifications_view.ex │ │ ├── sourcecast_view.ex │ │ ├── team_view.ex │ │ └── courses_view.ex │ ├── templates │ │ ├── layout │ │ │ └── email.html.eex │ │ └── email │ │ │ ├── assessment_submission.html.eex │ │ │ └── avenger_backlog.html.eex │ ├── controllers │ │ ├── default_controller.ex │ │ ├── jwks_controller.ex │ │ └── stories_controller.ex │ ├── admin_views │ │ ├── admin_assets_view.ex │ │ ├── admin_courses_view.ex │ │ ├── admin_user_view.ex │ │ ├── admin_goals_view.ex │ │ └── admin_teams_view.ex │ ├── plug │ │ ├── assign_current_user.ex │ │ └── rate_limiter.ex │ ├── gettext.ex │ └── helpers │ │ └── controller_helper.ex ├── cadet │ ├── assessments │ │ ├── assessment_access.ex │ │ ├── question_type.ex │ │ ├── submission_status.ex │ │ ├── autograding_status.ex │ │ ├── upload.ex │ │ ├── answer_types │ │ │ ├── voting_answer.ex │ │ │ ├── programming_answer.ex │ │ │ └── mcq_answer.ex │ │ ├── external_library.ex │ │ ├── question_types │ │ │ ├── programming_question_testcases.ex │ │ │ ├── mcq_choice.ex │ │ │ ├── voting_question.ex │ │ │ ├── programming_question.ex │ │ │ └── mcq_question.ex │ │ └── submission_votes.ex │ ├── mailer.ex │ ├── auth │ │ ├── error_handler.ex │ │ ├── empty_guardian.ex │ │ ├── providers │ │ │ ├── saml │ │ │ │ ├── nusstf_assertion_extractor.ex │ │ │ │ └── nusstu_assertion_extractor.ex │ │ │ ├── openid │ │ │ │ ├── cognito_claim_extractor.ex │ │ │ │ ├── auth0_claim_extractor.ex │ │ │ │ ├── google_claim_extractor.ex │ │ │ │ ├── mit_claim_extractor.ex │ │ │ │ └── mit_csail_claim_extractor.ex │ │ │ └── config.ex │ │ └── pipeline.ex │ ├── jobs │ │ └── scheduler.ex │ ├── accounts │ │ ├── role.ex │ │ ├── notification_type.ex │ │ ├── user.ex │ │ ├── course_registration.ex │ │ ├── notification.ex │ │ └── query.ex │ ├── env.ex │ ├── release.ex │ ├── helpers │ │ ├── context_helper.ex │ │ ├── aws_helper.ex │ │ └── display_helper.ex │ ├── program_analysis │ │ └── lexer.ex │ ├── devices │ │ ├── device.ex │ │ └── device_registration.ex │ ├── notifications │ │ ├── sent_notification.ex │ │ ├── time_option.ex │ │ ├── notification_config.ex │ │ ├── notification_preference.ex │ │ └── notification_type.ex │ ├── courses │ │ └── sourcecast_upload.ex │ ├── ai_comments │ │ └── ai_comment.ex │ ├── incentives │ │ ├── achievement_to_goal.ex │ │ ├── goal.ex │ │ ├── goal_progress.ex │ │ └── achievement_prerequisite.ex │ ├── repo.ex │ ├── chatbot │ │ ├── conversation.ex │ │ └── prompt_builder.ex │ ├── stories │ │ └── story.ex │ ├── email.ex │ └── code_exchange.ex ├── mix │ └── tasks │ │ └── server.ex └── cadet.ex ├── priv ├── static │ └── favicon.ico └── repo │ └── migrations │ ├── 20190510152804_drop_announcements_table.exs │ ├── 20210908024720_rename_rank_to_score.exs │ ├── 20190713080756_alter_answers_table_comment.exs │ ├── 20200522174343_remove_chatkit.exs │ ├── 20180815021653_add_unique_constraint_on_group_names.exs │ ├── 20180821164317_add_index_on_student_id_in_submission.exs │ ├── 20200522174509_remove_materials.exs │ ├── 20180719122514_add_index_questions_table_assessment_id.exs │ ├── 20210719091011_rename_latest_viewed_id.exs │ ├── 20230215051347_users_add_email_column.exs │ ├── 20190729154942_restore_comments.exs │ ├── 20180728050601_remove_title_from_questions.exs │ ├── 20180803163101_add_unique_constraint_to_assessment_number.exs │ ├── 20200410124849_create_chapters.exs │ ├── 20210915125021_add_reveal_hours.exs │ ├── 20210804182725_add_assets_prefix.exs │ ├── 20200423155735_add_variant_to_chapters.exs │ ├── 20200804042151_update_user_game_state.exs │ ├── 20210502052637_add_relative_score.exs │ ├── 20190318070229_alter_answers_table_autograding_errors.exs │ ├── 20190619083156_add_password_to_assessments.exs │ ├── 20210809034149_alter_module_help_text.exs │ ├── 20181231071350_add_grader_id_to_answers.exs │ ├── 20220703143832_add_user_super_admin.exs │ ├── 20200410074625_add_game_states.exs │ ├── 20200702145245_refactor_default_chapter.exs │ ├── 20240213081500_answers_add_popular_score_column.exs │ ├── 20180719111804_alter_questions_table_library_not_null.exs │ ├── 20210122162606_add_voting_question_type.exs │ ├── 20210804050449_remove_achievement_ability.exs │ ├── 20240804095846_add_prepend_context_to_llm_chatroom.exs │ ├── 20200527092617_overhaul_auth.exs │ ├── 20210707040448_add_research_agreement_toggle.exs │ ├── 20240322122108_add_is_grading_published.exs │ ├── 20240805214100_submissions_add_submitted_at_column.exs │ ├── 20240214125701_add_max_team_size_to_assessments.exs │ ├── 20240221032615_create_teams_table.exs │ ├── 20241014200600_create_teams_submission_constraint.exs │ ├── 20250330104845_add_is_minigame_to_assessment_config.exs │ ├── 20251028050808_add_llm_assessment_prompt_assessment.exs │ ├── 20180809071346_add_autograding_errors_to_answers.exs │ ├── 20240222094759_add_last_modified_to_answers.exs │ ├── 20190919022834_update_assessment_type_enum.exs │ ├── 20231105164101_create_submissions_assessment_index.exs │ ├── 20190530065753_add_unsubmit_fields.exs │ ├── 20210503200828_add_job_log.exs │ ├── 20240714233659_create_llm_chats_table.exs │ ├── 20220813085830_drop_group_name_constraint.exs │ ├── 20230311105547_populate_nus_student_emails.exs │ ├── 20240221033554_create_team_members_table.exs │ ├── 20251008160219_add_llm_grading_access.exs │ ├── 20200803100910_update_sourcecast.exs │ ├── 20230331010500_remove_unique_score_constraint.exs │ ├── 20240116114642_add_stories_toggle_to_course_config.exs │ ├── 20230214081421_add_oban_jobs_table.exs │ ├── 20180526084150_create_submissions.exs │ ├── 20180113145459_create_announcements.exs │ ├── 20240331222300_add_is_grading_auto_published.exs │ ├── 20210323154140_change_achievements.exs │ ├── 20230214143617_create_sent_notifications.exs │ ├── 20240206045330_add_has_token_counter_toggle_to_assessment_config.exs │ ├── 20250417093922_create_token_exchange_table.exs │ ├── 20190713144515_update_materials.exs │ ├── 20240321141522_add_has_voting_features_toggle_to_assessment_config.exs │ ├── 20180818060805_add_xp_to_assessments.exs │ ├── 20190701013010_create_sourcecast.exs │ ├── 20180526055901_guardiandb.exs │ ├── 20190713140211_create_categories.exs │ ├── 20251022103623_create_ai_comments.exs │ ├── 20230215092543_add_avenger_backlog_notification_type.exs │ ├── 20180101144251_create_users.exs │ ├── 20180728073014_create_submission_status.exs │ ├── 20200707084425_create_achievement_goals.exs │ ├── 20180117144515_create_materials.exs │ ├── 20190603023734_add_notifications.exs │ ├── 20240320154407_add_has_token_counter_toggle_to_assessment.exs │ ├── 20180804035838_add_autograding_fields.exs │ ├── 20230214065925_create_notification_types.exs │ ├── 20230215072400_add_assessment_submission_notification_type.exs │ ├── 20180117125701_create_groups.exs │ ├── 20200715022804_create_achievement_prerequisites.exs │ ├── 20200719200931_create_achievement_progress.exs │ ├── 20210205030432_create_submission_votes_table.exs │ ├── 20210415034407_alter_achievements_datetime_nullable.exs │ ├── 20200812202222_remove_assessment_type_enum.exs │ ├── 20251019160255_add_llm_api_key_to_courses.exs │ ├── 20230214132717_create_time_options.exs │ ├── 20230214074219_create_notification_configs.exs │ ├── 20240322184853_update_is_grading_published.exs │ ├── 20180815021708_remove_constraints_from_leader_in_group.exs │ ├── 20210807040646_add_provider_to_user.exs │ ├── 20200603095201_create_stories_table.exs │ ├── 20210713113032_update_testcase_format.exs │ ├── 20230214140555_create_notification_preferences.exs │ ├── 20180526050817_create_questions.exs │ ├── 20200706185258_create_devices.exs │ ├── 20180704020027_create_answers.exs │ ├── 20180101181301_create_authorizations.exs │ ├── 20240221033707_alter_submissions_table.exs │ ├── 20250429081534_add_leaderboard_display_columns.exs │ ├── 20200707073617_create_achievements.exs │ ├── 20180119002258_create_assessments.exs │ ├── 20230215091253_add_notification_configs_courses_trigger.exs │ ├── 20230215091948_add_notification_configs_assessments_trigger.exs │ └── 20210716073359_update_achievement.exs ├── .iex.exs ├── .editorconfig ├── coveralls.json ├── src └── source_lexer.xrl └── .github └── dependabot.yml /schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/source-academy/backend/HEAD/schema.png -------------------------------------------------------------------------------- /test/fixtures/upload.txt: -------------------------------------------------------------------------------- 1 | This file exist for testing file upload. DO NOT REMOVE -------------------------------------------------------------------------------- /config/releases.exs: -------------------------------------------------------------------------------- 1 | "CONFIG" |> System.get_env("/etc/cadet.exs") |> Code.eval_file() 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["mix.exs", "{config,lib,priv,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /deployment/terraform/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 0.13" 3 | } 4 | -------------------------------------------------------------------------------- /lib/cadet_web/views/email_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.EmailView do 2 | use CadetWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/cadet_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.LayoutView do 2 | use CadetWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/source-academy/backend/HEAD/priv/static/favicon.ico -------------------------------------------------------------------------------- /test/fixtures/upload.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/source-academy/backend/HEAD/test/fixtures/upload.pdf -------------------------------------------------------------------------------- /test/fixtures/upload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/source-academy/backend/HEAD/test/fixtures/upload.png -------------------------------------------------------------------------------- /test/fixtures/upload.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/source-academy/backend/HEAD/test/fixtures/upload.wav -------------------------------------------------------------------------------- /lib/cadet_web/templates/layout/email.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= @inner_content %> 6 | 7 | 8 | -------------------------------------------------------------------------------- /lib/cadet/assessments/assessment_access.ex: -------------------------------------------------------------------------------- 1 | import EctoEnum 2 | 3 | defenum(Cadet.Assessments.AssessmentAccess, :assessment_access, [ 4 | :public, 5 | :private 6 | ]) 7 | -------------------------------------------------------------------------------- /lib/cadet/assessments/question_type.ex: -------------------------------------------------------------------------------- 1 | import EctoEnum 2 | 3 | defenum(Cadet.Assessments.QuestionType, :question_type, [ 4 | :programming, 5 | :mcq, 6 | :voting 7 | ]) 8 | -------------------------------------------------------------------------------- /lib/cadet/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Mailer do 2 | @moduledoc """ 3 | Mailer used to sent notification emails. 4 | """ 5 | use Bamboo.Mailer, otp_app: :cadet 6 | end 7 | -------------------------------------------------------------------------------- /lib/cadet/assessments/submission_status.ex: -------------------------------------------------------------------------------- 1 | import EctoEnum 2 | 3 | defenum(Cadet.Assessments.SubmissionStatus, :submission_status, [ 4 | :attempting, 5 | :attempted, 6 | :submitted 7 | ]) 8 | -------------------------------------------------------------------------------- /.iex.exs: -------------------------------------------------------------------------------- 1 | import Ecto.Query 2 | alias Cadet.Repo 3 | alias Cadet.Accounts.{User, Team, TeamMember} 4 | alias Cadet.Assessments.{Answer, Assessment, Question, Submission} 5 | alias Cadet.Courses.Group 6 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | System.put_env("LEADER", "1") 2 | 3 | {:ok, _} = Application.ensure_all_started(:ex_machina) 4 | 5 | ExUnit.start() 6 | Faker.start() 7 | 8 | Ecto.Adapters.SQL.Sandbox.mode(Cadet.Repo, :manual) 9 | -------------------------------------------------------------------------------- /lib/cadet_web/controllers/default_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.DefaultController do 2 | use CadetWeb, :controller 3 | 4 | def index(conn, _) do 5 | text(conn, "Welcome to the Source Academy Backend!") 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190510152804_drop_announcements_table.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.DropAnnouncementsTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | drop_if_exists(table(:announcements)) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210908024720_rename_rank_to_score.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.RenameRankToScore do 2 | use Ecto.Migration 3 | 4 | def change do 5 | rename(table(:submission_votes), :rank, to: :score) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190713080756_alter_answers_table_comment.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AlterAnswersTableComment do 2 | use Ecto.Migration 3 | 4 | def change do 5 | rename(table(:answers), :comment, to: :room_id) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200522174343_remove_chatkit.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.RemoveChatkit do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:answers) do 6 | remove(:room_id) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/cadet_web/views/answer_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.AnswerView do 2 | use CadetWeb, :view 3 | 4 | def render("lastModified.json", %{lastModified: lastModified}) do 5 | %{ 6 | lastModified: lastModified 7 | } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180815021653_add_unique_constraint_on_group_names.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddUniqueConstraintOnGroupNames do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create(unique_index(:groups, [:name])) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180821164317_add_index_on_student_id_in_submission.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddIndexOnStudentIdInSubmission do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create(index(:submissions, :student_id)) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200522174509_remove_materials.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.RemoveMaterials do 2 | use Ecto.Migration 3 | 4 | def change do 5 | drop_if_exists(table(:materials)) 6 | drop_if_exists(table(:categories)) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180719122514_add_index_questions_table_assessment_id.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddIndexQuestionsTableAssessmentId do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create(index(:questions, [:assessment_id])) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210719091011_rename_latest_viewed_id.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.RenameLatestViewedId do 2 | use Ecto.Migration 3 | 4 | def change do 5 | rename(table(:users), :latest_viewed_id, to: :latest_viewed_course_id) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230215051347_users_add_email_column.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.UsersAddEmailColumn do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add(:email, :string) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190729154942_restore_comments.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.RestoreComments do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:answers) do 6 | add(:comments, :text, null: true) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{ex,eex,exs,ts,tsx,json}] 12 | indent_style = space 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /lib/cadet_web/views/auth_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.AuthView do 2 | use CadetWeb, :view 3 | 4 | def render("token.json", %{access_token: access_token, refresh_token: refresh_token}) do 5 | %{access_token: access_token, refresh_token: refresh_token} 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180728050601_remove_title_from_questions.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.RemoveTitleFromQuestions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:questions) do 6 | remove(:title) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180803163101_add_unique_constraint_to_assessment_number.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddUniqueConstraintToAssessmentNumber do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create(unique_index(:assessments, [:number])) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200410124849_create_chapters.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateChapters do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:chapters) do 6 | add(:chapterno, :integer, null: false) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210915125021_add_reveal_hours.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddRevealHours do 2 | use Ecto.Migration 3 | 4 | def change do 5 | execute("update questions set question = question || jsonb_build_object('reveal_hours', 48)") 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210804182725_add_assets_prefix.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddAssetsPrefix do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:courses) do 6 | add(:assets_prefix, :string, null: true) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200423155735_add_variant_to_chapters.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddVariantToChapters do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:chapters) do 6 | add(:variant, :string, null: false) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200804042151_update_user_game_state.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.UpdateUserGameState do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | modify(:game_states, :map, default: %{}) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210502052637_add_relative_score.exs: -------------------------------------------------------------------------------- 1 | defmodule Elixir.Cadet.Repo.Migrations.AddRelativeScore do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:answers) do 6 | add(:relative_score, :float, default: 0.0) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/mix/tasks/server.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Cadet.Server do 2 | @moduledoc """ 3 | Run the Cadet server. 4 | Currently it is equivalent with `phx.server` 5 | """ 6 | use Mix.Task 7 | 8 | def run(args) do 9 | :ok = Mix.Tasks.Phx.Server.run(args) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190318070229_alter_answers_table_autograding_errors.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AlterAnswersTableAutogradingErrors do 2 | use Ecto.Migration 3 | 4 | def change do 5 | rename(table(:answers), :autograding_errors, to: :autograding_results) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190619083156_add_password_to_assessments.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddPasswordToAssessments do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:assessments) do 6 | add(:password, :text, null: true) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210809034149_alter_module_help_text.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AlterModuleHelpText do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:courses) do 6 | modify(:module_help_text, :text, from: :string) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/cadet/assessments/autograding_status.ex: -------------------------------------------------------------------------------- 1 | import EctoEnum 2 | 3 | defenum(Cadet.Assessments.Answer.AutogradingStatus, :autograding_status, [ 4 | :none, 5 | :processing, 6 | :success, 7 | # note that :failed refers to the autograder failing due to system errors 8 | :failed 9 | ]) 10 | -------------------------------------------------------------------------------- /lib/cadet/auth/error_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Auth.ErrorHandler do 2 | @moduledoc """ 3 | Handles authentication errors 4 | """ 5 | import Plug.Conn 6 | 7 | def auth_error(conn, {_type, _reason}, _opts) do 8 | conn 9 | |> send_resp(401, "Unauthorised") 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/migrations/20181231071350_add_grader_id_to_answers.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddGraderIdToAnswers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:answers) do 6 | add(:grader_id, references(:users), null: true) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220703143832_add_user_super_admin.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddUserSuperAdmin do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add(:super_admin, :boolean, null: false, default: false) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200410074625_add_game_states.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddGameStates do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add(:game_states, :map, default: %{collectibles: %{}, completed_quests: []}) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200702145245_refactor_default_chapter.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.RefactorDefaultChapter do 2 | use Ecto.Migration 3 | 4 | def change do 5 | rename(table(:chapters), to: table(:sublanguages)) 6 | rename(table(:sublanguages), :chapterno, to: :chapter) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240213081500_answers_add_popular_score_column.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AnswersAddPopularScoreColumn do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table("answers") do 6 | add(:popular_score, :float, default: 0.0) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/cadet_web/router_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.RouterTest do 2 | use CadetWeb.ConnCase 3 | 4 | alias CadetWeb.Router 5 | 6 | test "Swagger", %{conn: conn} do 7 | Router.swagger_info() 8 | conn = get(conn, "/swagger/index.html") 9 | assert response(conn, 200) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180719111804_alter_questions_table_library_not_null.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AlterQuestionsTableLibraryNotNull do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:questions) do 6 | modify(:library, :map, null: false) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210122162606_add_voting_question_type.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AdaddVotingQuestionType do 2 | use Ecto.Migration 3 | @disable_ddl_transaction true 4 | 5 | def change do 6 | Ecto.Migration.execute("ALTER TYPE question_type ADD VALUE IF NOT EXISTS 'voting'") 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210804050449_remove_achievement_ability.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.RemoveAchievementAbility do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:achievements) do 6 | remove(:ability, :text, null: false, default: "Core") 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240804095846_add_prepend_context_to_llm_chatroom.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddPrependContextToLlmChatroom do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:llm_chats) do 6 | add(:prepend_context, :jsonb, null: false) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/cadet/assessments/upload.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.Upload do 2 | @moduledoc """ 3 | Uploaded PDF file for the mission 4 | """ 5 | use Cadet, :remote_assets 6 | 7 | @versions [:original] 8 | 9 | def storage_dir(_, _) do 10 | "uploads/#{Cadet.Env.env()}/mission_pdf" 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/cadet_web/templates/email/assessment_submission.html.eex: -------------------------------------------------------------------------------- 1 |

Dear <%= @avenger_name %>,

2 | 3 |

There is a new submission by <%= @student_name %> for <%= @assessment_title %>. Please Review and grade the submission

4 | 5 | Unsubscribe from this email topic. 6 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200527092617_overhaul_auth.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.OverhaulAuth do 2 | use Ecto.Migration 3 | 4 | def up do 5 | rename(table(:users), :nusnet_id, to: :username) 6 | drop(table(:authorizations)) 7 | Ecto.Migration.execute("DROP TYPE provider") 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210707040448_add_research_agreement_toggle.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddResearchAgreementToggle do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:course_registrations) do 6 | add(:agreed_to_research, :boolean, null: true) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240322122108_add_is_grading_published.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddIsGradingPublished do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:submissions) do 6 | add(:is_grading_published, :boolean, null: false, default: false) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240805214100_submissions_add_submitted_at_column.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.SubmissionsAddSubmittedAtColumn do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:submissions) do 6 | add(:submitted_at, :timestamp, null: true) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/cadet/jobs/scheduler.ex: -------------------------------------------------------------------------------- 1 | # credo:disable-for-this-file Credo.Check.Readability.ModuleDoc 2 | # @moduledoc is actually generated by a macro inside Quantum 3 | defmodule Cadet.Jobs.Scheduler do 4 | @moduledoc """ 5 | Quantum is used for scheduling jobs with cron jobs. 6 | """ 7 | use Quantum, otp_app: :cadet 8 | end 9 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240214125701_add_max_team_size_to_assessments.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddMaxTeamSizeToAssessments do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:assessments) do 6 | add(:max_team_size, :integer, null: false, default: 1) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240221032615_create_teams_table.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateTeamsTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:teams) do 6 | add(:assessment_id, references(:assessments), null: false) 7 | timestamps() 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /priv/repo/migrations/20241014200600_create_teams_submission_constraint.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateTeamsSubmissionConstraint do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create(index(:submissions, :team_id)) 6 | create(unique_index(:submissions, [:assessment_id, :team_id])) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/cadet/accounts/role.ex: -------------------------------------------------------------------------------- 1 | import EctoEnum 2 | 3 | defenum(Cadet.Accounts.Role, :role, [ 4 | # Admin is the role given to appointed site administrator(s). 5 | :admin, 6 | 7 | # Staff is the role given to Teaching Assistants 8 | :staff, 9 | 10 | # Student is the role given to Students 11 | :student 12 | ]) 13 | -------------------------------------------------------------------------------- /priv/repo/migrations/20250330104845_add_is_minigame_to_assessment_config.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddIsMinigameToAssessmentConfig do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:assessment_configs) do 6 | add(:is_minigame, :boolean, default: false) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20251028050808_add_llm_assessment_prompt_assessment.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddLlmAssessmentPromptAssessment do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:assessments) do 6 | add(:llm_assessment_prompt, :text, default: nil) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180809071346_add_autograding_errors_to_answers.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddAutogradingErrorsToAnswers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:answers) do 6 | add(:autograding_errors, {:array, :map}, null: false, default: []) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240222094759_add_last_modified_to_answers.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddLastModifiedToAnswers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:answers) do 6 | add(:last_modified_at, :utc_datetime, default: fragment("CURRENT_TIMESTAMP")) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190919022834_update_assessment_type_enum.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.UpdateAssessmentTypeEnum do 2 | use Ecto.Migration 3 | 4 | @disable_ddl_transaction true 5 | 6 | def change do 7 | Ecto.Migration.execute("ALTER TYPE assessment_type ADD VALUE IF NOT EXISTS 'practical'") 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/cadet/env.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Env do 2 | @moduledoc """ 3 | Contains helpers to retrieve certain application-wide runtime configuration. 4 | """ 5 | 6 | @doc """ 7 | Returns the current environment. 8 | """ 9 | @spec env :: atom() 10 | def env do 11 | Application.get_env(:cadet, :environment) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "lib/cadet_web.ex", 4 | "lib/cadet_web/endpoint.ex", 5 | "lib/cadet.ex", 6 | "lib/cadet/application.ex", 7 | "lib/cadet/release.ex", 8 | "lib/cadet/repo.ex", 9 | "lib/mix/tasks", 10 | "test/support", 11 | "test/factories", 12 | "src/source_lexer.erl" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /priv/repo/migrations/20231105164101_create_submissions_assessment_index.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateSubmissionsAssessmentIndex do 2 | use Ecto.Migration 3 | 4 | def up do 5 | create(index(:submissions, [:assessment_id])) 6 | end 7 | 8 | def down do 9 | drop(index(:submissions, [:assessment_id])) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190530065753_add_unsubmit_fields.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddUnsubmitFields do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:submissions) do 6 | add(:unsubmitted_by_id, references(:users), null: true) 7 | add(:unsubmitted_at, :timestamp, null: true) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210503200828_add_job_log.exs: -------------------------------------------------------------------------------- 1 | defmodule Elixir.Cadet.Repo.Migrations.AddJobLog do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:job_log) do 6 | add(:name, :string, null: false) 7 | add(:last_run, :utc_datetime, null: false) 8 | end 9 | 10 | unique_index(:job_log, :name) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240714233659_create_llm_chats_table.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateLlmChatsTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:llm_chats) do 6 | add(:user_id, references(:users), null: false) 7 | add(:messages, :jsonb, null: false) 8 | timestamps() 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/cadet_web/controllers/default_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DefaultControllerTest do 2 | use CadetWeb.ConnCase 3 | 4 | describe "GET /" do 5 | test "default root page renders correctly", %{conn: conn} do 6 | conn = get(conn, "/") 7 | assert response(conn, 200) === "Welcome to the Source Academy Backend!" 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /deployment/cadet.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | After=network.target 3 | Requires=network.target 4 | StartLimitIntervalSec=0 5 | 6 | [Service] 7 | Type=simple 8 | TimeoutStartSec=0 9 | Restart=always 10 | RestartSec=5 11 | ExecStart=/opt/cadet/bin/cadet start 12 | User=nobody 13 | Environment=HOME=/opt/cadet/tmp 14 | Environment=PORT=4000 15 | 16 | [Install] 17 | WantedBy=multi-user.target 18 | -------------------------------------------------------------------------------- /lib/cadet/auth/empty_guardian.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Auth.EmptyGuardian do 2 | @moduledoc """ 3 | This module just provides an empty Guardian configuration, sufficient 4 | to use Guardian.Token.Jwt.Verify.verify_claims. 5 | """ 6 | 7 | def config(a), do: config(a, nil) 8 | 9 | def config(:allowed_drift, _def), do: 10_000 10 | 11 | def config(_a, def), do: def 12 | end 13 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220813085830_drop_group_name_constraint.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.DropGroupNameConstraint do 2 | use Ecto.Migration 3 | 4 | def change do 5 | drop(unique_index(:groups, [:name])) 6 | drop(unique_index(:groups, [:name, :course_id])) 7 | create(unique_index(:groups, [:name, :course_id], name: :unique_name_per_course)) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230311105547_populate_nus_student_emails.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.PopulateNusStudentEmails do 2 | use Ecto.Migration 3 | 4 | def change do 5 | execute(" 6 | update users 7 | set email = username || '@u.nus.edu' 8 | where username ~ '^[eE][0-9]{7}$' and email IS NULL and provider = 'luminus'; 9 | ") 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240221033554_create_team_members_table.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateTeamMembersTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:team_members) do 6 | add(:team_id, references(:teams), null: false) 7 | add(:student_id, references(:course_registrations), null: false) 8 | timestamps() 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/migrations/20251008160219_add_llm_grading_access.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddLlmGradingAccess do 2 | use Ecto.Migration 3 | 4 | def up do 5 | alter table(:courses) do 6 | add(:enable_llm_grading, :boolean, null: true) 7 | end 8 | end 9 | 10 | def down do 11 | alter table(:courses) do 12 | remove(:enable_llm_grading) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/cadet/release.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Release do 2 | @moduledoc """ 3 | Contains Release.migrate, to simplify running migrations from the command line 4 | """ 5 | def migrate do 6 | Application.load(:cadet) 7 | 8 | Ecto.Migrator.with_repo( 9 | Cadet.Repo, 10 | &Ecto.Migrator.run(&1, Application.app_dir(:cadet, "priv/repo/migrations"), :up, all: true) 11 | ) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/cadet_web/templates/email/avenger_backlog.html.eex: -------------------------------------------------------------------------------- 1 |

Dear <%= @avenger_name %>,

2 | 3 | You have ungraded submissions. Please review and grade the following submissions as soon as possible. 4 | 5 | <%= for s <- @submissions do %> 6 |

<%= s["assessment"]["title"] %> by <%= s["student"]["name"]%>

7 | <% end %> 8 | 9 | Unsubscribe from this email topic. 10 | -------------------------------------------------------------------------------- /deployment/terraform/s3.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "sourcecasts" { 2 | bucket = "${var.env}-cadet-sourcecasts" 3 | acl = "public-read" 4 | 5 | tags = { 6 | Name = "${title(var.env)} Cadet Sourcecasts" 7 | Environment = var.env 8 | } 9 | } 10 | 11 | data "aws_s3_bucket" "assets" { 12 | bucket = var.assets_bucket 13 | } 14 | 15 | data "aws_s3_bucket" "config" { 16 | bucket = var.config_bucket 17 | } 18 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200803100910_update_sourcecast.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.UpdateSourcecast do 2 | use Ecto.Migration 3 | 4 | def change do 5 | execute("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"") 6 | 7 | alter table(:sourcecasts) do 8 | add(:uid, :string, null: false, default: fragment("uuid_generate_v4()")) 9 | end 10 | 11 | create(unique_index(:sourcecasts, [:uid])) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230331010500_remove_unique_score_constraint.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.RemoveUniqueScoreConstraint do 2 | use Ecto.Migration 3 | 4 | def up do 5 | drop(unique_index(:submission_votes, [:user_id, :question_id, :score], name: :unique_score)) 6 | end 7 | 8 | def down do 9 | create(unique_index(:submission_votes, [:user_id, :question_id, :score], name: :unique_score)) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240116114642_add_stories_toggle_to_course_config.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddStoriesToggleToCourseConfig do 2 | use Ecto.Migration 3 | 4 | def up do 5 | alter table(:courses) do 6 | add(:enable_stories, :boolean, null: false, default: false) 7 | end 8 | end 9 | 10 | def down do 11 | alter table(:courses) do 12 | remove(:enable_stories) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/cadet_web/admin_views/admin_assets_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.AdminAssetsView do 2 | use CadetWeb, :view 3 | use Timex 4 | 5 | def render("index.json", %{assets: assets}) do 6 | render_many(assets, CadetWeb.AdminAssetsView, "show.json", as: :asset) 7 | end 8 | 9 | def render("show.json", %{asset: asset}) do 10 | asset 11 | end 12 | 13 | def render("show.json", %{resp: resp}) do 14 | resp 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230214081421_add_oban_jobs_table.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddObanJobsTable do 2 | use Ecto.Migration 3 | 4 | def up do 5 | Oban.Migration.up(version: 11) 6 | end 7 | 8 | # We specify `version: 1` in `down`, ensuring that we'll roll all the way back down if 9 | # necessary, regardless of which version we've migrated `up` to. 10 | def down do 11 | Oban.Migration.down(version: 1) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180526084150_create_submissions.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateSubmissions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:submissions) do 6 | add(:assessment_id, references(:assessments), null: false) 7 | add(:student_id, references(:users), null: false) 8 | timestamps() 9 | end 10 | 11 | create(unique_index(:submissions, [:assessment_id, :student_id])) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180113145459_create_announcements.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateAnnouncements do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:announcements) do 6 | add(:title, :string, null: false) 7 | add(:content, :text) 8 | add(:pinned, :boolean) 9 | add(:published, :boolean) 10 | 11 | add(:poster_id, references(:users)) 12 | 13 | timestamps() 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/factories/accounts/team_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Accounts.TeamFactory do 2 | @moduledoc """ 3 | Factory(ies) for Cadet.Accounts.Team entity 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | # alias Cadet.Accounts.{Role, User} 9 | alias Cadet.Accounts.Team 10 | 11 | def team_factory do 12 | %Team{ 13 | assessment: build(:assessment) 14 | } 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/cadet/accounts/notification_type.ex: -------------------------------------------------------------------------------- 1 | import EctoEnum 2 | 3 | defenum(Cadet.Accounts.NotificationType, :notification_type, [ 4 | # Notifications for new assessments 5 | :new, 6 | # Notifications for unsubmitted submissions 7 | :unsubmitted, 8 | # Notifications for submitted assessments 9 | :submitted, 10 | # Notifications for published grades 11 | :published_grading, 12 | # Notifications for unpublished grades 13 | :unpublished_grading 14 | ]) 15 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240331222300_add_is_grading_auto_published.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddIsGradingAutoPublished do 2 | use Ecto.Migration 3 | 4 | def up do 5 | alter table(:assessment_configs) do 6 | add(:is_grading_auto_published, :boolean, null: false, default: false) 7 | end 8 | end 9 | 10 | def down do 11 | alter table(:assessment_configs) do 12 | remove(:is_grading_auto_published) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/cadet_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.ErrorView do 2 | use CadetWeb, :view 3 | 4 | def render("404.json", _assigns) do 5 | "Page not found" 6 | end 7 | 8 | def render("500.json", _assigns) do 9 | "Internal server error" 10 | end 11 | 12 | # In case no render clause matches or no 13 | # template is found, let's render it as 500 14 | def template_not_found(_template, assigns) do 15 | render("500.json", assigns) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210323154140_change_achievements.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.ChangeAchievements do 2 | use Ecto.Migration 3 | 4 | def change do 5 | rename(table(:goals), :max_xp, to: :target_count) 6 | 7 | rename(table(:goal_progress), :xp, to: :count) 8 | 9 | alter table(:achievements) do 10 | add(:xp, :integer, null: false, default: 0) 11 | add(:is_variable_xp, :boolean, null: false, default: false) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/cadet/assessments/external_library_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.Library.ExternalLibraryTest do 2 | alias Cadet.Assessments.Library.ExternalLibrary 3 | 4 | use Cadet.ChangesetCase, entity: ExternalLibrary 5 | 6 | describe "Changesets" do 7 | setup do 8 | %{valid_params: build(:external_library)} 9 | end 10 | 11 | test "valid changesets", %{valid_params: params} do 12 | assert_changeset(params, :valid) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/cadet_web/views/answer_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.AnswerViewTest do 2 | use CadetWeb.ConnCase, async: true 3 | 4 | alias CadetWeb.AnswerView 5 | 6 | @last_modified ~U[2022-01-01T00:00:00Z] 7 | 8 | describe "render/2" do 9 | test "renders last modified timestamp as JSON" do 10 | json = AnswerView.render("lastModified.json", %{lastModified: @last_modified}) 11 | 12 | assert json[:lastModified] == @last_modified 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/factories/courses/group_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Courses.GroupFactory do 2 | @moduledoc """ 3 | Factory for Group entity 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | alias Cadet.Courses.Group 9 | 10 | def group_factory do 11 | %Group{ 12 | name: sequence("group"), 13 | leader: build(:course_registration), 14 | course: build(:course) 15 | } 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/cadet/auth/providers/saml/nusstf_assertion_extractor.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Auth.Providers.NusstfAssertionExtractor do 2 | @moduledoc """ 3 | Extracts fields from NUS Staff IdP SAML assertions. 4 | """ 5 | 6 | @behaviour Cadet.Auth.Providers.AssertionExtractor 7 | 8 | def get_username(assertion) do 9 | Map.get(assertion.attributes, "SamAccountName") 10 | end 11 | 12 | def get_name(assertion) do 13 | Map.get(assertion.attributes, "DisplayName") 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230214143617_create_sent_notifications.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateSentNotifications do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:sent_notifications) do 6 | add(:content, :text, null: false) 7 | add(:course_reg_id, references(:course_registrations, on_delete: :nothing), null: false) 8 | 9 | timestamps() 10 | end 11 | 12 | create(index(:sent_notifications, [:course_reg_id])) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240206045330_add_has_token_counter_toggle_to_assessment_config.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddHasTokenCounterToggleToAssessmentConfig do 2 | use Ecto.Migration 3 | 4 | def up do 5 | alter table(:assessment_configs) do 6 | add(:has_token_counter, :boolean, null: false, default: false) 7 | end 8 | end 9 | 10 | def down do 11 | alter table(:assessment_configs) do 12 | remove(:has_token_counter) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/cadet/auth/providers/openid/cognito_claim_extractor.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Auth.Providers.CognitoClaimExtractor do 2 | @moduledoc """ 3 | Extracts fields from Cognito JWTs. 4 | """ 5 | 6 | @behaviour Cadet.Auth.Providers.OpenID.ClaimExtractor 7 | 8 | def get_username(claims, _access_token) do 9 | claims["username"] 10 | end 11 | 12 | def get_name(claims, _access_token) do 13 | claims["username"] 14 | end 15 | 16 | def get_token_type, do: "access_token" 17 | end 18 | -------------------------------------------------------------------------------- /lib/cadet_web/views/chat_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.ChatView do 2 | use CadetWeb, :view 3 | 4 | def render("conversation_init.json", %{ 5 | conversation_id: id, 6 | last_message: last, 7 | max_content_size: size 8 | }) do 9 | %{conversationId: id, response: last, maxContentSize: size} 10 | end 11 | 12 | def render("conversation.json", %{conversation_id: id, response: response}) do 13 | %{conversationId: id, response: response} 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/repo/migrations/20250417093922_create_token_exchange_table.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateTokenExchangeTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:token_exchange) do 6 | add(:code, :string, null: false) 7 | add(:generated_at, :utc_datetime_usec, null: false) 8 | add(:expires_at, :utc_datetime_usec, null: false) 9 | add(:user_id, references(:users), null: false) 10 | timestamps() 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190713144515_update_materials.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.UpdateMaterials do 2 | use Ecto.Migration 3 | 4 | def change do 5 | drop(index(:materials, [:parent_id, :name])) 6 | create(index(:materials, [:uploader_id])) 7 | 8 | alter table(:materials) do 9 | remove(:parent_id) 10 | add(:category_id, references(:categories, on_delete: :delete_all)) 11 | end 12 | 13 | rename(table(:materials), :name, to: :title) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240321141522_add_has_voting_features_toggle_to_assessment_config.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddHasVotingFeaturesToggleToAssessmentConfig do 2 | use Ecto.Migration 3 | 4 | def up do 5 | alter table(:assessment_configs) do 6 | add(:has_voting_features, :boolean, null: false, default: false) 7 | end 8 | end 9 | 10 | def down do 11 | alter table(:assessment_configs) do 12 | remove(:has_voting_features) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/cadet/assessments/answer_types/programming_answer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.AnswerTypes.ProgrammingAnswerTest do 2 | alias Cadet.Assessments.AnswerTypes.ProgrammingAnswer 3 | 4 | use Cadet.ChangesetCase, entity: ProgrammingAnswer 5 | 6 | describe "Changesets" do 7 | test "valid changeset" do 8 | assert_changeset(%{code: "This is some code"}, :valid) 9 | end 10 | 11 | test "invalid changeset" do 12 | assert_changeset(%{}, :invalid) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180818060805_add_xp_to_assessments.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddXpToAssessments do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:questions) do 6 | add(:max_xp, :integer, default: 0) 7 | end 8 | 9 | alter table(:submissions) do 10 | add(:xp_bonus, :integer, default: 0) 11 | end 12 | 13 | alter table(:answers) do 14 | add(:xp, :integer, default: 0) 15 | add(:xp_adjustment, :integer, default: 0) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190701013010_create_sourcecast.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateSourcecast do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:sourcecasts) do 6 | add(:title, :string, null: false) 7 | add(:description, :string) 8 | add(:uploader_id, references(:users, on_delete: :nilify_all)) 9 | add(:audio, :string) 10 | add(:playbackData, :text) 11 | timestamps() 12 | end 13 | 14 | create(index(:sourcecasts, [:uploader_id])) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/factories/notifications/time_option_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Notifications.TimeOptionFactory do 2 | @moduledoc """ 3 | Factory for the TimeOption entity 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | alias Cadet.Notifications.TimeOption 9 | 10 | def time_option_factory do 11 | %TimeOption{ 12 | is_default: false, 13 | minutes: 0, 14 | notification_config: build(:notification_config) 15 | } 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/cadet/auth/providers/openid/auth0_claim_extractor.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Auth.Providers.Auth0ClaimExtractor do 2 | @moduledoc """ 3 | Extracts fields from Auth0 JWTs. 4 | """ 5 | 6 | @behaviour Cadet.Auth.Providers.OpenID.ClaimExtractor 7 | 8 | def get_username(claims, _id_token) do 9 | if claims["email_verified"] do 10 | claims["email"] 11 | else 12 | nil 13 | end 14 | end 15 | 16 | def get_name(claims, _id_token), do: claims["name"] 17 | 18 | def get_token_type, do: "id_token" 19 | end 20 | -------------------------------------------------------------------------------- /lib/cadet_web/views/devices_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.DevicesView do 2 | use CadetWeb, :view 3 | 4 | def render("index.json", %{registrations: registrations}) do 5 | render_many(registrations, CadetWeb.DevicesView, "show.json", as: :registration) 6 | end 7 | 8 | def render("show.json", %{registration: registration}) do 9 | %{ 10 | id: registration.id, 11 | title: registration.title, 12 | type: registration.device.type, 13 | secret: registration.device.secret 14 | } 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180526055901_guardiandb.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.Guardian.DB do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:guardian_tokens, primary_key: false) do 6 | add(:jti, :string, primary_key: true) 7 | add(:aud, :string, primary_key: true) 8 | add(:typ, :string) 9 | add(:iss, :string) 10 | add(:sub, :string) 11 | add(:exp, :bigint) 12 | add(:jwt, :text) 13 | add(:claims, :map) 14 | timestamps() 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/factories/accounts/notification_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Accounts.NotificationFactory do 2 | @moduledoc """ 3 | Factory for the Notification entity 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | alias Cadet.Accounts.Notification 9 | 10 | def notification_factory do 11 | valid_types = [:new] 12 | 13 | %Notification{ 14 | type: Enum.random(valid_types), 15 | read: Enum.random([true, false]) 16 | } 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/cadet_web/plug/assign_current_user.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.Plug.AssignCurrentUser do 2 | @moduledoc """ 3 | A plug that assign :current_user to currently logged in user 4 | """ 5 | alias Cadet.Auth.Guardian.Plug, as: GPlug 6 | 7 | import Plug.Conn 8 | 9 | def init(opts), do: opts 10 | 11 | def call(conn, _opts) do 12 | current_user = GPlug.current_resource(conn) 13 | 14 | if current_user != nil do 15 | assign(conn, :current_user, current_user) 16 | else 17 | conn 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190713140211_create_categories.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateCategories do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:categories) do 6 | add(:title, :string, null: false) 7 | add(:description, :string) 8 | add(:uploader_id, references(:users, on_delete: :nilify_all)) 9 | add(:category_id, references(:categories, on_delete: :delete_all)) 10 | timestamps() 11 | end 12 | 13 | create(index(:categories, [:uploader_id])) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/repo/migrations/20251022103623_create_ai_comments.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateAiCommentLogs do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:ai_comment_logs) do 6 | add(:answer_id, references(:answers, on_delete: :delete_all), null: false) 7 | add(:raw_prompt, :text, null: false) 8 | add(:answers_json, :text, null: false) 9 | add(:response, :text) 10 | add(:error, :text) 11 | add(:final_comment, :text) 12 | timestamps() 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/cadet/auth/providers/saml/nusstf_assertion_extractor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Auth.Providers.NusstfAssertionExtractorTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Cadet.Auth.Providers.NusstfAssertionExtractor, as: Testee 5 | 6 | @username "JohnT" 7 | @name "John Tan" 8 | @assertion %{attributes: %{"SamAccountName" => @username, "DisplayName" => @name}} 9 | 10 | test "success" do 11 | assert @username == Testee.get_username(@assertion) 12 | assert @name == Testee.get_name(@assertion) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/factories/accounts/team_member_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Accounts.TeamMemberFactory do 2 | @moduledoc """ 3 | Factory(ies) for Cadet.Accounts.TeamMember entity 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | # alias Cadet.Accounts.{Role, User} 9 | alias Cadet.Accounts.TeamMember 10 | 11 | def team_member_factory do 12 | %TeamMember{ 13 | student: build(:course_registration), 14 | team: build(:team) 15 | } 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/cadet/assessments/answer_types/voting_answer.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.AnswerTypes.VotingAnswer do 2 | @moduledoc """ 3 | The VotingQuestion entity represents a Voting question. 4 | """ 5 | use Cadet, :model 6 | 7 | @primary_key false 8 | embedded_schema do 9 | field(:completed, :boolean) 10 | end 11 | 12 | @required_fields ~w(completed)a 13 | 14 | def changeset(answer, params \\ %{}) do 15 | answer 16 | |> cast(params, @required_fields) 17 | |> validate_required(@required_fields) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/factories/devices/device_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Devices.DeviceFactory do 2 | @moduledoc """ 3 | Factory(ies) for Cadet.Devices.Device entity 4 | """ 5 | 6 | alias Cadet.Devices.Device 7 | 8 | defmacro __using__(_opts) do 9 | quote do 10 | def device_factory do 11 | %Device{ 12 | secret: Faker.UUID.v4(), 13 | type: Enum.random(~w(esp32 ev3)), 14 | client_key: Faker.UUID.v4(), 15 | client_cert: Faker.UUID.v4() 16 | } 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/cadet/auth/providers/openid/google_claim_extractor.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Auth.Providers.GoogleClaimExtractor do 2 | @moduledoc """ 3 | Extracts fields from Google JWTs. 4 | """ 5 | 6 | @behaviour Cadet.Auth.Providers.OpenID.ClaimExtractor 7 | 8 | def get_username(claims, _id_token) do 9 | if claims["email_verified"] do 10 | claims["email"] 11 | else 12 | nil 13 | end 14 | end 15 | 16 | def get_name(claims, _id_token) do 17 | claims["name"] 18 | end 19 | 20 | def get_token_type, do: "id_token" 21 | end 22 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230215092543_add_avenger_backlog_notification_type.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddAvengerBacklogNotificationType do 2 | use Ecto.Migration 3 | 4 | def up do 5 | execute( 6 | "INSERT INTO notification_types (name, template_file_name, is_autopopulated, inserted_at, updated_at) VALUES ('AVENGER BACKLOG', 'avenger_backlog', TRUE, current_timestamp, current_timestamp)" 7 | ) 8 | end 9 | 10 | def down do 11 | execute("DELETE FROM notification_types WHERE name = 'AVENGER BACKLOG'") 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/cadet/assessments/answer_types/programming_answer.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.AnswerTypes.ProgrammingAnswer do 2 | @moduledoc """ 3 | The ProgrammingQuestion entity represents a Programming question. 4 | """ 5 | use Cadet, :model 6 | 7 | @primary_key false 8 | embedded_schema do 9 | field(:code, :string) 10 | end 11 | 12 | @required_fields ~w(code)a 13 | 14 | def changeset(answer, params \\ %{}) do 15 | answer 16 | |> cast(params, @required_fields) 17 | |> validate_required(@required_fields) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180101144251_create_users.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateUsers do 2 | use Ecto.Migration 3 | 4 | alias Cadet.Accounts.Role 5 | 6 | def up do 7 | Role.create_type() 8 | 9 | create table(:users) do 10 | add(:name, :string, null: false) 11 | add(:role, :role, null: false) 12 | add(:nusnet_id, :string) 13 | timestamps() 14 | end 15 | 16 | create(unique_index(:users, [:nusnet_id])) 17 | end 18 | 19 | def down do 20 | drop(table(:users)) 21 | Role.drop_type() 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180728073014_create_submission_status.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateSubmissionStatus do 2 | use Ecto.Migration 3 | 4 | alias Cadet.Assessments.SubmissionStatus 5 | 6 | def up do 7 | SubmissionStatus.create_type() 8 | 9 | alter table(:submissions) do 10 | add(:status, :submission_status, null: false, default: "attempting") 11 | end 12 | end 13 | 14 | def down do 15 | alter table(:submissions) do 16 | remove(:status) 17 | end 18 | 19 | SubmissionStatus.drop_type() 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200707084425_create_achievement_goals.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateAchievementGoals do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:achievement_goals) do 6 | add(:order, :integer, null: false) 7 | add(:text, :text, null: false) 8 | add(:target, :integer, null: false) 9 | 10 | add(:achievement_id, references(:achievements, on_delete: :delete_all), null: false) 11 | end 12 | 13 | create(index(:achievement_goals, [:achievement_id, :order], unique: true)) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180117144515_create_materials.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateMaterials do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:materials) do 6 | add(:name, :string, null: false) 7 | add(:description, :string) 8 | add(:parent_id, references(:materials, on_delete: :delete_all)) 9 | add(:uploader_id, references(:users, on_delete: :nilify_all)) 10 | add(:file, :string) 11 | 12 | timestamps() 13 | end 14 | 15 | create(index(:materials, [:parent_id, :name])) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190603023734_add_notifications.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddNotifications do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:notifications) do 6 | add(:type, :string) 7 | add(:read, :boolean) 8 | add(:user_id, references(:users), null: false) 9 | add(:assessment_id, references(:assessments), null: true) 10 | add(:submission_id, references(:submissions), null: true) 11 | timestamps() 12 | end 13 | 14 | create(index(:notifications, [:user_id, :assessment_id])) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240320154407_add_has_token_counter_toggle_to_assessment.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddHasTokenCounterToggleToAssessment do 2 | use Ecto.Migration 3 | 4 | def up do 5 | alter table(:assessments) do 6 | add(:has_token_counter, :boolean, null: false, default: false) 7 | add(:has_voting_features, :boolean, null: false, default: false) 8 | end 9 | end 10 | 11 | def down do 12 | alter table(:assessments) do 13 | remove(:has_token_counter) 14 | remove(:has_voting_features) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/support/assert_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.AssertHelper do 2 | @moduledoc """ 3 | Contains some general assertion helpers for tests. 4 | """ 5 | 6 | import ExUnit.Assertions, only: [assert: 1] 7 | 8 | def assert_submaps_eq(expected, actual, fields) do 9 | assert length(expected) == length(actual) 10 | 11 | Enum.map(Enum.zip([expected, actual]), fn {e, a} -> assert_submap_eq(e, a, fields) end) 12 | end 13 | 14 | def assert_submap_eq(expected, actual, fields) do 15 | assert Map.take(expected, fields) == Map.take(actual, fields) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180804035838_add_autograding_fields.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddAutogradingFields do 2 | use Ecto.Migration 3 | 4 | alias Cadet.Assessments.Answer.AutogradingStatus 5 | 6 | def up do 7 | AutogradingStatus.create_type() 8 | 9 | alter table(:answers) do 10 | add(:autograding_status, :autograding_status, null: false, default: "none") 11 | end 12 | end 13 | 14 | def down do 15 | alter table(:answers) do 16 | remove(:autograding_status) 17 | end 18 | 19 | AutogradingStatus.drop_type() 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230214065925_create_notification_types.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateNotificationTypes do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:notification_types) do 6 | add(:name, :string, null: false) 7 | add(:template_file_name, :string, null: false) 8 | add(:is_enabled, :boolean, default: false, null: false) 9 | add(:is_autopopulated, :boolean, default: false, null: false) 10 | 11 | timestamps() 12 | end 13 | 14 | create(unique_index(:notification_types, [:name])) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230215072400_add_assessment_submission_notification_type.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddAssessmentSubmissionNotificationType do 2 | use Ecto.Migration 3 | 4 | def up do 5 | execute( 6 | "INSERT INTO notification_types (name, template_file_name, is_autopopulated, inserted_at, updated_at) VALUES ('ASSESSMENT SUBMISSION', 'assessment_submission', FALSE, current_timestamp, current_timestamp)" 7 | ) 8 | end 9 | 10 | def down do 11 | execute("DELETE FROM notification_types WHERE name = 'ASSESSMENT SUBMISSION'") 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/factories/assessments/submission_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.SubmissionFactory do 2 | @moduledoc """ 3 | Factory for the Submission entity 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | alias Cadet.Assessments.Submission 9 | 10 | def submission_factory do 11 | %Submission{ 12 | student: build(:course_registration, %{role: :student}), 13 | team: nil, 14 | assessment: build(:assessment), 15 | is_grading_published: false 16 | } 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/cadet/helpers/context_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.ContextHelper do 2 | @moduledoc """ 3 | Contains utility functions that may be commonly used across the Cadet project. 4 | """ 5 | 6 | alias Cadet.Repo 7 | 8 | def simple_update(queryable, id, opts \\ []) do 9 | params = opts[:params] || [] 10 | using = opts[:using] || fn x, _ -> x end 11 | model = Repo.get(queryable, id) 12 | 13 | if model == nil do 14 | {:error, :not_found} 15 | else 16 | changeset = using.(model, params) 17 | Repo.update(changeset) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/factories/assessments/submission_vote_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.SubmissionVotesFactory do 2 | @moduledoc """ 3 | Factory for the SubmissionVote entity 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | alias Cadet.Assessments.SubmissionVotes 9 | 10 | def submission_vote_factory do 11 | %SubmissionVotes{ 12 | voter: build(:course_registration, %{role: :student}), 13 | question: build(:voting_question), 14 | submission: build(:submission) 15 | } 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180117125701_create_groups.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateGroups do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:groups) do 6 | add(:leader_id, references(:users), null: false) 7 | add(:mentor_id, references(:users)) 8 | add(:name, :string) 9 | end 10 | 11 | create(unique_index(:groups, [:leader_id])) 12 | create(index(:groups, [:mentor_id])) 13 | 14 | alter table(:users) do 15 | add(:group_id, references(:groups)) 16 | end 17 | 18 | create(index(:users, [:group_id])) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200715022804_create_achievement_prerequisites.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateAchievementPrerequisites do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:achievement_prerequisites, primary_key: false) do 6 | add(:achievement_id, references(:achievements, on_delete: :delete_all), 7 | null: false, 8 | primary_key: true 9 | ) 10 | 11 | add(:prerequisite_id, references(:achievements, on_delete: :delete_all), 12 | null: false, 13 | primary_key: true 14 | ) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200719200931_create_achievement_progress.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateAchievementProgress do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:achievement_progress) do 6 | add(:progress, :integer, default: 0) 7 | 8 | add(:user_id, references(:users, on_delete: :delete_all), null: false) 9 | add(:goal_id, references(:achievement_goals, on_delete: :delete_all), null: false) 10 | 11 | timestamps() 12 | end 13 | 14 | create(index(:achievement_progress, [:user_id, :goal_id], unique: true)) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/cadet/auth/providers/openid/cognito_claim_extractor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Auth.Providers.CognitoClaimExtractorTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Cadet.Auth.Providers.CognitoClaimExtractor, as: Testee 5 | 6 | @username "adofjihid" 7 | @role :staff 8 | @claims %{"username" => @username, "cognito:groups" => [Atom.to_string(@role)]} 9 | 10 | test "test" do 11 | assert @username == Testee.get_username(@claims, "") 12 | assert @username == Testee.get_name(@claims, "") 13 | 14 | assert Testee.get_token_type() == "access_token" 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/cadet/auth/providers/saml/nusstu_assertion_extractor.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Auth.Providers.NusstuAssertionExtractor do 2 | @moduledoc """ 3 | Extracts fields from NUS Student IdP SAML assertions. 4 | """ 5 | 6 | @behaviour Cadet.Auth.Providers.AssertionExtractor 7 | 8 | def get_username(assertion) do 9 | Map.get(assertion.attributes, "samaccountname") 10 | end 11 | 12 | def get_name(assertion) do 13 | first_name = Map.get(assertion.attributes, "givenname") 14 | last_name = Map.get(assertion.attributes, "surname") 15 | "#{first_name} #{last_name}" 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210205030432_create_submission_votes_table.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddSubmissionVotesTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:submission_votes) do 6 | add(:rank, :integer) 7 | add(:user_id, references(:users), null: false) 8 | add(:submission_id, references(:submissions), null: false) 9 | add(:question_id, references(:questions), null: false) 10 | timestamps() 11 | end 12 | 13 | create(unique_index(:submission_votes, [:user_id, :question_id, :rank], name: :unique_score)) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/cadet/program_analysis/lexer.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.ProgramAnalysis.Lexer do 2 | @moduledoc """ 3 | Provides functions to lexically analyse program strings 4 | """ 5 | 6 | def count_tokens(program) do 7 | case lex(program) do 8 | {:ok, tokens} -> 9 | Enum.count(tokens) 10 | 11 | {:error, _} -> 12 | 0 13 | end 14 | end 15 | 16 | defp lex(str) do 17 | case :source_lexer.string(to_charlist(str)) do 18 | {:ok, tokens, _} -> 19 | {:ok, tokens} 20 | 21 | {:error, reason, _} -> 22 | {:error, reason} 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210415034407_alter_achievements_datetime_nullable.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AlterAchievementsDatetimeNullable do 2 | use Ecto.Migration 3 | 4 | def up do 5 | alter table(:achievements) do 6 | modify(:open_at, :timestamp, null: true, default: nil) 7 | modify(:close_at, :timestamp, null: true, default: nil) 8 | end 9 | end 10 | 11 | def down do 12 | alter table(:achievements) do 13 | modify(:open_at, :timestamp, default: fragment("NOW()")) 14 | modify(:close_at, :timestamp, default: fragment("NOW()")) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/cadet/incentives/goal_progress_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Incentives.GoalProgressTest do 2 | alias Cadet.Incentives.GoalProgress 3 | 4 | use Cadet.ChangesetCase, entity: GoalProgress 5 | 6 | describe "Changesets" do 7 | test "valid params" do 8 | course_reg = insert(:course_registration) 9 | goal = insert(:goal) 10 | 11 | assert_changeset_db( 12 | %{ 13 | goal_uuid: goal.uuid, 14 | course_reg_id: course_reg.id, 15 | count: 500, 16 | completed: false 17 | }, 18 | :valid 19 | ) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/factories/courses/sourcecast_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Courses.SourcecastFactory do 2 | @moduledoc """ 3 | Factory for Sourcecast entity 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | alias Cadet.Courses.Sourcecast 9 | 10 | def sourcecast_factory do 11 | %Sourcecast{ 12 | title: Faker.StarWars.character(), 13 | description: Faker.StarWars.planet(), 14 | audio: build(:upload), 15 | playbackData: Faker.StarWars.planet(), 16 | uploader: build(:user) 17 | } 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/cadet/incentives/goal_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Incentives.GoalTest do 2 | alias Cadet.Incentives.Goal 3 | alias Ecto.UUID 4 | 5 | use Cadet.ChangesetCase, entity: Goal 6 | 7 | describe "Changesets" do 8 | test "valid params" do 9 | course = insert(:course) 10 | 11 | assert_changeset_db( 12 | %{ 13 | uuid: UUID.generate(), 14 | course_id: course.id, 15 | target_count: 1000, 16 | text: "Sample Text", 17 | type: "test_type", 18 | meta: %{} 19 | }, 20 | :valid 21 | ) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200812202222_remove_assessment_type_enum.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.RemoveAssessmentTypeEnum do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:assessments) do 6 | add(:type_new, :string) 7 | end 8 | 9 | Ecto.Migration.execute("UPDATE assessments SET type_new = type::text") 10 | 11 | alter table(:assessments) do 12 | modify(:type_new, :string, null: false) 13 | remove(:type) 14 | end 15 | 16 | rename(table(:assessments), :type_new, to: :type) 17 | 18 | Ecto.Migration.execute("DROP TYPE assessment_type") 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/factories/courses/assessment_config_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Courses.AssessmentConfigFactory do 2 | @moduledoc """ 3 | Factory for the AssessmentConfig entity 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | alias Cadet.Courses.AssessmentConfig 9 | 10 | def assessment_config_factory do 11 | %AssessmentConfig{ 12 | order: 1, 13 | type: Faker.Pokemon.En.name(), 14 | early_submission_xp: 200, 15 | hours_before_early_xp_decay: 48, 16 | course: build(:course) 17 | } 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/factories/notifications/notification_type_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Notifications.NotificationTypeFactory do 2 | @moduledoc """ 3 | Factory for the NotificationType entity 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | alias Cadet.Notifications.NotificationType 9 | 10 | def notification_type_factory do 11 | %NotificationType{ 12 | is_autopopulated: false, 13 | is_enabled: false, 14 | name: "Generic Notificaation Type", 15 | template_file_name: "generic_template_name" 16 | } 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/cadet/assessments/external_library.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.Library.ExternalLibrary do 2 | @moduledoc """ 3 | The library entity represents an external library to be used in a question. 4 | """ 5 | use Cadet, :model 6 | 7 | @primary_key false 8 | embedded_schema do 9 | field(:name, :string, default: "none") 10 | field(:symbols, {:array, :string}, default: []) 11 | end 12 | 13 | @required_fields ~w(name symbols)a 14 | 15 | def changeset(library, params \\ %{}) do 16 | library 17 | |> cast(params, @required_fields) 18 | |> validate_required(@required_fields) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/factories/chatbot/conversation_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Chatbot.ConversationFactory do 2 | @moduledoc """ 3 | Factories for Cadet.Chatbot.Conversation entity. 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | alias Cadet.Chatbot.Conversation 9 | 10 | def conversation_factory do 11 | %Conversation{ 12 | user: build(:user), 13 | prepend_context: [%{role: "system", content: "You are a helpful assistant."}], 14 | messages: [%{role: "assistant", content: "Hello, how can I help you?"}] 15 | } 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/cadet_web/plug/assign_current_user_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.Plug.AssignCurrentUserTest do 2 | use CadetWeb.ConnCase 3 | 4 | alias CadetWeb.Plug.AssignCurrentUser 5 | 6 | test "init" do 7 | AssignCurrentUser.init(%{}) 8 | # nothing to test 9 | end 10 | 11 | test "not logged in", %{conn: conn} do 12 | conn = AssignCurrentUser.call(conn, %{}) 13 | assert conn.assigns[:current_user] == nil 14 | end 15 | 16 | @tag authenticate: :student 17 | test "logged in", %{conn: conn} do 18 | conn = AssignCurrentUser.call(conn, %{}) 19 | assert conn.assigns[:current_user] != nil 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/cadet_web/views/stories_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.StoriesView do 2 | use CadetWeb, :view 3 | 4 | def render("index.json", %{stories: stories}) do 5 | render_many(stories, CadetWeb.StoriesView, "show.json", as: :story) 6 | end 7 | 8 | def render("show.json", %{story: story}) do 9 | transform_map_for_view(story, %{ 10 | id: :id, 11 | title: :title, 12 | filenames: :filenames, 13 | imageUrl: :image_url, 14 | isPublished: :is_published, 15 | openAt: &format_datetime(&1.open_at), 16 | closeAt: &format_datetime(&1.close_at), 17 | courseId: :course_id 18 | }) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /priv/repo/migrations/20251019160255_add_llm_api_key_to_courses.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddLlmApiKeyToCourses do 2 | use Ecto.Migration 3 | 4 | def up do 5 | alter table(:courses) do 6 | add(:llm_api_key, :text, null: true) 7 | add(:llm_model, :text, null: true) 8 | add(:llm_api_url, :text, null: true) 9 | add(:llm_course_level_prompt, :text, null: true) 10 | end 11 | end 12 | 13 | def down do 14 | alter table(:courses) do 15 | remove(:llm_course_level_prompt) 16 | remove(:llm_api_key) 17 | remove(:llm_model) 18 | remove(:llm_api_url) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/factories/notifications/notifcation_config_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Notifications.NotificationConfigFactory do 2 | @moduledoc """ 3 | Factory for the NotificationConfig entity 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | alias Cadet.Notifications.NotificationConfig 9 | 10 | def notification_config_factory do 11 | %NotificationConfig{ 12 | is_enabled: false, 13 | notification_type: build(:notification_type), 14 | course: build(:course), 15 | assessment_config: build(:assessment_config) 16 | } 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230214132717_create_time_options.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateTimeOptions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:time_options) do 6 | add(:minutes, :integer, null: false) 7 | add(:is_default, :boolean, default: false, null: false) 8 | 9 | add(:notification_config_id, references(:notification_configs, on_delete: :delete_all), 10 | null: false 11 | ) 12 | 13 | timestamps() 14 | end 15 | 16 | create( 17 | unique_index(:time_options, [:minutes, :notification_config_id], name: :unique_time_options) 18 | ) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/factories/achievements/goal_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Incentives.GoalFactory do 2 | @moduledoc """ 3 | Factory for the Goal entity 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | alias Cadet.Incentives.Goal 9 | alias Ecto.UUID 10 | 11 | def goal_factory do 12 | %Goal{ 13 | uuid: UUID.generate(), 14 | text: "Score earned from Curve Introduction mission", 15 | target_count: Faker.random_between(1, 1000), 16 | type: "test_type", 17 | course: insert(:course), 18 | meta: %{} 19 | } 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/cadet_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.ErrorViewTest do 2 | use CadetWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.json" do 8 | assert render_to_string(CadetWeb.ErrorView, "404.json", []) == "\"Page not found\"" 9 | end 10 | 11 | test "render 500.json" do 12 | assert render_to_string(CadetWeb.ErrorView, "500.json", []) == "\"Internal server error\"" 13 | end 14 | 15 | test "render any other" do 16 | assert render_to_string(CadetWeb.ErrorView, "505.json", []) == "\"Internal server error\"" 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/cadet/auth/pipeline.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Auth.Pipeline do 2 | @moduledoc """ 3 | Pipeline to verify Guardian JWT token session and header 4 | """ 5 | use Guardian.Plug.Pipeline, 6 | otp_app: :cadet, 7 | error_handler: Cadet.Auth.ErrorHandler, 8 | module: Cadet.Auth.Guardian 9 | 10 | # If there is a session token, validate it 11 | plug(Guardian.Plug.VerifySession, claims: %{"typ" => "access"}) 12 | # If there is an authorization header, validate it 13 | plug(Guardian.Plug.VerifyHeader, claims: %{"typ" => "access"}) 14 | # Load the user if either of the verifications worked 15 | plug(Guardian.Plug.LoadResource, allow_blank: true) 16 | end 17 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230214074219_create_notification_configs.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateNotificationConfigs do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:notification_configs) do 6 | add(:is_enabled, :boolean, default: false, null: false) 7 | 8 | add(:notification_type_id, references(:notification_types, on_delete: :delete_all), 9 | null: false 10 | ) 11 | 12 | add(:course_id, references(:courses, on_delete: :delete_all), null: false) 13 | add(:assessment_config_id, references(:assessment_configs, on_delete: :delete_all)) 14 | 15 | timestamps() 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240322184853_update_is_grading_published.exs: -------------------------------------------------------------------------------- 1 | defmodule MyApp.Repo.Migrations.UpdateIsGradingPublished do 2 | use Ecto.Migration 3 | 4 | def up do 5 | execute(""" 6 | UPDATE submissions 7 | SET is_grading_published = true 8 | WHERE id IN ( 9 | SELECT s.id 10 | FROM submissions AS s 11 | JOIN answers AS a ON a.submission_id = s.id 12 | GROUP BY s.id 13 | HAVING COUNT(a.id) = COUNT(a.grader_id) 14 | ) 15 | """) 16 | end 17 | 18 | def down do 19 | execute(""" 20 | UPDATE submissions 21 | SET is_grading_published = false 22 | """) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/cadet/assessments/answer_types/mcq_answer.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.AnswerTypes.MCQAnswer do 2 | @moduledoc """ 3 | The Assessments.QuestionTypes.MCQQuestion entity represents an MCQ Answer. 4 | It comprises of one of the MCQ choices. 5 | """ 6 | use Cadet, :model 7 | 8 | @primary_key false 9 | embedded_schema do 10 | field(:choice_id, :integer) 11 | end 12 | 13 | @required_fields ~w(choice_id)a 14 | 15 | def changeset(answer, params \\ %{}) do 16 | answer 17 | |> cast(params, @required_fields) 18 | |> validate_required(@required_fields) 19 | |> validate_number(:choice_id, greater_than_or_equal_to: 0) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/cadet/auth/providers/saml/nusstu_assertion_extractor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Auth.Providers.NusstuAssertionExtractorTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Cadet.Auth.Providers.NusstuAssertionExtractor, as: Testee 5 | 6 | @username "JohnT" 7 | @firstname "John" 8 | @lastname "Tan" 9 | @assertion %{ 10 | attributes: %{ 11 | "samaccountname" => @username, 12 | "givenname" => @firstname, 13 | "surname" => @lastname 14 | } 15 | } 16 | 17 | test "success" do 18 | assert @username == Testee.get_username(@assertion) 19 | assert @firstname <> " " <> @lastname == Testee.get_name(@assertion) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/factories/notifications/notification_preference_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Notifications.NotificationPreferenceFactory do 2 | @moduledoc """ 3 | Factory for the NotificationPreference entity 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | alias Cadet.Notifications.NotificationPreference 9 | 10 | def notification_preference_factory do 11 | %NotificationPreference{ 12 | is_enabled: false, 13 | notification_config: build(:notification_config), 14 | time_option: build(:time_option), 15 | course_reg: build(:course_registration) 16 | } 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/cadet/helpers/aws_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.AwsHelper do 2 | @moduledoc """ 3 | Contains some methods to workaround silly things in ExAws. 4 | """ 5 | 6 | def request(operation = %ExAws.Operation.RestQuery{}, headers, config_overrides) do 7 | config = ExAws.Config.new(operation.service, config_overrides) 8 | url = ExAws.Request.Url.build(operation, config) 9 | 10 | result = 11 | ExAws.Request.request( 12 | operation.http_method, 13 | url, 14 | operation.body, 15 | headers, 16 | config, 17 | operation.service 18 | ) 19 | 20 | operation.parser.(result, operation.action) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180815021708_remove_constraints_from_leader_in_group.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.RemoveConstraintsFromLeaderInGroup do 2 | use Ecto.Migration 3 | 4 | def up do 5 | drop(unique_index(:groups, [:leader_id])) 6 | drop(constraint(:groups, "groups_leader_id_fkey")) 7 | 8 | alter table(:groups) do 9 | modify(:leader_id, references(:users), null: true) 10 | end 11 | end 12 | 13 | def down do 14 | create(unique_index(:groups, [:leader_id])) 15 | drop(constraint(:groups, "groups_leader_id_fkey")) 16 | 17 | alter table(:groups) do 18 | modify(:leader_id, references(:users), null: false) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/cadet/assessments/answer_types/mcq_answer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.AnswerTypes.MCQAnswerTest do 2 | alias Cadet.Assessments.AnswerTypes.MCQAnswer 3 | 4 | use Cadet.ChangesetCase, entity: MCQAnswer 5 | 6 | describe "Changesets" do 7 | test "valid changesets" do 8 | assert_changeset(%{choice_id: 0}, :valid) 9 | assert_changeset(%{choice_id: 1}, :valid) 10 | assert_changeset(%{choice_id: 2}, :valid) 11 | assert_changeset(%{choice_id: 3}, :valid) 12 | assert_changeset(%{choice_id: 4}, :valid) 13 | end 14 | 15 | test "invalid changesets" do 16 | assert_changeset(%{choice_id: -2}, :invalid) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/factories/stories/story_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Stories.StoryFactory do 2 | @moduledoc """ 3 | Factory for the Story entity 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | alias Cadet.Stories.Story 9 | 10 | def story_factory do 11 | %Story{ 12 | open_at: Timex.shift(Timex.now(), days: 1), 13 | close_at: Timex.shift(Timex.now(), days: Enum.random(2..30)), 14 | is_published: false, 15 | filenames: ["mission-1.txt"], 16 | title: "Mission1", 17 | image_url: "http://example.com", 18 | course: build(:course) 19 | } 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/cadet/incentives/achievement_to_goal_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Incentives.AchievementToGoalTest do 2 | alias Cadet.Incentives.AchievementToGoal 3 | 4 | use Cadet.ChangesetCase, entity: AchievementToGoal 5 | 6 | describe "Changesets" do 7 | test "valid changesets" do 8 | insert(:achievement, uuid: "d1fdae3f-2775-4503-ab6b-e043149d4a15") 9 | insert(:goal, uuid: "d1fdae3f-2775-4503-ab6b-0123456789ab") 10 | 11 | assert_changeset_db( 12 | %{ 13 | achievement_uuid: "d1fdae3f-2775-4503-ab6b-e043149d4a15", 14 | goal_uuid: "d1fdae3f-2775-4503-ab6b-0123456789ab" 15 | }, 16 | :valid 17 | ) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210807040646_add_provider_to_user.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddProviderToUser do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add(:provider, :string, null: true) 7 | end 8 | 9 | drop(unique_index(:users, [:username], name: "users_nusnet_id_index")) 10 | create(unique_index(:users, [:username, :provider])) 11 | 12 | execute("update users set provider = split_part(username, '/', 1)") 13 | 14 | execute("update users set username = substring(username from char_length(provider) + 2)") 15 | 16 | alter table(:users) do 17 | modify(:provider, :string, null: false) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/cadet/devices/device.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Devices.Device do 2 | @moduledoc """ 3 | Represents a remote execution device. 4 | """ 5 | 6 | use Cadet, :model 7 | 8 | @type t :: %__MODULE__{} 9 | 10 | schema "devices" do 11 | field(:secret, :string) 12 | field(:type, :string) 13 | 14 | field(:client_key, :binary) 15 | field(:client_cert, :binary) 16 | 17 | timestamps() 18 | end 19 | 20 | @required_fields ~w(secret type)a 21 | @optional_fields ~w(client_key client_cert)a 22 | 23 | def changeset(device, params \\ %{}) do 24 | device 25 | |> cast(params, @required_fields ++ @optional_fields) 26 | |> validate_required(@required_fields) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/factories/accounts/course_registration_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Accounts.CourseRegistrationFactory do 2 | @moduledoc """ 3 | Factory(ies) for Cadet.Accounts.CourseRegistration entity 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | alias Cadet.Accounts.{Role, CourseRegistration} 9 | # alias Cadet.Courses.{Course, Group} 10 | 11 | def course_registration_factory do 12 | %CourseRegistration{ 13 | user: build(:user), 14 | course: build(:course), 15 | # group: build(:group), 16 | role: Enum.random(Role.__enum_map__()), 17 | game_states: %{} 18 | } 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200603095201_create_stories_table.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateStoriesTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:stories) do 6 | add(:open_at, :timestamp, null: false) 7 | add(:close_at, :timestamp, null: false) 8 | add(:is_published, :boolean, null: false, default: false) 9 | add(:title, :string, null: false) 10 | add(:image_url, :string, null: false) 11 | add(:filenames, {:array, :string}, null: false) 12 | timestamps() 13 | end 14 | 15 | create(index(:stories, [:open_at])) 16 | create(index(:stories, [:close_at])) 17 | create(index(:stories, [:is_published])) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/cadet/incentives/achievement_prerequisite_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Incentives.AchievementPrerequisiteTest do 2 | alias Cadet.Incentives.AchievementPrerequisite 3 | 4 | use Cadet.ChangesetCase, entity: AchievementPrerequisite 5 | 6 | describe "Changesets" do 7 | test "valid params" do 8 | insert(:achievement, uuid: "d1fdae3f-2775-4503-ab6b-e043149d4a15") 9 | insert(:achievement, uuid: "d1fdae3f-2775-4503-ab6b-0123456789ab") 10 | 11 | assert_changeset_db( 12 | %{ 13 | prerequisite_uuid: "d1fdae3f-2775-4503-ab6b-e043149d4a15", 14 | achievement_uuid: "d1fdae3f-2775-4503-ab6b-0123456789ab" 15 | }, 16 | :valid 17 | ) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/cadet/assessments/question_types/programming_question_testcases.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.QuestionTypes.Testcase do 2 | @moduledoc """ 3 | The Assessments.QuestionTypes.Testcase entity represents a public/opaque/secret testcase. 4 | """ 5 | use Cadet, :model 6 | 7 | @primary_key false 8 | embedded_schema do 9 | field(:program, :string) 10 | field(:answer, :string) 11 | field(:score, :integer) 12 | end 13 | 14 | @required_fields ~w(program answer score)a 15 | 16 | def changeset(question, params \\ %{}) do 17 | question 18 | |> cast(params, @required_fields) 19 | |> validate_required(@required_fields) 20 | |> validate_number(:score, greater_than_or_equal_to: 0) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210713113032_update_testcase_format.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.UpdateTestcaseFormat do 2 | use Ecto.Migration 3 | 4 | def change do 5 | execute( 6 | "update questions set question = (question - 'private' || jsonb_build_object('opaque', question->'private', 'secret', '[]'::jsonb)) where type = 'programming' and build_hidden_testcases;" 7 | ) 8 | 9 | execute( 10 | "update questions set question = (question - 'private' || jsonb_build_object('secret', question->'private', 'opaque', '[]'::jsonb)) where type = 'programming' and not build_hidden_testcases;" 11 | ) 12 | 13 | alter table(:questions) do 14 | remove(:build_hidden_testcases) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230214140555_create_notification_preferences.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateNotificationPreferences do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:notification_preferences) do 6 | add(:is_enabled, :boolean, default: false, null: false) 7 | 8 | add( 9 | :notification_config_id, 10 | references(:notification_configs, on_delete: :delete_all, null: false), 11 | null: false 12 | ) 13 | 14 | add(:time_option_id, references(:time_options, on_delete: :nothing), default: nil) 15 | add(:course_reg_id, references(:course_registrations, on_delete: :delete_all), null: false) 16 | 17 | timestamps() 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /src/source_lexer.xrl: -------------------------------------------------------------------------------- 1 | Definitions. 2 | 3 | SINGLELINECOMMENT = \/\/.* 4 | MULTILINECOMMENT = [/][*][^*]*[*]+([^*/][^*]*[*]+)*[/] 5 | KEYWORDS = [a-zA-Z0-9_$]* 6 | OPERATORS = (>>>=|===|!==|>>>|<<=|>>=|!=|==|<=|>=|\+=|-=|\*=|%=|<<|>>|&=|\|=|&&|\|\||\^=|--|\+\+|\+|-|\*|/|%|<|>|=|&|\||\^|\(|\)|\[|\]|\{|\}|!|~|,|;|\.|:|\?) 7 | WHITESPACE = [\s\t\r\n]+ 8 | SQSTRING = '([^\\'\r\n]|\\(.|[\r\n]))*' 9 | DQSTRING = "([^\\"\r\n]|\\(.|[\r\n]))*" 10 | 11 | Rules. 12 | 13 | {SINGLELINECOMMENT} : skip_token. 14 | {MULTILINECOMMENT} : skip_token. 15 | {WHITESPACE} : skip_token. 16 | {OPERATORS} : {token, nil}. 17 | {KEYWORDS} : {token, nil}. 18 | {SQSTRING} : {token, nil}. 19 | {DQSTRING} : {token, nil}. 20 | . : {token, nil}. 21 | 22 | Erlang code. 23 | -------------------------------------------------------------------------------- /lib/cadet/devices/device_registration.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Devices.DeviceRegistration do 2 | @moduledoc """ 3 | Represents a registration of a remote execution device by a user. 4 | """ 5 | 6 | use Cadet, :model 7 | 8 | alias Cadet.Accounts.User 9 | alias Cadet.Devices.Device 10 | 11 | @type t :: %__MODULE__{} 12 | 13 | schema "device_registrations" do 14 | field(:title, :string) 15 | 16 | belongs_to(:user, User) 17 | belongs_to(:device, Device) 18 | 19 | timestamps() 20 | end 21 | 22 | @required_fields ~w(title user_id device_id)a 23 | 24 | def changeset(device, params \\ %{}) do 25 | device 26 | |> cast(params, @required_fields) 27 | |> validate_required(@required_fields) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /deployment/terraform/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "s3" { 3 | bucket = "cadet-terraform-state" 4 | key = "cadet.tfstate" 5 | region = "ap-southeast-1" 6 | encrypt = "true" 7 | acl = "private" 8 | } 9 | 10 | required_providers { 11 | aws = { 12 | source = "hashicorp/aws" 13 | version = "~> 3.0" 14 | } 15 | 16 | random = { 17 | source = "hashicorp/random" 18 | version = "~> 3.1" 19 | } 20 | } 21 | } 22 | 23 | provider "aws" { 24 | region = "ap-southeast-1" 25 | } 26 | 27 | data "aws_region" "current" {} 28 | 29 | data "aws_caller_identity" "current" {} 30 | 31 | locals { 32 | account_id = data.aws_caller_identity.current.account_id 33 | region = data.aws_region.current.name 34 | } 35 | -------------------------------------------------------------------------------- /lib/cadet/notifications/sent_notification.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Notifications.SentNotification do 2 | @moduledoc """ 3 | SentNotification entity to store all sent notifications for logging (and future purposes etc. mailbox) 4 | """ 5 | use Ecto.Schema 6 | import Ecto.Changeset 7 | alias Cadet.Accounts.CourseRegistration 8 | 9 | schema "sent_notifications" do 10 | field(:content, :string) 11 | 12 | belongs_to(:course_reg, CourseRegistration) 13 | 14 | timestamps() 15 | end 16 | 17 | @doc false 18 | def changeset(sent_notification, attrs) do 19 | sent_notification 20 | |> cast(attrs, [:content, :course_reg_id]) 21 | |> validate_required([:content, :course_reg_id]) 22 | |> foreign_key_constraint(:course_reg_id) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /deployment/terraform/sm.tf: -------------------------------------------------------------------------------- 1 | resource "aws_secretsmanager_secret" "db" { 2 | name = "${var.env}-cadet-db" 3 | 4 | tags = { 5 | "Environment" = var.env, 6 | "Name" = "${title(var.env)} Cadet DB" 7 | } 8 | } 9 | 10 | resource "aws_secretsmanager_secret_version" "db" { 11 | secret_id = aws_secretsmanager_secret.db.id 12 | secret_string = jsonencode({ 13 | username = aws_db_instance.db.username, 14 | password = aws_db_instance.db.password, 15 | engine = aws_db_instance.db.engine, 16 | host = aws_db_instance.db.address, 17 | port = aws_db_instance.db.port, 18 | dbname = aws_db_instance.db.name, 19 | dbInstanceIdentifier = aws_db_instance.db.id 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180526050817_create_questions.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateQuestions do 2 | use Ecto.Migration 3 | 4 | alias Cadet.Assessments.QuestionType 5 | 6 | def up do 7 | QuestionType.create_type() 8 | 9 | create table(:questions) do 10 | add(:display_order, :integer) 11 | add(:type, :question_type, null: false) 12 | add(:title, :string) 13 | add(:library, :map) 14 | add(:grading_library, :map) 15 | add(:question, :map, null: false) 16 | add(:max_grade, :integer, default: 0) 17 | add(:assessment_id, references(:assessments), null: false) 18 | timestamps() 19 | end 20 | end 21 | 22 | def down do 23 | drop(table(:questions)) 24 | 25 | QuestionType.drop_type() 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/cadet/assessments/question_types/mcq_question_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.QuestionTypes.MCQQuestionTest do 2 | alias Cadet.Assessments.QuestionTypes.MCQQuestion 3 | 4 | use Cadet.ChangesetCase, entity: MCQQuestion 5 | 6 | describe "Changesets" do 7 | test "valid changeset" do 8 | assert_changeset( 9 | %{ 10 | content: "asd", 11 | choices: [%{choice_id: 1, content: "asd", is_correct: true}] 12 | }, 13 | :valid 14 | ) 15 | end 16 | 17 | test "invalid changesets" do 18 | assert_changeset(%{content: "asd"}, :invalid) 19 | 20 | assert_changeset( 21 | %{content: "asd", choices: [%{choice_id: 2, content: "asd", is_correct: false}]}, 22 | :invalid 23 | ) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/factories/courses/course_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Courses.CourseFactory do 2 | @moduledoc """ 3 | Factory for the Course entity 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | alias Cadet.Courses.Course 9 | 10 | def course_factory do 11 | %Course{ 12 | course_name: "Programming Methodology", 13 | course_short_name: "CS1101S", 14 | viewable: true, 15 | enable_game: true, 16 | enable_achievements: true, 17 | enable_sourcecast: true, 18 | enable_stories: false, 19 | source_chapter: 1, 20 | source_variant: "default", 21 | module_help_text: "Help Text", 22 | enable_llm_grading: false 23 | } 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/cadet/auth/providers/openid/google_claim_extractor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Auth.Providers.GoogleClaimExtractorTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Cadet.Auth.Providers.GoogleClaimExtractor, as: Testee 5 | 6 | @username "hello@world.com" 7 | 8 | test "test verified email" do 9 | claims = %{"email" => @username, "email_verified" => true} 10 | 11 | assert Testee.get_username(claims, "") == @username 12 | assert is_nil(Testee.get_name(claims, "")) 13 | 14 | assert Testee.get_token_type() == "id_token" 15 | end 16 | 17 | test "test non-verified email" do 18 | claims = %{"email" => @username, "email_verified" => false} 19 | 20 | assert is_nil(Testee.get_username(claims, "")) 21 | assert is_nil(Testee.get_name(claims, "")) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/cadet/auth/empty_guardian_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Auth.EmptyGuardianTest do 2 | use ExUnit.Case 3 | alias Cadet.Auth.EmptyGuardian 4 | 5 | describe "config/1" do 6 | test "returns default value for allowed_drift" do 7 | assert EmptyGuardian.config(:allowed_drift) == 10_000 8 | end 9 | 10 | test "returns nil for other keys" do 11 | assert EmptyGuardian.config(:other_key) == nil 12 | end 13 | end 14 | 15 | describe "config/2" do 16 | test "returns default value for allowed_drift regardless of second argument" do 17 | assert EmptyGuardian.config(:allowed_drift, :default) == 10_000 18 | end 19 | 20 | test "returns second argument for other keys" do 21 | assert EmptyGuardian.config(:other_key, :default) == :default 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/cadet/assessments/question_types/mcq_choice.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.QuestionTypes.MCQChoice do 2 | @moduledoc """ 3 | The Assessments.QuestionTypes.MCQChoice entity represents an MCQ Choice. 4 | """ 5 | use Cadet, :model 6 | 7 | @primary_key false 8 | embedded_schema do 9 | field(:content, :string) 10 | field(:hint, :string) 11 | field(:is_correct, :boolean) 12 | field(:choice_id, :integer) 13 | end 14 | 15 | @required_fields ~w(content is_correct choice_id)a 16 | @optional_fields ~w(hint is_correct)a 17 | 18 | def changeset(question, params \\ %{}) do 19 | question 20 | |> cast(params, @required_fields ++ @optional_fields) 21 | |> validate_required(@required_fields) 22 | |> validate_number(:choice_id, greater_than_or_equal_to: 0) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/cadet/courses/sourcecast_upload.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Courses.SourcecastUpload do 2 | @moduledoc """ 3 | Represents an uploaded file for Sourcecast 4 | """ 5 | use Arc.Definition 6 | use Arc.Ecto.Definition 7 | 8 | @extension_whitelist ~w(.wav) 9 | @versions [:original] 10 | 11 | # coveralls-ignore-start 12 | def bucket, do: :cadet |> Application.fetch_env!(:uploader) |> Keyword.get(:sourcecasts_bucket) 13 | # coveralls-ignore-stop 14 | 15 | def storage_dir(_, _) do 16 | if Cadet.Env.env() == :test do 17 | "uploads/test/sourcecasts" 18 | else 19 | "" 20 | end 21 | end 22 | 23 | def validate({file, _}) do 24 | file_extension = file.file_name |> Path.extname() |> String.downcase() 25 | Enum.member?(@extension_whitelist, file_extension) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/cadet_web/views/notifications_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.NotificationsView do 2 | use CadetWeb, :view 3 | 4 | def render("index.json", %{notifications: notifications}) do 5 | render_many(notifications, CadetWeb.NotificationsView, "notification.json") 6 | end 7 | 8 | def render("notification.json", %{notifications: notifications}) do 9 | transform_map_for_view(notifications, %{ 10 | id: :id, 11 | type: :type, 12 | assessment_id: :assessment_id, 13 | submission_id: :submission_id, 14 | assessment: &render_notification_assessment/1 15 | }) 16 | end 17 | 18 | defp render_notification_assessment(notification) do 19 | transform_map_for_view(notification.assessment, %{ 20 | type: & &1.config.type, 21 | title: :title 22 | }) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/cadet_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | use Gettext, backend: CadetWeb.Gettext 9 | 10 | # Simple translation 11 | gettext "Here is the string to translate" 12 | 13 | # Plural translation 14 | ngettext "Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3 17 | 18 | # Domain-based translation 19 | dgettext "errors", "Here is the error message to translate" 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext.Backend, otp_app: :cadet 24 | end 25 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200706185258_create_devices.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateDevices do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:devices) do 6 | add(:secret, :string, null: false) 7 | add(:type, :string, null: false) 8 | 9 | add(:client_key, :bytea, null: true, default: nil) 10 | add(:client_cert, :bytea, null: true, default: nil) 11 | 12 | timestamps() 13 | end 14 | 15 | create(unique_index(:devices, [:secret])) 16 | 17 | create table(:device_registrations) do 18 | add(:title, :string, null: false) 19 | 20 | add(:user_id, references(:users), null: false) 21 | add(:device_id, references(:devices), null: false) 22 | 23 | timestamps() 24 | end 25 | 26 | create(index(:device_registrations, [:user_id])) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/cadet/accounts/user_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Accounts.UserTest do 2 | alias Cadet.Accounts.User 3 | 4 | use Cadet.ChangesetCase, entity: User 5 | 6 | describe "Changesets" do 7 | test "valid changeset" do 8 | assert_changeset(%{provider: "test", username: "luminus/E0000000"}, :valid) 9 | assert_changeset(%{provider: "test", username: "luminus/E0000001", name: "Avenger"}, :valid) 10 | assert_changeset(%{provider: "test", username: "happy", latest_viewed_course_id: 1}, :valid) 11 | end 12 | 13 | test "invalid changeset" do 14 | assert_changeset(%{name: "people"}, :invalid) 15 | assert_changeset(%{latest_viewed_course_id: 1}, :invalid) 16 | assert_changeset(%{username: "luminus/E0000000"}, :invalid) 17 | assert_changeset(%{provider: "test"}, :invalid) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180704020027_create_answers.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateAnswersTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:answers) do 6 | add(:grade, :integer, default: 0) 7 | add(:answer, :map, null: false) 8 | add(:submission_id, references(:submissions), null: false) 9 | add(:question_id, references(:questions), null: false) 10 | add(:comment, :text) 11 | add(:adjustment, :integer, default: 0) 12 | 13 | timestamps() 14 | end 15 | 16 | create( 17 | unique_index( 18 | :answers, 19 | [:submission_id, :question_id], 20 | name: :answers_submission_id_question_id_index 21 | ) 22 | ) 23 | 24 | create(index(:answers, [:submission_id])) 25 | create(index(:answers, [:question_id])) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/cadet/assessments/question_types/programming_question_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.QuestionTypes.ProgrammingQuestionTest do 2 | alias Cadet.Assessments.QuestionTypes.ProgrammingQuestion 3 | 4 | use Cadet.ChangesetCase, entity: ProgrammingQuestion 5 | 6 | describe "Changesets" do 7 | test "valid changeset" do 8 | assert_changeset( 9 | %{ 10 | content: "asd", 11 | template: "asd" 12 | }, 13 | :valid 14 | ) 15 | end 16 | 17 | test "invalid changesets" do 18 | assert_changeset( 19 | %{ 20 | content: 1, 21 | template: "asd" 22 | }, 23 | :invalid 24 | ) 25 | 26 | assert_changeset( 27 | %{ 28 | content: "asd" 29 | }, 30 | :invalid 31 | ) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/cadet/auth/provider_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Auth.ProviderTest do 2 | @moduledoc """ 3 | Some of the test values in this file are specified in config/test.exs. 4 | """ 5 | 6 | use ExUnit.Case, async: true 7 | 8 | alias Cadet.Auth.Provider 9 | 10 | test "with valid provider" do 11 | assert {:ok, _} = Provider.authorise(%{provider_instance: "test", code: "student_code"}) 12 | 13 | assert {:ok, _} = Provider.get_name("test", "student_token") 14 | end 15 | 16 | test "with invalid provider" do 17 | assert {:error, :other, "Invalid or nonexistent provider config"} = 18 | Provider.authorise(%{provider_instance: "3452345", code: "student_code"}) 19 | 20 | assert {:error, :other, "Invalid or nonexistent provider config"} = 21 | Provider.get_name("32523453", "student_token") 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180101181301_create_authorizations.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateAuthorizations do 2 | use Ecto.Migration 3 | 4 | def up do 5 | Ecto.Migration.execute("CREATE TYPE provider AS ENUM ('nusnet_id')") 6 | 7 | create table(:authorizations) do 8 | add(:provider, :provider, null: false) 9 | add(:uid, :string, null: false) 10 | add(:user_id, references(:users, on_delete: :delete_all)) 11 | add(:expires_at, :bigint) 12 | end 13 | 14 | create(unique_index(:authorizations, [:provider, :uid])) 15 | create(index(:authorizations, [:provider])) 16 | create(index(:authorizations, [:uid])) 17 | create(index(:authorizations, [:user_id])) 18 | end 19 | 20 | def down do 21 | drop(table(:authorizations)) 22 | Ecto.Migration.execute("DROP TYPE provider") 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/cadet_web/views/sourcecast_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.SourcecastView do 2 | use CadetWeb, :view 3 | 4 | def render("index.json", %{sourcecasts: sourcecasts}) do 5 | render_many(sourcecasts, CadetWeb.SourcecastView, "show.json", as: :sourcecast) 6 | end 7 | 8 | def render("show.json", %{sourcecast: sourcecast}) do 9 | transform_map_for_view(sourcecast, %{ 10 | id: :id, 11 | title: :title, 12 | description: :description, 13 | uid: :uid, 14 | inserted_at: &format_datetime(&1.inserted_at), 15 | updated_at: &format_datetime(&1.updated_at), 16 | audio: :audio, 17 | playbackData: :playbackData, 18 | uploader: &transform_map_for_view(&1.uploader, [:name, :id]), 19 | url: &Cadet.Courses.SourcecastUpload.url({&1.audio, &1}), 20 | courseId: :course_id 21 | }) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "07:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: git_hooks 11 | versions: 12 | - 0.6.0 13 | - 0.6.1 14 | - dependency-name: ex_aws 15 | versions: 16 | - 2.1.7 17 | - 2.1.9 18 | - 2.2.0 19 | - dependency-name: timex 20 | versions: 21 | - 3.6.4 22 | - 3.7.0 23 | - 3.7.2 24 | - 3.7.3 25 | - dependency-name: hackney 26 | versions: 27 | - 1.17.1 28 | - 1.17.2 29 | - 1.17.3 30 | - 1.17.4 31 | - dependency-name: phoenix 32 | versions: 33 | - 1.5.8 34 | - dependency-name: ex_machina 35 | versions: 36 | - 2.6.0 37 | - 2.7.0 38 | - dependency-name: phoenix_swagger 39 | versions: 40 | - 0.8.3 41 | -------------------------------------------------------------------------------- /lib/cadet_web/views/team_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.TeamView do 2 | @moduledoc """ 3 | View module for rendering team-related data as JSON. 4 | """ 5 | 6 | use CadetWeb, :view 7 | 8 | @doc """ 9 | Renders the JSON representation of team formation overview. 10 | 11 | ## Parameters 12 | 13 | * `teamFormationOverview` - A map containing team formation overview data. 14 | 15 | """ 16 | def render("index.json", %{teamFormationOverview: teamFormationOverview}) do 17 | %{ 18 | teamId: teamFormationOverview.teamId, 19 | assessmentId: teamFormationOverview.assessmentId, 20 | assessmentName: teamFormationOverview.assessmentName, 21 | assessmentType: teamFormationOverview.assessmentType, 22 | studentIds: teamFormationOverview.studentIds, 23 | studentNames: teamFormationOverview.studentNames 24 | } 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/cadet_web/admin_views/admin_courses_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.AdminCoursesView do 2 | use CadetWeb, :view 3 | 4 | def render("assessment_configs.json", %{configs: configs}) do 5 | render_many(configs, CadetWeb.AdminCoursesView, "config.json", as: :config) 6 | end 7 | 8 | def render("config.json", %{config: config}) do 9 | transform_map_for_view(config, %{ 10 | assessmentConfigId: :id, 11 | type: :type, 12 | displayInDashboard: :show_grading_summary, 13 | isMinigame: :is_minigame, 14 | isManuallyGraded: :is_manually_graded, 15 | earlySubmissionXp: :early_submission_xp, 16 | hasVotingFeatures: :has_voting_features, 17 | hasTokenCounter: :has_token_counter, 18 | hoursBeforeEarlyXpDecay: :hours_before_early_xp_decay, 19 | isGradingAutoPublished: :is_grading_auto_published 20 | }) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/fixtures/custom_cassettes/chatbot/chat_conversation#1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "request": { 4 | "url": "https://api.openai.com/v1/chat/completions", 5 | "method": "post", 6 | "body": "{\"model\":\"gpt-4\",\"messages\":[]}" 7 | }, 8 | "response": { 9 | "binary": false, 10 | "status_code": 200, 11 | "headers": { 12 | "Content-Type": "application/json", 13 | "Content-Length": "319" 14 | }, 15 | "body": "{\"choices\":[{\"finish_reason\":\"stop\",\"index\":0,\"message\":{\"content\":\"Some hardcoded test response.\",\"role\":\"assistant\"},\"logprobs\":null}],\"created\":1677664795,\"id\":\"chatcmpl-7QyqpwdfhqwajicIEznoc6Q47XAyW\",\"model\":\"gpt-4o\",\"object\":\"chat.completion\",\"usage\":{\"completion_tokens\":17,\"prompt_tokens\":57,\"total_tokens\":74}}\n", 16 | "type": "ok" 17 | } 18 | } 19 | ] 20 | -------------------------------------------------------------------------------- /test/cadet/auth/providers/openid/auth0_claim_extractor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Auth.Providers.Auth0ClaimExtractorTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Cadet.Auth.Providers.Auth0ClaimExtractor, as: Testee 5 | 6 | @username "hello@world.com" 7 | 8 | test "test verified email" do 9 | claims = %{ 10 | "email" => @username, 11 | "email_verified" => true, 12 | "name" => "name name", 13 | "https://source-academy.github.io/role" => "admin" 14 | } 15 | 16 | assert Testee.get_username(claims, "") == @username 17 | assert Testee.get_name(claims, "") == "name name" 18 | 19 | assert Testee.get_token_type() == "id_token" 20 | end 21 | 22 | test "test non-verified email" do 23 | claims = %{"email" => @username, "email_verified" => false} 24 | 25 | assert is_nil(Testee.get_username(claims, "")) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/cadet/ai_comments/ai_comment.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.AIComments.AIComment do 2 | @moduledoc """ 3 | Defines the schema and changeset for AI comments. 4 | """ 5 | 6 | use Ecto.Schema 7 | import Ecto.Changeset 8 | 9 | schema "ai_comment_logs" do 10 | field(:raw_prompt, :string) 11 | field(:answers_json, :string) 12 | field(:response, :string) 13 | field(:error, :string) 14 | field(:final_comment, :string) 15 | 16 | belongs_to(:answer, Cadet.Assessments.Answer) 17 | 18 | timestamps() 19 | end 20 | 21 | @required_fields ~w(answer_id raw_prompt answers_json)a 22 | @optional_fields ~w(response error final_comment)a 23 | 24 | def changeset(ai_comment, attrs) do 25 | ai_comment 26 | |> cast(attrs, @required_fields ++ @optional_fields) 27 | |> validate_required(@required_fields) 28 | |> foreign_key_constraint(:answer_id) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/cadet/auth/providers/openid/mit_claim_extractor.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Auth.Providers.MITClaimExtractor do 2 | @moduledoc """ 3 | Extracts fields from MIT OIDC JWTs. 4 | """ 5 | 6 | @behaviour Cadet.Auth.Providers.OpenID.ClaimExtractor 7 | 8 | def get_username(_claims, access_token), do: get_userinfo(access_token, "email") 9 | 10 | def get_name(_claims, access_token), do: get_userinfo(access_token, "name") 11 | 12 | def get_token_type, do: "access_token" 13 | 14 | defp get_userinfo(token, key) do 15 | headers = [{"Authorization", "Bearer #{token}"}] 16 | options = [timeout: 10_000, recv_timeout: 10_000] 17 | 18 | case HTTPoison.get("https://oidc.mit.edu/userinfo", headers, options) do 19 | {:ok, %{body: body, status_code: 200}} -> 20 | body |> Jason.decode!() |> Map.get(key) 21 | 22 | {:ok, _} -> 23 | nil 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/factories/assessments/answer_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.AnswerFactory do 2 | @moduledoc """ 3 | Factory for the Answer entity 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | alias Cadet.Assessments.Answer 9 | 10 | def answer_factory do 11 | %Answer{ 12 | answer: %{}, 13 | autograding_status: :none, 14 | comments: Faker.Food.dish() 15 | } 16 | end 17 | 18 | def programming_answer_factory do 19 | %{ 20 | code: sequence(:code, &"return #{&1};") 21 | } 22 | end 23 | 24 | def mcq_answer_factory do 25 | %{ 26 | choice_id: Enum.random(0..2) 27 | } 28 | end 29 | 30 | def voting_answer_factory do 31 | %{ 32 | completed: Enum.random(0..1) == 1 33 | } 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/fixtures/custom_cassettes/adfs/authorise#5.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "request": { 4 | "body": "blah", 5 | "headers": { 6 | "Content-Type": "application/x-www-form-urlencoded" 7 | }, 8 | "method": "post", 9 | "options": [], 10 | "request_body": "", 11 | "url": "https://my-adfs/adfs/oauth2/token" 12 | }, 13 | "response": { 14 | "binary": false, 15 | "body": "{\"error\":\"invalid_client\",\"error_description\":\"boom.\"}", 16 | "headers": { 17 | "Cache-Control": "no-store", 18 | "Pragma": "no-cache", 19 | "Content-Length": "173", 20 | "Content-Type": "application/json;charset=UTF-8", 21 | "Server": "Microsoft-HTTPAPI/2.0 Microsoft-HTTPAPI/2.0", 22 | "Date": "Sun, 03 Jul 2022 07:17:08 GMT" 23 | }, 24 | "status_code": 500, 25 | "type": "ok" 26 | } 27 | } 28 | ] 29 | -------------------------------------------------------------------------------- /lib/cadet/auth/providers/openid/mit_csail_claim_extractor.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Auth.Providers.MITCSAILClaimExtractor do 2 | @moduledoc """ 3 | Extracts fields from MIT CSAIL OIDC JWTs. 4 | """ 5 | 6 | @behaviour Cadet.Auth.Providers.OpenID.ClaimExtractor 7 | 8 | def get_username(_claims, access_token), do: get_userinfo(access_token, "email") 9 | 10 | def get_name(_claims, access_token), do: get_userinfo(access_token, "name") 11 | 12 | def get_token_type, do: "access_token" 13 | 14 | defp get_userinfo(token, key) do 15 | headers = [{"Authorization", "Bearer #{token}"}] 16 | options = [timeout: 10_000, recv_timeout: 10_000] 17 | 18 | case HTTPoison.get("https://oidc.csail.mit.edu/userinfo", headers, options) do 19 | {:ok, %{body: body, status_code: 200}} -> 20 | body |> Jason.decode!() |> Map.get(key) 21 | 22 | {:ok, _} -> 23 | nil 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/cadet/assessments/query_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.QueryTest do 2 | use Cadet.DataCase 3 | 4 | alias Cadet.Assessments.Query 5 | 6 | test "all_assessments_with_max_grade" do 7 | assessment = insert(:assessment) 8 | insert_list(5, :question, assessment: assessment, max_xp: 200) 9 | 10 | result = 11 | Query.all_assessments_with_max_xp() 12 | |> where(id: ^assessment.id) 13 | |> Repo.one() 14 | 15 | assessment_id = assessment.id 16 | 17 | assert %{max_xp: 1000, id: ^assessment_id} = result 18 | end 19 | 20 | test "assessments_max_grade" do 21 | assessment = insert(:assessment) 22 | insert_list(5, :question, assessment: assessment, max_xp: 200) 23 | 24 | result = 25 | Query.assessments_max_xp() 26 | |> Repo.all() 27 | |> Enum.find(&(&1.assessment_id == assessment.id)) 28 | 29 | assert result.max_xp == 1000 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/cadet/incentives/achievement_to_goal.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Incentives.AchievementToGoal do 2 | @moduledoc """ 3 | Joins achievements to goals. 4 | """ 5 | use Cadet, :model 6 | 7 | alias Cadet.Incentives.{Achievement, Goal} 8 | 9 | @primary_key false 10 | @foreign_key_type :binary_id 11 | schema "achievement_to_goal" do 12 | belongs_to(:achievement, Achievement, 13 | foreign_key: :achievement_uuid, 14 | primary_key: true, 15 | references: :uuid 16 | ) 17 | 18 | belongs_to(:goal, Goal, foreign_key: :goal_uuid, primary_key: true, references: :uuid) 19 | end 20 | 21 | @required_fields ~w(achievement_uuid goal_uuid)a 22 | 23 | def changeset(join, params) do 24 | join 25 | |> cast(params, @required_fields) 26 | |> validate_required(@required_fields) 27 | |> foreign_key_constraint(:achievement_uuid) 28 | |> foreign_key_constraint(:goal_uuid) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/factories/assessments/library_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.LibraryFactory do 2 | @moduledoc """ 3 | Factory for the Library entity 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | alias Cadet.Assessments.Library.ExternalLibraryName 9 | 10 | def library_factory do 11 | %{ 12 | chapter: Enum.random(1..4), 13 | globals: 14 | Enum.reduce( 15 | 0..5, 16 | %{}, 17 | fn _, acc -> Map.put(acc, Faker.Lorem.word(), Faker.Lorem.sentence()) end 18 | ), 19 | external: build(:external_library) 20 | } 21 | end 22 | 23 | def external_library_factory do 24 | %{ 25 | name: Enum.random(~w(none runes curves sounds binarytrees pixnflix)), 26 | symbols: Faker.Lorem.words(Enum.random(5..15)) 27 | } 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240221033707_alter_submissions_table.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AlterSubmissionsTable do 2 | use Ecto.Migration 3 | 4 | def up do 5 | execute("ALTER TABLE submissions DROP CONSTRAINT IF EXISTS submissions_student_id_fkey;") 6 | 7 | alter table(:submissions) do 8 | modify(:student_id, references(:course_registrations), null: true) 9 | add(:team_id, references(:teams), null: true) 10 | end 11 | 12 | execute("ALTER TABLE submissions ADD CONSTRAINT xor_constraint CHECK ( 13 | (student_id IS NULL AND team_id IS NOT NULL) OR 14 | (student_id IS NOT NULL AND team_id IS NULL) 15 | );") 16 | end 17 | 18 | def down do 19 | execute("ALTER TABLE submissions DROP CONSTRAINT xor_constraint;") 20 | 21 | alter table(:submissions) do 22 | modify(:student_id, references(:course_registrations), null: false) 23 | drop(:team_id) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /priv/repo/migrations/20250429081534_add_leaderboard_display_columns.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddLeaderboardDisplayColumns do 2 | use Ecto.Migration 3 | 4 | def up do 5 | alter table(:courses) do 6 | add(:enable_overall_leaderboard, :boolean, null: false, default: true) 7 | add(:enable_contest_leaderboard, :boolean, null: false, default: true) 8 | add(:top_leaderboard_display, :integer, default: 100) 9 | add(:top_contest_leaderboard_display, :integer, default: 10) 10 | end 11 | 12 | execute(""" 13 | UPDATE courses 14 | SET enable_overall_leaderboard = false, enable_contest_leaderboard = false 15 | """) 16 | end 17 | 18 | def down do 19 | alter table(:courses) do 20 | remove(:enable_overall_leaderboard) 21 | remove(:enable_contest_leaderboard) 22 | remove(:top_leaderboard_display) 23 | remove(:top_contest_leaderboard_display) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/fixtures/vcr_cassettes/aws/model_delete_asset#1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "request": { 4 | "body": "", 5 | "headers": { 6 | "Authorization": "***", 7 | "host": "s3.ap-southeast-1.amazonaws.com", 8 | "x-amz-date": "20210802T105948Z", 9 | "content-length": "0", 10 | "x-amz-content-sha256": "***" 11 | }, 12 | "method": "head", 13 | "options": { 14 | "with_body": "true", 15 | "recv_timeout": 660000 16 | }, 17 | "request_body": "", 18 | "url": "https://s3.ap-southeast-1.amazonaws.com/test-sa-assets/courses-test/1/testFolder/test4.png" 19 | }, 20 | "response": { 21 | "binary": false, 22 | "body": null, 23 | "headers": { 24 | "Content-Type": "application/xml", 25 | "Date": "Mon, 02 Aug 2021 10:59:47 GMT", 26 | "Server": "AmazonS3" 27 | }, 28 | "status_code": 404, 29 | "type": "ok" 30 | } 31 | } 32 | ] 33 | -------------------------------------------------------------------------------- /lib/cadet/notifications/time_option.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Notifications.TimeOption do 2 | @moduledoc """ 3 | TimeOption entity for options course admins have created for notifications 4 | """ 5 | use Ecto.Schema 6 | import Ecto.Changeset 7 | alias Cadet.Notifications.NotificationConfig 8 | 9 | schema "time_options" do 10 | field(:is_default, :boolean, default: false) 11 | field(:minutes, :integer) 12 | 13 | belongs_to(:notification_config, NotificationConfig) 14 | 15 | timestamps() 16 | end 17 | 18 | @doc false 19 | def changeset(time_option, attrs) do 20 | time_option 21 | |> cast(attrs, [:minutes, :is_default, :notification_config_id]) 22 | |> validate_required([:minutes, :notification_config_id]) 23 | |> validate_number(:minutes, greater_than_or_equal_to: 0) 24 | |> unique_constraint([:minutes, :notification_config_id], name: :unique_time_options) 25 | |> foreign_key_constraint(:notification_config_id) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/cadet/incentives/goal.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Incentives.Goal do 2 | @moduledoc """ 3 | Represents a goal. 4 | """ 5 | use Cadet, :model 6 | 7 | alias Cadet.Courses.Course 8 | alias Cadet.Incentives.{AchievementToGoal, GoalProgress} 9 | 10 | @type t :: %__MODULE__{} 11 | 12 | @primary_key {:uuid, :binary_id, autogenerate: false} 13 | schema "goals" do 14 | field(:text, :string) 15 | field(:target_count, :integer) 16 | 17 | field(:type, :string) 18 | field(:meta, :map) 19 | 20 | belongs_to(:course, Course) 21 | has_many(:progress, GoalProgress, foreign_key: :goal_uuid) 22 | has_many(:achievements, AchievementToGoal, on_replace: :delete_if_exists) 23 | end 24 | 25 | @required_fields ~w(uuid text target_count type meta course_id)a 26 | 27 | def changeset(goal, params) do 28 | goal 29 | |> cast(params, @required_fields) 30 | |> validate_required(@required_fields) 31 | |> foreign_key_constraint(:course_id) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/cadet/assessments/question_types/voting_question.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.QuestionTypes.VotingQuestion do 2 | @moduledoc """ 3 | The VotingQuestion entity represents a Voting question. 4 | """ 5 | use Cadet, :model 6 | 7 | @primary_key false 8 | embedded_schema do 9 | field(:content, :string) 10 | field(:prepend, :string, default: "") 11 | field(:template, :string) 12 | field(:contest_number, :string) 13 | field(:reveal_hours, :integer) 14 | field(:token_divider, :integer) 15 | field(:xp_values, {:array, :integer}, default: [500, 400, 300]) 16 | end 17 | 18 | @required_fields ~w(content contest_number reveal_hours token_divider)a 19 | @optional_fields ~w(prepend template xp_values)a 20 | 21 | def changeset(question, params \\ %{}) do 22 | question 23 | |> cast(params, @required_fields ++ @optional_fields) 24 | |> validate_required(@required_fields) 25 | |> validate_number(:token_divider, greater_than: 0) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/cadet/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo do 2 | use Ecto.Repo, otp_app: :cadet, adapter: Ecto.Adapters.Postgres 3 | 4 | alias ExAws.SecretsManager 5 | 6 | @dialyzer {:no_match, init: 2} 7 | 8 | @doc """ 9 | Dynamically obtains the database credentials from AWS Secrets Manager. 10 | """ 11 | def init(_, opts) do 12 | case Keyword.get(opts, :rds_secret_name) do 13 | nil -> 14 | {:ok, opts} 15 | 16 | rds_secret_name -> 17 | %{"SecretString" => credentials_json} = 18 | rds_secret_name |> SecretsManager.get_secret_value() |> ExAws.request!() 19 | 20 | credentials = Jason.decode!(credentials_json) 21 | 22 | {:ok, 23 | Keyword.merge(opts, 24 | username: credentials["username"], 25 | password: credentials["password"], 26 | hostname: credentials["host"], 27 | port: credentials["port"], 28 | database: credentials["dbname"] 29 | )} 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/cadet_web/admin_views/admin_user_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.AdminUserView do 2 | use CadetWeb, :view 3 | 4 | def render("users.json", %{users: users}) do 5 | render_many(users, CadetWeb.AdminUserView, "cr.json", as: :cr) 6 | end 7 | 8 | def render("get_students.json", %{users: users}) do 9 | render_many(users, CadetWeb.AdminUserView, "students.json", as: :students) 10 | end 11 | 12 | def render("cr.json", %{cr: cr}) do 13 | %{ 14 | courseRegId: cr.id, 15 | course_id: cr.course_id, 16 | name: cr.user.name, 17 | provider: cr.user.provider, 18 | username: cr.user.username, 19 | role: cr.role, 20 | group: 21 | case cr.group do 22 | nil -> nil 23 | _ -> cr.group.name 24 | end 25 | } 26 | end 27 | 28 | def render("students.json", %{students: students}) do 29 | %{ 30 | userId: students.id, 31 | name: students.user.name, 32 | username: students.user.username 33 | } 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/cadet/incentives/goal_progress.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Incentives.GoalProgress do 2 | @moduledoc """ 3 | Represents goal progress per user. 4 | """ 5 | use Cadet, :model 6 | 7 | alias Cadet.Incentives.Goal 8 | alias Cadet.Accounts.CourseRegistration 9 | 10 | @primary_key false 11 | schema "goal_progress" do 12 | field(:count, :integer) 13 | field(:completed, :boolean) 14 | 15 | belongs_to(:course_reg, CourseRegistration, primary_key: true) 16 | 17 | belongs_to(:goal, Goal, 18 | primary_key: true, 19 | foreign_key: :goal_uuid, 20 | type: :binary_id, 21 | references: :uuid 22 | ) 23 | 24 | timestamps() 25 | end 26 | 27 | @required_fields ~w(count completed course_reg_id goal_uuid)a 28 | 29 | def changeset(progress, params) do 30 | progress 31 | |> cast(params, @required_fields) 32 | |> validate_required(@required_fields) 33 | |> foreign_key_constraint(:course_reg_id) 34 | |> foreign_key_constraint(:goal_uuid) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/fixtures/custom_cassettes/adfs/authorise#4.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "request": { 4 | "body": "blah", 5 | "headers": { 6 | "Content-Type": "application/x-www-form-urlencoded" 7 | }, 8 | "method": "post", 9 | "options": [], 10 | "request_body": "", 11 | "url": "https://my-adfs/adfs/oauth2/token" 12 | }, 13 | "response": { 14 | "binary": false, 15 | "body": "{\"error\":\"invalid_client\",\"error_description\":\"MSIS9607: The \\u0027client_id\\u0027 parameter in the request is invalid. No registered client is found with this identifier.\"}", 16 | "headers": { 17 | "Cache-Control": "no-store", 18 | "Pragma": "no-cache", 19 | "Content-Length": "173", 20 | "Content-Type": "application/json;charset=UTF-8", 21 | "Server": "Microsoft-HTTPAPI/2.0 Microsoft-HTTPAPI/2.0", 22 | "Date": "Sun, 03 Jul 2022 07:17:08 GMT" 23 | }, 24 | "status_code": 400, 25 | "type": "ok" 26 | } 27 | } 28 | ] 29 | -------------------------------------------------------------------------------- /lib/cadet/incentives/achievement_prerequisite.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Incentives.AchievementPrerequisite do 2 | @moduledoc """ 3 | Represents achievement prerequisites. 4 | """ 5 | use Cadet, :model 6 | 7 | alias Cadet.Incentives.Achievement 8 | 9 | @primary_key false 10 | @foreign_key_type :binary_id 11 | schema "achievement_prerequisites" do 12 | belongs_to(:achievement, Achievement, 13 | foreign_key: :achievement_uuid, 14 | primary_key: true, 15 | references: :uuid 16 | ) 17 | 18 | belongs_to(:prerequisite, Achievement, 19 | foreign_key: :prerequisite_uuid, 20 | primary_key: true, 21 | references: :uuid 22 | ) 23 | end 24 | 25 | @required_fields ~w(achievement_uuid prerequisite_uuid)a 26 | 27 | def changeset(prerequisite, params) do 28 | prerequisite 29 | |> cast(params, @required_fields) 30 | |> validate_required(@required_fields) 31 | |> foreign_key_constraint(:achievement_uuid) 32 | |> foreign_key_constraint(:prerequisite_uuid) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/fixtures/custom_cassettes/adfs/authorise#3.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "request": { 4 | "body": "blah", 5 | "headers": { 6 | "Content-Type": "application/x-www-form-urlencoded" 7 | }, 8 | "method": "post", 9 | "options": [], 10 | "request_body": "", 11 | "url": "https://my-adfs/adfs/oauth2/token" 12 | }, 13 | "response": { 14 | "binary": false, 15 | "body": "{\"error\":\"invalid_request\",\"error_description\":\"MSIS9609: The \\u0027redirect_uri\\u0027 parameter is invalid. No redirect uri with the specified value is registered for the received \\u0027client_id\\u0027. \"}", 16 | "headers": { 17 | "Cache-Control": "no-store", 18 | "Pragma": "no-cache", 19 | "Content-Length": "206", 20 | "Content-Type": "application/json;charset=UTF-8", 21 | "Server": "Microsoft-HTTPAPI/2.0 Microsoft-HTTPAPI/2.0", 22 | "Date": "Sun, 03 Jul 2022 07:16:40 GMT" 23 | }, 24 | "status_code": 400, 25 | "type": "ok" 26 | } 27 | } 28 | ] 29 | -------------------------------------------------------------------------------- /lib/cadet_web/controllers/jwks_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.JWKSController do 2 | use CadetWeb, :controller 3 | 4 | alias Guardian.Token.Jwt.SecretFetcher.SecretFetcherDefaultImpl 5 | alias JOSE.JWK 6 | 7 | # note this has to be after the Guardian aliases above, otherwise they will 8 | # try to alias submodules of this module 9 | alias Cadet.Auth.Guardian 10 | 11 | def index(conn, _params) do 12 | json(conn, %{keys: fetch_jwks()}) 13 | end 14 | 15 | defp fetch_jwks do 16 | secret_fetcher = Guardian.config(:secret_fetcher, SecretFetcherDefaultImpl) 17 | {:ok, secret} = secret_fetcher.fetch_signing_secret(Guardian, []) 18 | 19 | {_, public_jwk} = 20 | secret 21 | |> to_jwk() 22 | |> JWK.to_public_map() 23 | 24 | [public_jwk] 25 | rescue 26 | _ -> [] 27 | end 28 | 29 | # Convert the value from Guardian's configuration to a %JWK{} 30 | defp to_jwk(s = %JWK{}), do: s 31 | defp to_jwk(s) when is_binary(s), do: JWK.from_oct(s) 32 | defp to_jwk(s) when is_map(s), do: JWK.from_map(s) 33 | end 34 | -------------------------------------------------------------------------------- /lib/cadet/assessments/submission_votes.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.SubmissionVotes do 2 | @moduledoc false 3 | use Cadet, :model 4 | 5 | alias Cadet.Accounts.CourseRegistration 6 | alias Cadet.Assessments.{Question, Submission} 7 | 8 | schema "submission_votes" do 9 | field(:score, :integer) 10 | 11 | belongs_to(:voter, CourseRegistration) 12 | belongs_to(:submission, Submission) 13 | belongs_to(:question, Question) 14 | timestamps() 15 | end 16 | 17 | @required_fields ~w(voter_id submission_id question_id)a 18 | @optional_fields ~w(score)a 19 | 20 | # There is no unique constraint for contest vote scores. 21 | def changeset(submission_vote, params) do 22 | submission_vote 23 | |> cast(params, @required_fields ++ @optional_fields) 24 | |> add_belongs_to_id_from_model([:voter, :submission, :question], params) 25 | |> validate_required(@required_fields) 26 | |> foreign_key_constraint(:voter_id) 27 | |> foreign_key_constraint(:submission_id) 28 | |> foreign_key_constraint(:question_id) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/fixtures/custom_cassettes/adfs/authorise#2.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "request": { 4 | "body": "client_id=CLIENT_ID&code=CODE_invalid&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Flogin", 5 | "headers": { 6 | "Content-Type": "application/x-www-form-urlencoded" 7 | }, 8 | "method": "post", 9 | "options": [], 10 | "request_body": "", 11 | "url": "https://my-adfs/adfs/oauth2/token" 12 | }, 13 | "response": { 14 | "binary": false, 15 | "body": "{\"error\":\"invalid_grant\",\"error_description\":\"MSIS9612: The authorization code received in \\u0027code\\u0027 parameter is invalid. \"}", 16 | "headers": { 17 | "Cache-Control": "no-store", 18 | "Pragma": "no-cache", 19 | "Content-Length": "132", 20 | "Content-Type": "application/json;charset=UTF-8", 21 | "Server": "Microsoft-HTTPAPI/2.0 Microsoft-HTTPAPI/2.0", 22 | "Date": "Sun, 03 Jul 2022 07:14:54 GMT" 23 | }, 24 | "status_code": 400, 25 | "type": "ok" 26 | } 27 | } 28 | ] -------------------------------------------------------------------------------- /priv/repo/migrations/20200707073617_create_achievements.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateAchievements do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:achievements, primary_key: false) do 6 | add(:id, :integer, null: false, primary_key: true) 7 | 8 | add(:title, :text, null: false) 9 | add(:ability, :text, null: false, default: "Core") 10 | add(:card_tile_url, :text) 11 | 12 | add(:open_at, :timestamp, default: fragment("NOW()")) 13 | add(:close_at, :timestamp, default: fragment("NOW()")) 14 | add(:is_task, :boolean, null: false, default: false) 15 | add(:position, :integer, null: false) 16 | 17 | add(:canvas_url, :text, 18 | default: 19 | "https://www.publicdomainpictures.net/pictures/30000/velka/plain-white-background.jpg" 20 | ) 21 | 22 | add(:description, :text) 23 | add(:completion_text, :text) 24 | 25 | timestamps() 26 | end 27 | 28 | create(index(:achievements, [:open_at])) 29 | create(index(:achievements, [:close_at])) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/cadet/assessments/question_types/mcq_choice_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.QuestionTypes.MCQChoiceTest do 2 | alias Cadet.Assessments.QuestionTypes.MCQChoice 3 | 4 | use Cadet.ChangesetCase, entity: MCQChoice 5 | 6 | describe "Changesets" do 7 | test "valid changesets" do 8 | assert_changeset(%{choice_id: 1, content: "asd", is_correct: true}, :valid) 9 | assert_changeset(%{choice_id: 4, content: "asd", hint: "asd", is_correct: true}, :valid) 10 | end 11 | 12 | test "invalid changesets" do 13 | assert_changeset(%{choice_id: 1, content: "asd"}, :invalid) 14 | assert_changeset(%{choice_id: 1, hint: "asd"}, :invalid) 15 | assert_changeset(%{choice_id: 1, is_correct: false}, :invalid) 16 | assert_changeset(%{choice_id: 1, content: "asd", hint: "aaa"}, :invalid) 17 | assert_changeset(%{content: 1, is_correct: true}, :invalid) 18 | assert_changeset(%{choice_id: 6, content: 1, is_correct: true}, :invalid) 19 | assert_changeset(%{choice_id: -1, content: 1, is_correct: true}, :invalid) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/cadet_web/views/team_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.TeamViewTest do 2 | use CadetWeb.ConnCase, async: true 3 | 4 | alias CadetWeb.TeamView 5 | 6 | @team_formation_overview %{ 7 | teamId: 1, 8 | assessmentId: 2, 9 | assessmentName: "Test Assessment", 10 | assessmentType: "Test Type", 11 | studentIds: [1, 2, 3], 12 | studentNames: ["Alice", "Bob", "Charlie"] 13 | } 14 | 15 | describe "render/2" do 16 | test "renders team formation overview as JSON" do 17 | json = TeamView.render("index.json", %{teamFormationOverview: @team_formation_overview}) 18 | 19 | assert json[:teamId] == @team_formation_overview.teamId 20 | assert json[:assessmentId] == @team_formation_overview.assessmentId 21 | assert json[:assessmentName] == @team_formation_overview.assessmentName 22 | assert json[:assessmentType] == @team_formation_overview.assessmentType 23 | assert json[:studentIds] == @team_formation_overview.studentIds 24 | assert json[:studentNames] == @team_formation_overview.studentNames 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/factories/achievements/achievement_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Incentives.AchievementFactory do 2 | @moduledoc """ 3 | Factory for the Achievement entity 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | alias Cadet.Incentives.{Achievement, AchievementPrerequisite} 9 | alias Ecto.UUID 10 | 11 | def achievement_factory do 12 | %Achievement{ 13 | uuid: UUID.generate(), 14 | course: insert(:course), 15 | title: Faker.Food.dish(), 16 | is_task: false, 17 | position: 0, 18 | xp: 0, 19 | is_variable_xp: false, 20 | description: Faker.Lorem.Shakespeare.En.king_richard_iii(), 21 | completion_text: Faker.Lorem.Shakespeare.En.romeo_and_juliet(), 22 | canvas_url: 23 | "https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/achievement/canvas/annotated-canvas.png" 24 | } 25 | end 26 | 27 | def achievement_prerequisite_factory do 28 | %AchievementPrerequisite{} 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /deployment/terraform/lambda.tf: -------------------------------------------------------------------------------- 1 | resource "aws_iam_role" "grader" { 2 | name = "${var.env}-cadet-grader" 3 | 4 | assume_role_policy = < cast(params, @required_fields ++ @optional_fields) 32 | |> add_belongs_to_id_from_model([:user], params) 33 | |> validate_required(@required_fields) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/cadet/auth/providers/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Auth.Providers.ConfigTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Cadet.Auth.Providers.Config 5 | 6 | @code "code" 7 | @token "token" 8 | @name "Test Name" 9 | @username "testusername" 10 | @role :student 11 | 12 | @config [ 13 | %{ 14 | token: @token, 15 | code: @code, 16 | name: @name, 17 | username: @username, 18 | role: @role 19 | } 20 | ] 21 | 22 | describe "authorise" do 23 | test "successfully" do 24 | assert {:ok, %{token: @token, username: @username}} = 25 | Config.authorise(@config, %{code: @code}) 26 | end 27 | 28 | test "with wrong code" do 29 | assert {:error, _, _} = Config.authorise(@config, %{code: @code <> "dflajhdfs"}) 30 | end 31 | end 32 | 33 | describe "get name" do 34 | test "successfully" do 35 | assert {:ok, @name} = Config.get_name(@config, @token) 36 | end 37 | 38 | test "with wrong token" do 39 | assert {:error, _, _} = Config.get_name(@config, @token <> "dflajhdfs") 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/factories/accounts/user_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Accounts.UserFactory do 2 | @moduledoc """ 3 | Factory(ies) for Cadet.Accounts.User entity 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | # alias Cadet.Accounts.{Role, User} 9 | alias Cadet.Accounts.User 10 | 11 | def user_factory do 12 | %User{ 13 | provider: "test", 14 | name: Faker.Person.En.name(), 15 | username: 16 | sequence( 17 | :nusnet_id, 18 | &"E#{&1 |> Integer.to_string() |> String.pad_leading(7, "0")}" 19 | ), 20 | latest_viewed_course: build(:course), 21 | super_admin: false 22 | } 23 | end 24 | 25 | def student_factory do 26 | %User{ 27 | provider: "test", 28 | name: Faker.Person.En.name(), 29 | username: 30 | sequence( 31 | :nusnet_id, 32 | &"E#{&1 |> Integer.to_string() |> String.pad_leading(7, "0")}" 33 | ), 34 | latest_viewed_course: build(:course) 35 | } 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/fixtures/vcr_cassettes/aws/model_upload_asset#1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "request": { 4 | "body": "", 5 | "headers": { 6 | "Authorization": "***", 7 | "host": "s3.ap-southeast-1.amazonaws.com", 8 | "x-amz-date": "20210802T105952Z", 9 | "content-length": "0", 10 | "x-amz-content-sha256": "***" 11 | }, 12 | "method": "head", 13 | "options": { 14 | "with_body": "true", 15 | "recv_timeout": 660000 16 | }, 17 | "request_body": "", 18 | "url": "https://s3.ap-southeast-1.amazonaws.com/test-sa-assets/courses-test/1/testFolder/test2.png" 19 | }, 20 | "response": { 21 | "binary": false, 22 | "body": null, 23 | "headers": { 24 | "Date": "Mon, 02 Aug 2021 10:59:52 GMT", 25 | "Last-Modified": "Mon, 02 Aug 2021 10:25:15 GMT", 26 | "ETag": "\"af2ab457d8b118efa176bc12cff4895f\"", 27 | "Accept-Ranges": "bytes", 28 | "Content-Type": "image/png", 29 | "Server": "AmazonS3", 30 | "Content-Length": "2996" 31 | }, 32 | "status_code": 200, 33 | "type": "ok" 34 | } 35 | } 36 | ] 37 | -------------------------------------------------------------------------------- /test/fixtures/custom_cassettes/aws/devices_get_cert#3.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "request": { 4 | "body": "", 5 | "headers": { 6 | "Authorization": "***", 7 | "host": "iot.ap-southeast-1.amazonaws.com", 8 | "x-amz-date": "20200708T202703Z" 9 | }, 10 | "method": "post", 11 | "options": { 12 | "with_body": "true", 13 | "recv_timeout": 660000 14 | }, 15 | "request_body": "", 16 | "url": "https://iot.ap-southeast-1.amazonaws.com/keys-and-certificate?setAsActive=true" 17 | }, 18 | "response": { 19 | "binary": false, 20 | "body": "{}", 21 | "headers": { 22 | "Date": "Wed, 08 Jul 2020 20:27:03 GMT", 23 | "Content-Type": "application/json", 24 | "Content-Length": "2", 25 | "Connection": "keep-alive", 26 | "x-amzn-RequestId": "d8974503-619e-4563-9b71-f676624c3d6a", 27 | "Access-Control-Allow-Origin": "*", 28 | "x-amz-apigw-id": "PXvTrGPiSQ0FXsA=", 29 | "X-Amzn-Trace-Id": "Root=1-5f062c17-eecca312f150dff4c950b0f0" 30 | }, 31 | "status_code": 500, 32 | "type": "ok" 33 | } 34 | } 35 | ] 36 | -------------------------------------------------------------------------------- /test/fixtures/custom_cassettes/aws/devices_get_ws_endpoint#2.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "request": { 4 | "body": "", 5 | "headers": { 6 | "Authorization": "***", 7 | "host": "iot.ap-southeast-1.amazonaws.com", 8 | "x-amz-date": "20200712T173721Z" 9 | }, 10 | "method": "get", 11 | "options": { 12 | "with_body": "true", 13 | "recv_timeout": 660000 14 | }, 15 | "request_body": "", 16 | "url": "https://iot.ap-southeast-1.amazonaws.com/endpoint?endpointType=iot:Data-ATS" 17 | }, 18 | "response": { 19 | "binary": false, 20 | "body": "{}\n", 21 | "headers": { 22 | "Date": "Sun, 12 Jul 2020 17:37:21 GMT", 23 | "Content-Type": "application/json", 24 | "Content-Length": "3", 25 | "Connection": "keep-alive", 26 | "x-amzn-RequestId": "8488483c-cffc-40c8-872a-4381f0c02f41", 27 | "Access-Control-Allow-Origin": "*", 28 | "x-amz-apigw-id": "PkiMwHfSSQ0Fkgg=", 29 | "X-Amzn-Trace-Id": "Root=1-5f0b4a51-5bbf91df139713fc727aa8ca" 30 | }, 31 | "status_code": 500, 32 | "type": "ok" 33 | } 34 | } 35 | ] 36 | -------------------------------------------------------------------------------- /test/cadet/helpers/display_helper_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.DisplayHelperTest.TestObject do 2 | use Ecto.Schema 3 | 4 | alias Ecto.Changeset 5 | 6 | schema "objects" do 7 | has_many(:subobjects, __MODULE__, on_replace: :delete) 8 | end 9 | 10 | def changeset(object, params) do 11 | object 12 | |> Changeset.cast(params, [:id]) 13 | |> Changeset.cast_assoc(:subobjects) 14 | end 15 | end 16 | 17 | defmodule Cadet.DisplayHelperTest do 18 | use ExUnit.Case 19 | 20 | alias Cadet.DisplayHelperTest.TestObject 21 | 22 | import Cadet.DisplayHelper 23 | 24 | describe "full_error_messages" do 25 | test "passes non-changeset through" do 26 | assert full_error_messages("aaa") == "aaa" 27 | end 28 | 29 | test "formats simple errors" do 30 | changeset = TestObject.changeset(%TestObject{}, %{id: "invalid"}) 31 | 32 | assert full_error_messages(changeset) =~ "id is invalid" 33 | end 34 | 35 | test "formats nested errors" do 36 | changeset = TestObject.changeset(%TestObject{}, %{subobjects: [%{id: "invalid"}]}) 37 | 38 | assert full_error_messages(changeset) == "subobjects {id is invalid}" 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/cadet/accounts/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Accounts.User do 2 | @moduledoc """ 3 | The User entity represents a user. 4 | It stores basic information such as name 5 | """ 6 | use Cadet, :model 7 | 8 | alias Cadet.Accounts.CourseRegistration 9 | alias Cadet.Courses.Course 10 | 11 | @type t :: %__MODULE__{ 12 | name: String.t(), 13 | username: String.t(), 14 | provider: String.t(), 15 | latest_viewed_course: Course.t() 16 | } 17 | 18 | schema "users" do 19 | field(:name, :string) 20 | field(:username, :string) 21 | field(:provider, :string) 22 | field(:super_admin, :boolean) 23 | field(:email, :string) 24 | 25 | belongs_to(:latest_viewed_course, Course) 26 | has_many(:courses, CourseRegistration) 27 | 28 | timestamps() 29 | end 30 | 31 | @required_fields ~w(username provider)a 32 | @optional_fields ~w(name latest_viewed_course_id super_admin)a 33 | 34 | def changeset(user, params \\ %{}) do 35 | user 36 | |> cast(params, @required_fields ++ @optional_fields) 37 | |> validate_required(@required_fields) 38 | |> foreign_key_constraint(:latest_viewed_course_id) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180119002258_create_assessments.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.CreateMissions do 2 | use Ecto.Migration 3 | 4 | def up do 5 | Ecto.Migration.execute( 6 | "CREATE TYPE assessment_type AS ENUM ('path', 'mission', 'sidequest', 'contest')" 7 | ) 8 | 9 | create table(:assessments) do 10 | add(:title, :string, null: false) 11 | add(:summary_short, :text) 12 | add(:summary_long, :text) 13 | add(:type, :string, null: false) 14 | add(:open_at, :timestamp, null: false) 15 | add(:close_at, :timestamp, null: false) 16 | add(:cover_picture, :string) 17 | add(:number, :string) 18 | add(:story, :string) 19 | add(:reading, :string) 20 | add(:mission_pdf, :string) 21 | add(:is_published, :boolean, null: false) 22 | timestamps() 23 | end 24 | 25 | create(index(:assessments, [:open_at])) 26 | create(index(:assessments, [:close_at])) 27 | create(index(:assessments, [:is_published])) 28 | create(index(:assessments, [:type])) 29 | end 30 | 31 | def down do 32 | drop(table(:assessments)) 33 | 34 | Ecto.Migration.execute("DROP TYPE assessment_type") 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/cadet/assessments/question_types/programming_question.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.QuestionTypes.ProgrammingQuestion do 2 | @moduledoc """ 3 | The ProgrammingQuestion entity represents a Programming question. 4 | """ 5 | use Cadet, :model 6 | 7 | alias Cadet.Assessments.QuestionTypes.Testcase 8 | 9 | @primary_key false 10 | embedded_schema do 11 | field(:content, :string) 12 | field(:prepend, :string, default: "") 13 | field(:template, :string) 14 | field(:postpend, :string, default: "") 15 | field(:solution, :string) 16 | field(:llm_prompt, :string) 17 | embeds_many(:public, Testcase) 18 | embeds_many(:opaque, Testcase) 19 | embeds_many(:secret, Testcase) 20 | end 21 | 22 | @required_fields ~w(content template)a 23 | @optional_fields ~w(solution prepend postpend llm_prompt)a 24 | 25 | def changeset(question, params \\ %{}) do 26 | question 27 | |> cast(params, @required_fields ++ @optional_fields) 28 | |> cast_embed(:public, with: &Testcase.changeset/2) 29 | |> cast_embed(:opaque, with: &Testcase.changeset/2) 30 | |> cast_embed(:secret, with: &Testcase.changeset/2) 31 | |> validate_required(@required_fields) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/cadet/assessments/question_types/voting_question_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.QuestionTypes.VotingQuestionTest do 2 | alias Cadet.Assessments.QuestionTypes.VotingQuestion 3 | 4 | use Cadet.ChangesetCase, entity: VotingQuestion 5 | 6 | describe "Changesets" do 7 | test "valid changeset" do 8 | assert_changeset( 9 | %{ 10 | content: "content", 11 | contest_number: "C4", 12 | reveal_hours: 48, 13 | token_divider: 50 14 | }, 15 | :valid 16 | ) 17 | end 18 | 19 | test "invalid changesets" do 20 | assert_changeset( 21 | %{ 22 | content: 1 23 | }, 24 | :invalid 25 | ) 26 | 27 | assert_changeset( 28 | %{ 29 | content: "content", 30 | contest_number: "C3", 31 | reveal_hours: 48, 32 | token_divider: -1 33 | }, 34 | :invalid 35 | ) 36 | 37 | assert_changeset( 38 | %{ 39 | content: "content", 40 | contest_number: "C6", 41 | reveal_hours: 48, 42 | token_divider: 0 43 | }, 44 | :invalid 45 | ) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/cadet/notifications/notification_config.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Notifications.NotificationConfig do 2 | @moduledoc """ 3 | NotificationConfig entity to store course/assessment configuration for a specific notification type. 4 | """ 5 | use Ecto.Schema 6 | import Ecto.Changeset 7 | alias Cadet.Courses.{Course, AssessmentConfig} 8 | alias Cadet.Notifications.NotificationType 9 | 10 | schema "notification_configs" do 11 | field(:is_enabled, :boolean, default: false) 12 | 13 | belongs_to(:notification_type, NotificationType) 14 | belongs_to(:course, Course) 15 | belongs_to(:assessment_config, AssessmentConfig) 16 | 17 | timestamps() 18 | end 19 | 20 | @doc false 21 | def changeset(notification_config, attrs) do 22 | notification_config 23 | |> cast(attrs, [:is_enabled, :notification_type_id, :course_id]) 24 | |> validate_required([:notification_type_id, :course_id]) 25 | |> prevent_nil_is_enabled() 26 | end 27 | 28 | defp prevent_nil_is_enabled(changeset = %{changes: %{is_enabled: is_enabled}}) 29 | when is_nil(is_enabled), 30 | do: add_error(changeset, :full_name, "empty") 31 | 32 | defp prevent_nil_is_enabled(changeset), 33 | do: changeset 34 | end 35 | -------------------------------------------------------------------------------- /test/cadet/assessments/question_types/programming_question_testcases_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.QuestionTypes.ProgrammingQuestionTestcaseTest do 2 | alias Cadet.Assessments.QuestionTypes.Testcase 3 | 4 | use Cadet.ChangesetCase, entity: Testcase 5 | 6 | describe "Changesets" do 7 | test "valid changeset" do 8 | assert_changeset( 9 | %{ 10 | score: 1, 11 | answer: "asd", 12 | program: "asd" 13 | }, 14 | :valid 15 | ) 16 | end 17 | 18 | test "invalid changesets" do 19 | assert_changeset( 20 | %{ 21 | score: -1, 22 | answer: "asd", 23 | program: "asd" 24 | }, 25 | :invalid 26 | ) 27 | 28 | assert_changeset( 29 | %{ 30 | score: 1, 31 | answer: "asd" 32 | }, 33 | :invalid 34 | ) 35 | 36 | assert_changeset( 37 | %{ 38 | answer: "asd", 39 | program: "asd" 40 | }, 41 | :invalid 42 | ) 43 | 44 | assert_changeset( 45 | %{ 46 | score: 1, 47 | program: "asd" 48 | }, 49 | :invalid 50 | ) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/fixtures/custom_cassettes/aws/devices_get_endpoint_address#1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "request": { 4 | "body": "", 5 | "headers": { 6 | "Authorization": "***", 7 | "host": "iot.ap-southeast-1.amazonaws.com", 8 | "x-amz-date": "20200712T173721Z" 9 | }, 10 | "method": "get", 11 | "options": { 12 | "with_body": "true", 13 | "recv_timeout": 660000 14 | }, 15 | "request_body": "", 16 | "url": "https://iot.ap-southeast-1.amazonaws.com/endpoint?endpointType=iot:Data-ATS" 17 | }, 18 | "response": { 19 | "binary": false, 20 | "body": "{\"endpointAddress\":\"test-ats.iot.ap-southeast-1.amazonaws.com\"}\n", 21 | "headers": { 22 | "Date": "Sun, 12 Jul 2020 17:37:21 GMT", 23 | "Content-Type": "application/json", 24 | "Content-Length": "74", 25 | "Connection": "keep-alive", 26 | "x-amzn-RequestId": "8488483c-cffc-40c8-872a-4381f0c02f41", 27 | "Access-Control-Allow-Origin": "*", 28 | "x-amz-apigw-id": "PkiMwHfSSQ0Fkgg=", 29 | "X-Amzn-Trace-Id": "Root=1-5f0b4a51-5bbf91df139713fc727aa8ca" 30 | }, 31 | "status_code": 200, 32 | "type": "ok" 33 | } 34 | } 35 | ] 36 | -------------------------------------------------------------------------------- /lib/cadet/stories/story.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Stories.Story do 2 | @moduledoc """ 3 | The Story entity stores metadata of a story 4 | """ 5 | use Cadet, :model 6 | 7 | alias Cadet.Courses.Course 8 | 9 | schema "stories" do 10 | field(:open_at, :utc_datetime_usec) 11 | field(:close_at, :utc_datetime_usec) 12 | field(:is_published, :boolean, default: false) 13 | field(:title, :string) 14 | field(:image_url, :string) 15 | field(:filenames, {:array, :string}) 16 | 17 | belongs_to(:course, Course) 18 | 19 | timestamps() 20 | end 21 | 22 | @required_fields ~w(open_at close_at title filenames course_id)a 23 | @optional_fields ~w(is_published image_url)a 24 | 25 | def changeset(story, attrs \\ %{}) do 26 | story 27 | |> cast(attrs, @required_fields ++ @optional_fields) 28 | |> validate_required(@required_fields) 29 | |> validate_open_close_date 30 | end 31 | 32 | defp validate_open_close_date(changeset) do 33 | validate_change(changeset, :open_at, fn :open_at, open_at -> 34 | if Timex.before?(open_at, get_field(changeset, :close_at)) do 35 | [] 36 | else 37 | [open_at: "Open date must be before close date"] 38 | end 39 | end) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/cadet/incentives/achievement_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Incentives.AchievementTest do 2 | alias Cadet.Incentives.Achievement 3 | 4 | use Cadet.ChangesetCase, entity: Achievement 5 | 6 | describe "Changesets" do 7 | test "valid changesets" do 8 | course = insert(:course) 9 | 10 | assert_changeset( 11 | %{ 12 | uuid: "d1fdae3f-2775-4503-ab6b-e043149d4a15", 13 | title: "Hello World", 14 | course_id: course.id, 15 | open_at: DateTime.from_naive!(~N[2016-05-24 13:26:08.003], "Etc/UTC"), 16 | close_at: DateTime.from_naive!(~N[2016-05-27 13:26:08.003], "Etc/UTC"), 17 | is_task: false, 18 | position: 0, 19 | xp: 0, 20 | is_variable_xp: false 21 | }, 22 | :valid 23 | ) 24 | end 25 | 26 | test "invalid changesets" do 27 | assert_changeset( 28 | %{ 29 | uuid: "d1fdae3f-2775-4503-ab6b-e043149d4a15", 30 | title: "Hello World", 31 | open_at: DateTime.from_naive!(~N[2016-05-27 13:26:08.003], "Etc/UTC"), 32 | close_at: DateTime.from_naive!(~N[2016-05-24 13:26:08.003], "Etc/UTC") 33 | }, 34 | :invalid 35 | ) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/fixtures/vcr_cassettes/aws/model_list_assets#2.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "request": { 4 | "body": "", 5 | "headers": { 6 | "Authorization": "***", 7 | "host": "s3.ap-southeast-1.amazonaws.com", 8 | "x-amz-date": "20210802T105950Z", 9 | "x-amz-content-sha256": "***" 10 | }, 11 | "method": "get", 12 | "options": { 13 | "with_body": "true", 14 | "recv_timeout": 660000 15 | }, 16 | "request_body": "", 17 | "url": "https://s3.ap-southeast-1.amazonaws.com/test-sa-assets/" 18 | }, 19 | "response": { 20 | "binary": false, 21 | "body": "\ntest-sa-assetscourses-test/2/testFolder/1000false", 22 | "headers": { 23 | "Date": "Mon, 02 Aug 2021 10:59:49 GMT", 24 | "x-amz-bucket-region": "ap-southeast-1", 25 | "Content-Type": "application/xml", 26 | "Transfer-Encoding": "chunked", 27 | "Server": "AmazonS3" 28 | }, 29 | "status_code": 200, 30 | "type": "ok" 31 | } 32 | } 33 | ] 34 | -------------------------------------------------------------------------------- /lib/cadet_web/views/courses_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.CoursesView do 2 | use CadetWeb, :view 3 | 4 | def render("config.json", %{config: config}) do 5 | %{ 6 | config: 7 | transform_map_for_view(config, %{ 8 | courseName: :course_name, 9 | courseShortName: :course_short_name, 10 | viewable: :viewable, 11 | enableGame: :enable_game, 12 | enableAchievements: :enable_achievements, 13 | enableOverallLeaderboard: :enable_overall_leaderboard, 14 | enableContestLeaderboard: :enable_contest_leaderboard, 15 | topLeaderboardDisplay: :top_leaderboard_display, 16 | topContestLeaderboardDisplay: :top_contest_leaderboard_display, 17 | enableSourcecast: :enable_sourcecast, 18 | enableStories: :enable_stories, 19 | enableLlmGrading: :enable_llm_grading, 20 | llmModel: :llm_model, 21 | llmApiUrl: :llm_api_url, 22 | llmCourseLevelPrompt: :llm_course_level_prompt, 23 | sourceChapter: :source_chapter, 24 | sourceVariant: :source_variant, 25 | moduleHelpText: :module_help_text, 26 | assessmentTypes: :assessment_configs, 27 | assetsPrefix: :assets_prefix 28 | }) 29 | } 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/cadet/notifications/notification_preference.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Notifications.NotificationPreference do 2 | @moduledoc """ 3 | NotificationPreference entity that stores user preferences for a specific notification for a specific course/assessment. 4 | """ 5 | use Ecto.Schema 6 | import Ecto.Changeset 7 | alias Cadet.Notifications.{NotificationConfig, TimeOption} 8 | alias Cadet.Accounts.CourseRegistration 9 | 10 | schema "notification_preferences" do 11 | field(:is_enabled, :boolean, default: false) 12 | 13 | belongs_to(:notification_config, NotificationConfig) 14 | belongs_to(:time_option, TimeOption) 15 | belongs_to(:course_reg, CourseRegistration) 16 | 17 | timestamps() 18 | end 19 | 20 | @doc false 21 | def changeset(notification_preference, attrs) do 22 | notification_preference 23 | |> cast(attrs, [:is_enabled, :notification_config_id, :course_reg_id]) 24 | |> validate_required([:notification_config_id, :course_reg_id]) 25 | |> prevent_nil_is_enabled() 26 | end 27 | 28 | defp prevent_nil_is_enabled(changeset = %{changes: %{is_enabled: is_enabled}}) 29 | when is_nil(is_enabled), 30 | do: add_error(changeset, :full_name, "empty") 31 | 32 | defp prevent_nil_is_enabled(changeset), 33 | do: changeset 34 | end 35 | -------------------------------------------------------------------------------- /lib/cadet/assessments/question_types/mcq_question.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Assessments.QuestionTypes.MCQQuestion do 2 | @moduledoc """ 3 | The Assessments.QuestionTypes.MCQQuestion entity represents an MCQ Question. 4 | It comprises of content and choices. 5 | """ 6 | use Cadet, :model 7 | 8 | alias Cadet.Assessments.QuestionTypes.MCQChoice 9 | 10 | @primary_key false 11 | embedded_schema do 12 | field(:content, :string) 13 | embeds_many(:choices, MCQChoice) 14 | end 15 | 16 | @required_fields ~w(content)a 17 | 18 | def changeset(question, params \\ %{}) do 19 | question 20 | |> cast(params, @required_fields) 21 | |> cast_embed(:choices, with: &MCQChoice.changeset/2, required: true) 22 | |> validate_one_correct_answer 23 | |> validate_required(@required_fields ++ ~w(choices)a) 24 | end 25 | 26 | defp validate_one_correct_answer(changeset) do 27 | changeset 28 | |> validate_change(:choices, fn :choices, choices -> 29 | no_of_correct_choices = 30 | choices 31 | |> Enum.reduce(0, &if(&1.changes && &1.changes[:is_correct], do: &2 + 1, else: &2)) 32 | 33 | if no_of_correct_choices == 1 do 34 | [] 35 | else 36 | [choices: "Number of correct answer must be one."] 37 | end 38 | end) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/cadet_web/admin_views/admin_goals_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.AdminGoalsView do 2 | use CadetWeb, :view 3 | use Timex 4 | 5 | def render("index.json", %{goals: goals}) do 6 | render_many(goals, CadetWeb.AdminGoalsView, "goal.json", as: :goal) 7 | end 8 | 9 | def render("index_goals_with_progress.json", %{goals: goals}) do 10 | render_many(goals, CadetWeb.AdminGoalsView, "goal_with_progress.json", as: :goal) 11 | end 12 | 13 | def render("goal.json", %{goal: goal}) do 14 | transform_map_for_view(goal, %{ 15 | uuid: :uuid, 16 | text: :text, 17 | targetCount: :target_count, 18 | type: :type, 19 | meta: :meta 20 | }) 21 | end 22 | 23 | def render("goal_with_progress.json", %{goal: goal}) do 24 | transform_map_for_view(goal, %{ 25 | uuid: :uuid, 26 | text: :text, 27 | count: fn 28 | %{progress: [%{count: count}]} -> count 29 | _ -> 0 30 | end, 31 | completed: fn 32 | %{progress: [%{completed: completed}]} -> completed 33 | _ -> false 34 | end, 35 | targetCount: :target_count, 36 | type: :type, 37 | meta: :meta, 38 | achievementUuids: 39 | &Enum.map(&1.achievements, fn achievement -> achievement.achievement_uuid end) 40 | }) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/factories/factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Factory do 2 | @moduledoc """ 3 | Factory for testing 4 | """ 5 | use ExMachina.Ecto, repo: Cadet.Repo 6 | 7 | use Cadet.Accounts.{ 8 | NotificationFactory, 9 | UserFactory, 10 | CourseRegistrationFactory, 11 | TeamFactory, 12 | TeamMemberFactory 13 | } 14 | 15 | use Cadet.Assessments.{ 16 | AnswerFactory, 17 | AssessmentFactory, 18 | LibraryFactory, 19 | QuestionFactory, 20 | SubmissionFactory, 21 | SubmissionVotesFactory 22 | } 23 | 24 | use Cadet.Chatbot.{ConversationFactory} 25 | 26 | use Cadet.Stories.{StoryFactory} 27 | 28 | use Cadet.Incentives.{ 29 | AchievementFactory, 30 | GoalFactory 31 | } 32 | 33 | use Cadet.Courses.{ 34 | AssessmentConfigFactory, 35 | CourseFactory, 36 | GroupFactory, 37 | SourcecastFactory 38 | } 39 | 40 | use Cadet.Notifications.{ 41 | NotificationTypeFactory, 42 | NotificationConfigFactory, 43 | NotificationPreferenceFactory, 44 | TimeOptionFactory 45 | } 46 | 47 | use Cadet.Devices.DeviceFactory 48 | 49 | def upload_factory do 50 | %Plug.Upload{ 51 | content_type: "text/plain", 52 | filename: sequence(:upload, &"upload#{&1}.txt"), 53 | path: "test/fixtures/upload.txt" 54 | } 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/cadet/auth/guardian_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Auth.GuardianTest do 2 | use Cadet.DataCase 3 | 4 | import Cadet.ModelHelper 5 | alias Cadet.Auth.Guardian 6 | 7 | test "token subject is user id" do 8 | user = insert(:user) 9 | 10 | assert Guardian.subject_for_token(user, nil) == 11 | {:ok, 12 | URI.encode_query(%{ 13 | id: user.id, 14 | username: user.username, 15 | provider: user.provider 16 | })} 17 | end 18 | 19 | test "get user from claims" do 20 | user = insert(:user) 21 | 22 | good_claims = %{ 23 | # Username and provider are only used for microservices 24 | # The main backend only checks the user ID 25 | "sub" => URI.encode_query(%{id: user.id}) 26 | } 27 | 28 | bad_claims_user_not_found = %{ 29 | "sub" => URI.encode_query(%{id: 2000}) 30 | } 31 | 32 | bad_claims_bad_sub = %{ 33 | "sub" => "bad" 34 | } 35 | 36 | assert Guardian.resource_from_claims(good_claims) == 37 | {:ok, remove_preload(user, :latest_viewed_course)} 38 | 39 | assert Guardian.resource_from_claims(bad_claims_user_not_found) == {:error, :not_found} 40 | assert Guardian.resource_from_claims(bad_claims_bad_sub) == {:error, :bad_request} 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/cadet/email.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Email do 2 | @moduledoc """ 3 | Contains methods for sending email notifications. 4 | """ 5 | use Bamboo.Phoenix, view: CadetWeb.EmailView 6 | import Bamboo.Email 7 | 8 | def avenger_backlog_email(template_file_name, avenger, ungraded_submissions) do 9 | if is_nil(avenger.email) do 10 | nil 11 | else 12 | base_email() 13 | |> to(avenger.email) 14 | |> assign(:avenger_name, avenger.name) 15 | |> assign(:submissions, ungraded_submissions) 16 | |> subject("Backlog for #{avenger.name}") 17 | |> render("#{template_file_name}.html") 18 | end 19 | end 20 | 21 | def assessment_submission_email(template_file_name, avenger, student, submission) do 22 | if is_nil(avenger.email) do 23 | nil 24 | else 25 | base_email() 26 | |> to(avenger.email) 27 | |> assign(:avenger_name, avenger.name) 28 | |> assign(:student_name, student.name) 29 | |> assign(:assessment_title, submission.assessment.title) 30 | |> subject("New submission for #{submission.assessment.title}") 31 | |> render("#{template_file_name}.html") 32 | end 33 | end 34 | 35 | defp base_email do 36 | new_email() 37 | |> from("noreply@sourceacademy.org") 38 | |> put_html_layout({CadetWeb.LayoutView, "email.html"}) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230215091253_add_notification_configs_courses_trigger.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddNotificationConfigsCoursesTrigger do 2 | use Ecto.Migration 3 | 4 | def up do 5 | execute(""" 6 | CREATE OR REPLACE FUNCTION populate_noti_configs_from_notification_types_for_course() RETURNS trigger AS $$ 7 | DECLARE 8 | ntype Record; 9 | BEGIN 10 | FOR ntype IN (SELECT * FROM notification_types WHERE is_autopopulated = TRUE) LOOP 11 | INSERT INTO notification_configs (notification_type_id, course_id, assessment_config_id, inserted_at, updated_at) 12 | VALUES (ntype.id, NEW.id, NULL, current_timestamp, current_timestamp); 13 | END LOOP; 14 | RETURN NEW; 15 | END; 16 | $$ LANGUAGE plpgsql; 17 | """) 18 | 19 | execute(""" 20 | CREATE TRIGGER populate_notification_configs_on_new_course 21 | AFTER INSERT ON courses 22 | FOR EACH ROW EXECUTE PROCEDURE populate_noti_configs_from_notification_types_for_course(); 23 | """) 24 | end 25 | 26 | def down do 27 | execute(""" 28 | DROP TRIGGER IF EXISTS populate_notification_configs_on_new_course ON courses; 29 | """) 30 | 31 | execute(""" 32 | DROP FUNCTION IF EXISTS populate_noti_configs_from_notification_types_for_course; 33 | """) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/cadet/notifications/notification_type.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Notifications.NotificationType do 2 | @moduledoc """ 3 | NotificationType entity that represents a unique type of notification that the system supports. 4 | There should only be a single entry of this notification regardless of number of courses/assessments using sending this notification. 5 | Course/assessment specific configuration should exist as NotificationConfig instead. 6 | """ 7 | use Ecto.Schema 8 | import Ecto.Changeset 9 | 10 | schema "notification_types" do 11 | field(:is_autopopulated, :boolean, default: false) 12 | field(:is_enabled, :boolean, default: false) 13 | field(:name, :string) 14 | field(:template_file_name, :string) 15 | 16 | timestamps() 17 | end 18 | 19 | @doc false 20 | def changeset(notification_type, attrs) do 21 | notification_type 22 | |> cast(attrs, [:name, :template_file_name, :is_enabled, :is_autopopulated]) 23 | |> validate_required([:name, :template_file_name, :is_autopopulated]) 24 | |> unique_constraint(:name) 25 | |> prevent_nil_is_enabled() 26 | end 27 | 28 | defp prevent_nil_is_enabled(changeset = %{changes: %{is_enabled: is_enabled}}) 29 | when is_nil(is_enabled), 30 | do: add_error(changeset, :full_name, "empty") 31 | 32 | defp prevent_nil_is_enabled(changeset), 33 | do: changeset 34 | end 35 | -------------------------------------------------------------------------------- /test/cadet_web/plug/rate_limiter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.Plugs.RateLimiterTest do 2 | use CadetWeb.ConnCase 3 | import Plug.Conn 4 | alias CadetWeb.Plugs.RateLimiter 5 | 6 | setup %{conn: conn} do 7 | ExRated.delete_bucket("user:1") 8 | # Mock user data,in application should be done by "assign_current_user" plug 9 | user = %{id: 1} 10 | conn = update_in(conn.assigns, &Map.put_new(&1, :current_user, user)) 11 | {:ok, conn: conn} 12 | end 13 | 14 | test "init" do 15 | assert RateLimiter.init(%{}) == %{} 16 | end 17 | 18 | test "rate limit not exceeded", %{conn: conn} do 19 | conn = RateLimiter.call(conn, %{}) 20 | 21 | assert conn.status != 429 22 | end 23 | 24 | test "rate limit exceeded", %{conn: conn} do 25 | # Simulate exceeding the rate limit 26 | for _ <- 1..RateLimiter.rate_limit() do 27 | conn = RateLimiter.call(conn, %{}) 28 | assert conn.status != 429 29 | end 30 | 31 | conn = RateLimiter.call(conn, %{}) 32 | assert conn.status == 429 33 | assert conn.resp_body == "Rate limit exceeded" 34 | end 35 | 36 | test "no user found in conn.assigns.current_user", %{conn: conn} do 37 | conn = put_in(conn.assigns.current_user, nil) 38 | 39 | conn = RateLimiter.call(conn, %{}) 40 | 41 | assert conn.status != 429 42 | assert conn.resp_body == nil 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/cadet/accounts/course_registration.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Accounts.CourseRegistration do 2 | @moduledoc """ 3 | The mapping table representing the registration of a user to a course. 4 | """ 5 | use Cadet, :model 6 | 7 | alias Cadet.Accounts.{Role, User} 8 | alias Cadet.Courses.{Course, Group} 9 | 10 | @type t :: %__MODULE__{ 11 | role: Role.t(), 12 | game_states: %{}, 13 | agreed_to_research: boolean(), 14 | group: Group.t() | nil, 15 | user: User.t() | nil, 16 | course: Course.t() | nil 17 | } 18 | 19 | schema "course_registrations" do 20 | field(:role, Role) 21 | field(:game_states, :map) 22 | field(:agreed_to_research, :boolean) 23 | 24 | belongs_to(:group, Group) 25 | belongs_to(:user, User) 26 | belongs_to(:course, Course) 27 | 28 | timestamps() 29 | end 30 | 31 | @required_fields ~w(user_id course_id role)a 32 | @optional_fields ~w(game_states group_id agreed_to_research)a 33 | 34 | def changeset(course_registration, params \\ %{}) do 35 | course_registration 36 | |> cast(params, @optional_fields ++ @required_fields) 37 | |> add_belongs_to_id_from_model([:user, :group, :course], params) 38 | |> validate_required(@required_fields) 39 | |> unique_constraint(:user_id, name: :course_registrations_user_id_course_id_index) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/cadet_web/admin_views/admin_teams_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.AdminTeamsView do 2 | @moduledoc """ 3 | View module for rendering admin teams data in JSON format. 4 | """ 5 | 6 | use CadetWeb, :view 7 | 8 | @doc """ 9 | Renders a list of team formation overviews in JSON format. 10 | 11 | ## Parameters 12 | 13 | * `teamFormationOverviews` - A list of team formation overviews to be rendered. 14 | 15 | """ 16 | def render("index.json", %{team_formation_overviews: team_formation_overviews}) do 17 | render_many(team_formation_overviews, CadetWeb.AdminTeamsView, "team_formation_overview.json", 18 | as: :team_formation_overview 19 | ) 20 | end 21 | 22 | @doc """ 23 | Renders a single team formation overview in JSON format. 24 | 25 | ## Parameters 26 | 27 | * `team_formation_overview` - The team formation overview to be rendered. 28 | 29 | """ 30 | def render("team_formation_overview.json", %{team_formation_overview: team_formation_overview}) do 31 | %{ 32 | teamId: team_formation_overview.teamId, 33 | assessmentId: team_formation_overview.assessmentId, 34 | assessmentName: team_formation_overview.assessmentName, 35 | assessmentType: team_formation_overview.assessmentType, 36 | studentIds: team_formation_overview.studentIds, 37 | studentNames: team_formation_overview.studentNames 38 | } 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/cadet/accounts/notification.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Accounts.Notification do 2 | @moduledoc """ 3 | The Notification entity represents a notification. 4 | It stores information pertaining to the type of notification and who in which course it belongs to. 5 | Each notification can have an assessment id or submission id, with optional question id. 6 | This will be used to pinpoint where the notification will be showed on the frontend. 7 | """ 8 | use Cadet, :model 9 | 10 | alias Cadet.Accounts.{NotificationType, Role, CourseRegistration} 11 | alias Cadet.Assessments.{Assessment, Submission} 12 | 13 | schema "notifications" do 14 | field(:type, NotificationType) 15 | field(:read, :boolean, default: false) 16 | field(:role, Role, virtual: true) 17 | 18 | belongs_to(:course_reg, CourseRegistration) 19 | belongs_to(:assessment, Assessment) 20 | belongs_to(:submission, Submission) 21 | 22 | timestamps() 23 | end 24 | 25 | @required_fields ~w(type read course_reg_id assessment_id)a 26 | @optional_fields ~w(submission_id)a 27 | 28 | def changeset(answer, params) do 29 | answer 30 | |> cast(params, @required_fields ++ @optional_fields) 31 | |> validate_required(@required_fields) 32 | |> foreign_key_constraint(:course_reg_id) 33 | |> foreign_key_constraint(:assessment_id) 34 | |> foreign_key_constraint(:submission_id) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230215091948_add_notification_configs_assessments_trigger.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.AddNotificationConfigsAssessmentsTrigger do 2 | use Ecto.Migration 3 | 4 | def up do 5 | execute(""" 6 | CREATE OR REPLACE FUNCTION populate_noti_configs_from_notification_types_for_assconf() RETURNS trigger AS $$ 7 | DECLARE 8 | ntype Record; 9 | BEGIN 10 | FOR ntype IN (SELECT * FROM notification_types WHERE is_autopopulated = FALSE) LOOP 11 | INSERT INTO notification_configs (notification_type_id, course_id, assessment_config_id, inserted_at, updated_at) 12 | VALUES (ntype.id, NEW.course_id, NEW.id, current_timestamp, current_timestamp); 13 | END LOOP; 14 | RETURN NEW; 15 | END; 16 | $$ LANGUAGE plpgsql; 17 | """) 18 | 19 | execute(""" 20 | CREATE TRIGGER populate_notification_configs_on_new_assessment_config 21 | AFTER INSERT ON assessment_configs 22 | FOR EACH ROW EXECUTE PROCEDURE populate_noti_configs_from_notification_types_for_assconf(); 23 | """) 24 | end 25 | 26 | def down do 27 | execute(""" 28 | DROP TRIGGER IF EXISTS populate_notification_configs_on_new_assessment_config ON assessment_configs; 29 | """) 30 | 31 | execute(""" 32 | DROP FUNCTION IF EXISTS populate_noti_configs_from_notification_types_for_assconf; 33 | """) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/cadet/auth/providers/config.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Auth.Providers.Config do 2 | @moduledoc """ 3 | Provides identity using configuration. 4 | 5 | The configuration should be a list of users in the following format: 6 | 7 | ``` 8 | [%{code: "code1", token: "token1", username: "Username", name: "Name", role: :student}] 9 | ``` 10 | 11 | This is mainly meant for test and development use. 12 | """ 13 | 14 | alias Cadet.Auth.Provider 15 | 16 | @behaviour Provider 17 | 18 | @spec authorise(any(), Provider.authorise_params()) :: 19 | {:ok, %{token: Provider.token(), username: String.t()}} 20 | | {:error, Provider.error(), String.t()} 21 | def authorise(config, %{ 22 | code: code 23 | }) do 24 | case Enum.find(config, nil, fn %{code: this_code} -> code == this_code end) do 25 | %{token: token, username: username} -> 26 | {:ok, %{token: token, username: username}} 27 | 28 | _ -> 29 | {:error, :invalid_credentials, "Invalid code"} 30 | end 31 | end 32 | 33 | @spec get_name(any(), Provider.token()) :: 34 | {:ok, String.t()} | {:error, Provider.error(), String.t()} 35 | def get_name(config, token) do 36 | case Enum.find(config, nil, fn %{token: this_token} -> token == this_token end) do 37 | %{name: name} -> {:ok, name} 38 | _ -> {:error, :invalid_credentials, "Invalid token"} 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/cadet/accounts/team_members_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Accounts.TeamMemberTest do 2 | use Cadet.DataCase, async: true 3 | 4 | alias Cadet.Accounts.TeamMember 5 | alias Cadet.Repo 6 | 7 | @valid_attrs %{student_id: 1, team_id: 1} 8 | 9 | describe "changeset/2" do 10 | test "creates a valid changeset with valid attributes" do 11 | team_member = %TeamMember{} 12 | changeset = TeamMember.changeset(team_member, @valid_attrs) 13 | assert changeset.valid? 14 | end 15 | 16 | test "returns an error when required fields are missing" do 17 | team_member = %TeamMember{} 18 | changeset = TeamMember.changeset(team_member, %{}) 19 | refute changeset.valid? 20 | assert {:error, _changeset} = Repo.insert(changeset) 21 | end 22 | 23 | test "returns an error when the team_id foreign key constraint is violated" do 24 | team_member = %TeamMember{} 25 | changeset = TeamMember.changeset(team_member, %{student_id: 1}) 26 | refute changeset.valid? 27 | assert {:error, _changeset} = Repo.insert(changeset) 28 | end 29 | 30 | test "returns an error when the student_id foreign key constraint is violated" do 31 | team_member = %TeamMember{} 32 | changeset = TeamMember.changeset(team_member, %{team_id: 1}) 33 | refute changeset.valid? 34 | assert {:error, _changeset} = Repo.insert(changeset) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/cadet_web/helpers/controller_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.ControllerHelper do 2 | @moduledoc """ 3 | Contains helper functions for controllers. 4 | """ 5 | 6 | alias Plug.Conn 7 | 8 | alias PhoenixSwagger.Schema 9 | 10 | @doc """ 11 | Sends a response based on a standard result. 12 | """ 13 | @spec handle_standard_result( 14 | :ok 15 | | {:error, {atom(), String.t()}} 16 | | {:ok, any}, 17 | Plug.Conn.t(), 18 | String.t() | nil 19 | ) :: Plug.Conn.t() 20 | def handle_standard_result(result, conn, success_response \\ nil) 21 | 22 | def handle_standard_result({:ok, _}, conn, success_response), 23 | do: handle_standard_result(:ok, conn, success_response) 24 | 25 | def handle_standard_result(:ok, conn, nil), do: Conn.send_resp(conn, :no_content, "") 26 | 27 | def handle_standard_result(:ok, conn, ""), do: Conn.send_resp(conn, :no_content, "") 28 | 29 | def handle_standard_result(:ok, conn, success_response), 30 | do: Conn.send_resp(conn, :ok, success_response) 31 | 32 | def handle_standard_result({:error, {code, response}}, conn, _), 33 | do: Conn.send_resp(conn, code, response) 34 | 35 | def schema_array(type, extra \\ []) do 36 | %Schema{ 37 | type: :array, 38 | items: 39 | %Schema{ 40 | type: type 41 | } 42 | |> Map.merge(Enum.into(extra, %{})) 43 | } 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/support/test_entity_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.TestEntityHelper do 2 | @moduledoc """ 3 | Contains entity macros used in tests. 4 | """ 5 | 6 | defmacro achievement_literal(id) do 7 | Macro.escape(%{ 8 | title: "Achievement #{id}", 9 | is_task: false, 10 | xp: 0, 11 | is_variable_xp: false, 12 | position: id, 13 | card_tile_url: "http://hello#{id}", 14 | canvas_url: "http://bye#{id}", 15 | description: "Test #{id}", 16 | completion_text: "Done #{id}" 17 | }) 18 | end 19 | 20 | defmacro achievement_json_literal(id) do 21 | Macro.escape(%{ 22 | "position" => id, 23 | "title" => "Achievement #{id}", 24 | "xp" => 0, 25 | "isVariableXp" => false, 26 | "cardBackground" => "http://hello#{id}", 27 | "isTask" => false, 28 | "view" => %{ 29 | "coverImage" => "http://bye#{id}", 30 | "completionText" => "Done #{id}", 31 | "description" => "Test #{id}" 32 | } 33 | }) 34 | end 35 | 36 | defmacro goal_literal(id) do 37 | Macro.escape(%{ 38 | target_count: id, 39 | text: "Sample #{id}", 40 | type: "type_#{id}", 41 | meta: %{"id" => id} 42 | }) 43 | end 44 | 45 | defmacro goal_json_literal(id) do 46 | Macro.escape(%{ 47 | "targetCount" => id, 48 | "meta" => %{"id" => id}, 49 | "text" => "Sample #{id}", 50 | "type" => "type_#{id}" 51 | }) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/cadet_web/plug/rate_limiter.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.Plugs.RateLimiter do 2 | import Plug.Conn 3 | require Logger 4 | 5 | @moduledoc """ 6 | A plug that applies rate limiting to requests. Currently, it limits the number of requests a user can make in a 24-hour period to 500. 7 | """ 8 | @rate_limit 500 9 | # 24 hours in milliseconds 10 | @period 86_400_000 11 | 12 | def rate_limit, do: @rate_limit 13 | 14 | def init(default), do: default 15 | 16 | # This must be put after the AssignCurrentUser plug 17 | def call(conn, _opts) do 18 | current_user = conn.assigns.current_user 19 | 20 | case current_user do 21 | nil -> 22 | Logger.error( 23 | "No user found in conn.assigns.current_user, please check the AssignCurrentUser plug" 24 | ) 25 | 26 | conn 27 | 28 | %{} = user -> 29 | user_id = user.id 30 | key = "user:#{user_id}" 31 | 32 | case ExRated.check_rate(key, @period, @rate_limit) do 33 | {:ok, count} -> 34 | Logger.info("Received request from user #{user_id} with count #{count}") 35 | conn 36 | 37 | {:error, limit} -> 38 | Logger.error("Rate limit of #{limit} exceeded for user #{user_id}") 39 | 40 | conn 41 | |> put_status(:too_many_requests) 42 | |> send_resp(:too_many_requests, "Rate limit exceeded") 43 | |> halt() 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/cadet/chatbot/prompt_builder.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Chatbot.PromptBuilder do 2 | @moduledoc """ 3 | The PromptBuilder module is responsible for building the prompt for the chatbot. 4 | """ 5 | 6 | alias Cadet.Chatbot.SicpNotes 7 | 8 | @prompt_prefix """ 9 | You are a competent tutor, assisting a student who is learning computer science following the textbook "Structure and Interpretation of Computer Programs, JavaScript edition". The student request is about a paragraph of the book. The request may be a follow-up request to a request that was posed to you previously. 10 | What follows are: 11 | (1) the summary of section (2) the full paragraph. Please answer the student request, 12 | not the requests of the history. If the student request is not related to the book, ask them to ask questions that are related to the book. Do not say that I provide you text. 13 | 14 | """ 15 | 16 | @query_prefix "\n(2) Here is the paragraph:\n" 17 | 18 | def build_prompt(section, context) do 19 | section_summary = SicpNotes.get_summary(section) 20 | 21 | section_prefix = 22 | case section_summary do 23 | nil -> 24 | "\n(1) There is no section summary for this section. Please answer the question based on the following paragraph.\n" 25 | 26 | summary -> 27 | "\n(1) Here is the summary of this section:\n" <> summary 28 | end 29 | 30 | @prompt_prefix <> section_prefix <> @query_prefix <> context 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/fixtures/custom_cassettes/adfs/authorise#1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "request": { 4 | "body": "client_id=CLIENT_ID&code=CODE&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Flogin", 5 | "headers": { 6 | "Content-Type": "application/x-www-form-urlencoded" 7 | }, 8 | "method": "post", 9 | "options": [], 10 | "request_body": "", 11 | "url": "https://my-adfs/adfs/oauth2/token" 12 | }, 13 | "response": { 14 | "binary": false, 15 | "body": "{\"access_token\":\"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJTYW1BY2NvdW50TmFtZSI6ImUwMTIzNDU2IiwiZGlzcGxheU5hbWUiOiJZb3VyIE5hbWUiLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MjAwMDAwMDAwMH0.YkXG5snv_aMQtctrhrdHIrJbt0gmDABuUV-7YJ4FCHM2G_b6N_cEWssUsaiTEsjJzTCVvTdMdgH4q4jqfAZ75-h9WTDv6erDk7Uee8k0HJ4Gi08gX7u_efRcqLRi2ydv1ed74LCoox_SLi97C5tYZBTJwMI6Ljm1HIO4VVGwbZpDXTjxwqvMzUw0bxDYtPgVhU-PE79rcvmGNuaWzt5GloQl6hgVYWtJpCbKh_fTT_d5czsq0TWXsCwSY9OK96ho966PrryjBSAvfjSFD4rNUb2c8vDKO1ozjMEpDBWgvjdUJXi4rdTdqFny-bgFG2dFZksIgnAZxaryH3m0AcIUOg\",\"token_type\":\"bearer\",\"expires_in\":3600}", 16 | "headers": { 17 | "Cache-Control": "no-store", 18 | "Pragma": "no-cache", 19 | "Content-Length": "558", 20 | "Content-Type": "application/json;charset=UTF-8", 21 | "Server": "Microsoft-HTTPAPI/2.0 Microsoft-HTTPAPI/2.0", 22 | "Date": "Sun, 03 Jul 2022 07:13:56 GMT" 23 | }, 24 | "status_code": 200, 25 | "type": "ok" 26 | } 27 | } 28 | ] 29 | -------------------------------------------------------------------------------- /test/fixtures/custom_cassettes/adfs/authorise#6.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "request": { 4 | "body": "client_id=CLIENT_ID&code=CODE&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Flogin", 5 | "headers": { 6 | "Content-Type": "application/x-www-form-urlencoded" 7 | }, 8 | "method": "post", 9 | "options": [], 10 | "request_body": "", 11 | "url": "https://my-adfs/adfs/oauth2/token" 12 | }, 13 | "response": { 14 | "binary": false, 15 | "body": "{\"access_token\":\"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJfU2FtQWNjb3VudE5hbWUiOiJlMDEyMzQ1NiIsImRpc3BsYXlOYW1lIjoiWW91ciBOYW1lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjIwMDAwMDAwMDB9.IUOntnGSFJEvojMynmuRrz-uK3J0s1OOwMlNBHMfd_X3mwhQZ8Mg2SxSd2m2CpwSWGwCxvjHLjH7152CZ0KoZnh9glXdoh80QBVVoeeu6HY62bk3LLT4FnocRVzxrFG4TscX3HzyIjEKag82OWnjQe7V0cjWKNgm4l_RxhmaNUZ6m3aAx9kxqyT1bfmgyPdC0rSYbTiSx0-E6RKJ_ynmM8fF2WUPiMecjFbuRHD2UWt7nzqpPMnJKFtUs_21wuiz51TfcUMpI1ewr9EJapgiKbsRACwFtXzNGgGZmJg7dcwwo73Mc_hLnK2SXyxeSsPSz6GXzu4ZxQMVdesB1C10Ig\",\"token_type\":\"bearer\",\"expires_in\":3600}", 16 | "headers": { 17 | "Cache-Control": "no-store", 18 | "Pragma": "no-cache", 19 | "Content-Length": "559", 20 | "Content-Type": "application/json;charset=UTF-8", 21 | "Server": "Microsoft-HTTPAPI/2.0 Microsoft-HTTPAPI/2.0", 22 | "Date": "Sun, 03 Jul 2022 07:13:56 GMT" 23 | }, 24 | "status_code": 200, 25 | "type": "ok" 26 | } 27 | } 28 | ] 29 | -------------------------------------------------------------------------------- /test/fixtures/custom_cassettes/adfs/authorise#7.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "request": { 4 | "body": "client_id=CLIENT_ID&code=CODE&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Flogin", 5 | "headers": { 6 | "Content-Type": "application/x-www-form-urlencoded" 7 | }, 8 | "method": "post", 9 | "options": [], 10 | "request_body": "", 11 | "url": "https://my-adfs/adfs/oauth2/token" 12 | }, 13 | "response": { 14 | "binary": false, 15 | "body": "{\"access_token\":\"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJTYW1BY2NvdW50TmFtZSI6ImUwMTIzNDU2IiwiZGlzcGxheU5hbWUiOiJZb3VyIE5hbWUiLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTUxNjIzOTAyM30.oz2n96Od11b3b_j70jkI0eSJb8k0nipCw3U8adb68lGr-vxeghKLCfeI9_gdlCiLUmIAszLE44VGxxZ_gHKp9EVgYRfm5YRzL0L82UgY6BUlDLa8K4RZTwv7XBy8ZyYo_ttKOnY34tIYLKn_f_Gr3De1iVwP80irYKk7k3GoBqNHAFriCp1MuoIoLoBaHhdFCrVuN0eSEvz267IibNqJlQS-CNmJYkVwY1QrNAcrkNlibpOdrCoqcaV6AbZ_3wcvmfuTZZOxzYwpa05C0oIa_XvG7GyKjtDvmEwu558EFbp6tHQTnpz1u3hdyJzW3A8s8vWqRQH0klL9KMJVjiB37Q\",\"token_type\":\"bearer\",\"expires_in\":3600}", 16 | "headers": { 17 | "Cache-Control": "no-store", 18 | "Pragma": "no-cache", 19 | "Content-Length": "558", 20 | "Content-Type": "application/json;charset=UTF-8", 21 | "Server": "Microsoft-HTTPAPI/2.0 Microsoft-HTTPAPI/2.0", 22 | "Date": "Sun, 03 Jul 2022 07:13:56 GMT" 23 | }, 24 | "status_code": 200, 25 | "type": "ok" 26 | } 27 | } 28 | ] 29 | -------------------------------------------------------------------------------- /lib/cadet_web/controllers/stories_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule CadetWeb.StoriesController do 2 | use CadetWeb, :controller 3 | use PhoenixSwagger 4 | 5 | alias Cadet.Stories.Stories 6 | 7 | def index(conn, %{"course_id" => course_id}) do 8 | list_all = conn.assigns.course_reg.role in [:admin, :staff] 9 | stories = Stories.list_stories(course_id, list_all) 10 | render(conn, "index.json", stories: stories) 11 | end 12 | 13 | swagger_path :index do 14 | get("/courses/{course_id}/stories") 15 | 16 | summary("Get a list of all stories") 17 | 18 | security([%{JWT: []}]) 19 | 20 | response(200, "OK", Schema.array(:Story)) 21 | end 22 | 23 | @spec swagger_definitions :: %{Story: any} 24 | def swagger_definitions do 25 | %{ 26 | Story: 27 | swagger_schema do 28 | properties do 29 | filenames(schema_array(:string), "Filenames of txt files", required: true) 30 | title(:string, "Title shown in Chapter Select Screen", required: true) 31 | imageUrl(:string, "Path to image shown in Chapter Select Screen", required: false) 32 | openAt(:string, "The opening date", format: "date-time", required: true) 33 | closeAt(:string, "The closing date", format: "date-time", required: true) 34 | isPublished(:boolean, "Whether or not is published", required: false) 35 | course_id(:integer, "The id of the course that this story belongs to", required: true) 36 | end 37 | end 38 | } 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/cadet/code_exchange.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.TokenExchange do 2 | @moduledoc """ 3 | The TokenExchange entity stores short-lived codes to be exchanged for long-lived auth tokens. 4 | """ 5 | use Cadet, :model 6 | 7 | import Ecto.Query 8 | 9 | alias Cadet.Repo 10 | alias Cadet.Accounts.User 11 | 12 | @primary_key {:code, :string, []} 13 | schema "token_exchange" do 14 | field(:generated_at, :utc_datetime_usec) 15 | field(:expires_at, :utc_datetime_usec) 16 | 17 | belongs_to(:user, User) 18 | 19 | timestamps() 20 | end 21 | 22 | @required_fields ~w(code generated_at expires_at user_id)a 23 | 24 | def get_by_code(code) do 25 | case Repo.get_by(__MODULE__, code: code) do 26 | nil -> 27 | {:error, "Not found"} 28 | 29 | struct -> 30 | if Timex.before?(struct.expires_at, Timex.now()) do 31 | {:error, "Expired"} 32 | else 33 | struct = Repo.preload(struct, :user) 34 | Repo.delete(struct) 35 | {:ok, struct} 36 | end 37 | end 38 | end 39 | 40 | def delete_expired do 41 | now = Timex.now() 42 | 43 | Repo.delete_all(from(c in __MODULE__, where: c.expires_at < ^now)) 44 | end 45 | 46 | def changeset(struct, attrs) do 47 | struct 48 | |> cast(attrs, @required_fields) 49 | |> validate_required(@required_fields) 50 | end 51 | 52 | def insert(attrs) do 53 | changeset = 54 | %__MODULE__{} 55 | |> changeset(attrs) 56 | 57 | changeset 58 | |> Repo.insert() 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/cadet/jobs/log_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Jobs.LogEntryTest do 2 | use Cadet.DataCase 3 | 4 | alias Cadet.Jobs.{Log, LogEntry} 5 | alias Timex.Duration 6 | 7 | @name "test_job" 8 | 9 | test "returns true (job runs) when no log entry" do 10 | assert Log.log_execution(@name, Duration.from_days(1)) 11 | 12 | entry = LogEntry |> where(name: @name) |> Repo.one() 13 | 14 | assert Timex.compare( 15 | entry.last_run, 16 | Timex.subtract(Timex.now(), Duration.from_minutes(1)) 17 | ) == 1 18 | end 19 | 20 | test "returns true (job runs) when log entry old enough" do 21 | %LogEntry{ 22 | name: @name, 23 | last_run: 24 | Timex.now() 25 | |> Timex.subtract(Duration.from_hours(25)) 26 | |> DateTime.truncate(:second) 27 | } 28 | |> Repo.insert!() 29 | 30 | assert Log.log_execution(@name, Duration.from_days(1)) 31 | 32 | entry = LogEntry |> where(name: @name) |> Repo.one() 33 | 34 | assert Timex.compare( 35 | entry.last_run, 36 | Timex.subtract(Timex.now(), Duration.from_minutes(1)) 37 | ) == 1 38 | end 39 | 40 | test "returns false (job does not run) when log entry too recent" do 41 | %LogEntry{ 42 | name: @name, 43 | last_run: 44 | Timex.now() 45 | |> Timex.subtract(Duration.from_hours(23)) 46 | |> DateTime.truncate(:second) 47 | } 48 | |> Repo.insert!() 49 | 50 | refute Log.log_execution(@name, Duration.from_days(1)) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210716073359_update_achievement.exs: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Repo.Migrations.UpdateAchievement do 2 | use Ecto.Migration 3 | import Ecto.Query, only: [from: 2] 4 | 5 | def change do 6 | alter table(:achievements) do 7 | add(:course_id, references(:courses), null: true) 8 | end 9 | 10 | alter table(:goals) do 11 | add(:course_id, references(:courses), null: true) 12 | end 13 | 14 | alter table(:goal_progress) do 15 | add(:course_reg_id, references(:course_registrations), null: true) 16 | end 17 | 18 | execute(fn -> 19 | courses = from(c in "courses", select: c.id) |> repo().all() 20 | course_id = courses |> Enum.at(0) 21 | repo().update_all("achievements", set: [course_id: course_id]) 22 | repo().update_all("goals", set: [course_id: course_id]) 23 | end) 24 | 25 | execute( 26 | "update goal_progress gp set course_reg_id = (select cr.id from course_registrations cr where cr.user_id = gp.user_id)" 27 | ) 28 | 29 | alter table(:achievements) do 30 | modify(:course_id, references(:courses), null: false, from: references(:courses)) 31 | end 32 | 33 | alter table(:goals) do 34 | modify(:course_id, references(:courses), null: false, from: references(:courses)) 35 | end 36 | 37 | alter table(:goal_progress) do 38 | remove(:user_id) 39 | 40 | modify(:course_reg_id, references(:course_registrations), 41 | null: false, 42 | from: references(:course_registrations) 43 | ) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /deployment/terraform/rds.tf: -------------------------------------------------------------------------------- 1 | resource "random_password" "db_password" { 2 | length = 64 3 | override_special = "~!#$%^&*()_+-=[]{}|;:,.<>?" 4 | } 5 | 6 | resource "aws_db_subnet_group" "db" { 7 | name = "${var.env}-cadet-db" 8 | subnet_ids = [aws_subnet.private_a.id, aws_subnet.private_b.id] 9 | 10 | tags = { 11 | Name = "${title(var.env)} Cadet DB" 12 | Environment = var.env 13 | } 14 | } 15 | 16 | resource "aws_db_instance" "db" { 17 | identifier_prefix = "${var.env}-cadet-db-" 18 | name = "cadet_${var.env}" 19 | instance_class = var.rds_instance_class 20 | db_subnet_group_name = aws_db_subnet_group.db.name 21 | vpc_security_group_ids = [aws_security_group.db.id] 22 | allocated_storage = var.rds_allocated_storage 23 | storage_type = "gp2" 24 | engine = "postgres" 25 | engine_version = "13.3" 26 | username = "postgres" 27 | password = random_password.db_password.result 28 | port = 5432 29 | publicly_accessible = false 30 | backup_retention_period = 14 31 | backup_window = "17:00-18:00" 32 | maintenance_window = "sun:18:00-sun:22:00" 33 | deletion_protection = true 34 | performance_insights_enabled = true 35 | monitoring_interval = 60 36 | 37 | tags = { 38 | Name = "${title(var.env)} Cadet DB" 39 | Environment = var.env 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/cadet/helpers/display_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.DisplayHelper do 2 | @moduledoc """ 3 | Contains utility functions that may be used for modules that need to be displayed to the user. 4 | """ 5 | import Ecto.Changeset 6 | 7 | def put_display_order(changeset, collection) do 8 | if Enum.empty?(collection) do 9 | change(changeset, %{display_order: 1}) 10 | else 11 | last = Enum.max_by(collection, & &1.display_order) 12 | change(changeset, %{display_order: last.display_order + 1}) 13 | end 14 | end 15 | 16 | @spec full_error_messages(Ecto.Changeset.t()) :: String.t() 17 | def full_error_messages(changeset = %Ecto.Changeset{}) do 18 | changeset 19 | |> traverse_errors(&process_error/1) 20 | |> format_message() 21 | end 22 | 23 | def full_error_messages(changeset), do: changeset 24 | 25 | defp process_error({msg, opts}) do 26 | Enum.reduce(opts, msg, fn {key, value}, acc -> 27 | String.replace( 28 | acc, 29 | "%{#{key}}", 30 | if(is_list(value), do: Enum.join(value, ","), else: inspect(value)) 31 | ) 32 | end) 33 | end 34 | 35 | defp format_message(errors = %{}) do 36 | errors 37 | |> Enum.map_join("\n", fn {k, v} -> 38 | message = 39 | v 40 | |> Enum.map_join("; ", fn 41 | %{} = sub -> "{#{format_message(sub)}}" 42 | str -> str 43 | end) 44 | 45 | "#{k} #{message}" 46 | end) 47 | end 48 | 49 | def create_invalid_changeset_with_error(key, message) do 50 | add_error(%Ecto.Changeset{}, key, message) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/cadet/accounts/query.ex: -------------------------------------------------------------------------------- 1 | defmodule Cadet.Accounts.Query do 2 | @moduledoc """ 3 | Generate queries related to the Accounts context 4 | """ 5 | import Ecto.Query 6 | 7 | alias Cadet.Accounts.{User, CourseRegistration} 8 | alias Cadet.Repo 9 | 10 | def all_students(course_id) do 11 | User 12 | |> in_course(course_id) 13 | |> where([u, cr], cr.role == "student") 14 | |> Repo.all() 15 | end 16 | 17 | def username(username) do 18 | User 19 | |> of_username(username) 20 | |> preload(:latest_viewed_course) 21 | end 22 | 23 | @spec students_of(CourseRegistration.t()) :: Ecto.Query.t() 24 | def students_of(course_reg = %CourseRegistration{course_id: course_id}) do 25 | # Note that staff role is not check here as we assume that 26 | # group leader is assign to a staff validated by group changeset 27 | CourseRegistration 28 | |> where([cr], cr.course_id == ^course_id) 29 | |> join(:inner, [cr], g in assoc(cr, :group)) 30 | |> where([cr, g], g.leader_id == ^course_reg.id) 31 | end 32 | 33 | def avenger_of?(avenger, student_id) do 34 | students = students_of(avenger) 35 | 36 | students 37 | |> Repo.get_by(id: student_id) 38 | |> case do 39 | nil -> false 40 | _ -> true 41 | end 42 | end 43 | 44 | defp of_username(query, username) do 45 | query |> where([a], a.username == ^username) 46 | end 47 | 48 | defp in_course(user, course_id) do 49 | user 50 | |> join(:inner, [u], cr in CourseRegistration, on: u.id == cr.user_id) 51 | |> where([_, cr], cr.course_id == ^course_id) 52 | end 53 | end 54 | --------------------------------------------------------------------------------