├── 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 = <