From e3a6fd214c4a2cbd169a5c7e9a7613b6edef90eb Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Wed, 5 Jul 2023 10:31:18 +0800 Subject: [PATCH 001/128] Update assessment schema and add max_team_size as a required field --- lib/cadet/accounts/notification.ex | 0 lib/cadet/assessments/assessment.ex | 3 ++- lib/cadet/assessments/assessments.ex | 1 + .../admin_controllers/admin_assessments_controller.ex | 11 ++++++++++- lib/cadet_web/admin_views/admin_assessments_view.ex | 3 ++- lib/cadet_web/controllers/assessments_controller.ex | 2 ++ lib/cadet_web/views/assessments_view.ex | 3 ++- .../20190510152804_drop_announcements_table.exs | 0 ...0230704125701_add_max_team_size_to_assessments.exs | 9 +++++++++ 9 files changed, 28 insertions(+), 4 deletions(-) mode change 100755 => 100644 lib/cadet/accounts/notification.ex mode change 100755 => 100644 priv/repo/migrations/20190510152804_drop_announcements_table.exs create mode 100644 priv/repo/migrations/20230704125701_add_max_team_size_to_assessments.exs diff --git a/lib/cadet/accounts/notification.ex b/lib/cadet/accounts/notification.ex old mode 100755 new mode 100644 diff --git a/lib/cadet/assessments/assessment.ex b/lib/cadet/assessments/assessment.ex index 77d0ef513..5e5c36233 100644 --- a/lib/cadet/assessments/assessment.ex +++ b/lib/cadet/assessments/assessment.ex @@ -32,6 +32,7 @@ defmodule Cadet.Assessments.Assessment do field(:story, :string) field(:reading, :string) field(:password, :string, default: nil) + field(:max_team_size, :integer, default: 1) belongs_to(:config, AssessmentConfig) belongs_to(:course, Course) @@ -40,7 +41,7 @@ defmodule Cadet.Assessments.Assessment do timestamps() end - @required_fields ~w(title open_at close_at number course_id config_id)a + @required_fields ~w(title open_at close_at number course_id config_id max_team_size)a @optional_fields ~w(reading summary_short summary_long is_published story cover_picture access password)a @optional_file_fields ~w(mission_pdf)a diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 6412ada2a..a16b66bb2 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -621,6 +621,7 @@ defmodule Cadet.Assessments do end def update_assessment(id, params) when is_ecto_id(id) do + IO.inspect(params) simple_update( Assessment, id, diff --git a/lib/cadet_web/admin_controllers/admin_assessments_controller.ex b/lib/cadet_web/admin_controllers/admin_assessments_controller.ex index 01600baee..31de1f4ea 100644 --- a/lib/cadet_web/admin_controllers/admin_assessments_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_assessments_controller.ex @@ -82,6 +82,7 @@ defmodule CadetWeb.AdminAssessmentsController do open_at = params |> Map.get("openAt") close_at = params |> Map.get("closeAt") is_published = params |> Map.get("isPublished") + max_team_size = params |> Map.get("maxTeamSize") updated_assessment = if is_nil(is_published) do @@ -89,7 +90,15 @@ defmodule CadetWeb.AdminAssessmentsController do else %{:is_published => is_published} end + + updated_assessment = + if is_nil(max_team_size) do + updated_assessment + else + Map.put(updated_assessment, :max_team_size, max_team_size) + end + IO.inspect(updated_assessment) with {:ok, assessment} <- check_dates(open_at, close_at, updated_assessment), {:ok, _nil} <- Assessments.update_assessment(assessment_id, assessment) do text(conn, "OK") @@ -113,7 +122,7 @@ defmodule CadetWeb.AdminAssessmentsController do else assessment = Map.put(assessment, :open_at, formatted_open_date) assessment = Map.put(assessment, :close_at, formatted_close_date) - + IO.inspect("good") {:ok, assessment} end end diff --git a/lib/cadet_web/admin_views/admin_assessments_view.ex b/lib/cadet_web/admin_views/admin_assessments_view.ex index 4b1274d5d..477ea1b38 100644 --- a/lib/cadet_web/admin_views/admin_assessments_view.ex +++ b/lib/cadet_web/admin_views/admin_assessments_view.ex @@ -27,7 +27,8 @@ defmodule CadetWeb.AdminAssessmentsView do private: &password_protected?(&1.password), isPublished: :is_published, questionCount: :question_count, - gradedCount: &(&1.graded_count || 0) + gradedCount: &(&1.graded_count || 0), + maxTeamSize: :max_team_size }) end diff --git a/lib/cadet_web/controllers/assessments_controller.ex b/lib/cadet_web/controllers/assessments_controller.ex index dc790e176..d5a4ea5e5 100644 --- a/lib/cadet_web/controllers/assessments_controller.ex +++ b/lib/cadet_web/controllers/assessments_controller.ex @@ -195,6 +195,8 @@ defmodule CadetWeb.AssessmentsController do "The number of answers in the submission which have been graded", required: true ) + + maxTeamSize(:integer, "The maximum team size allowed", required: true) end end, Assessment: diff --git a/lib/cadet_web/views/assessments_view.ex b/lib/cadet_web/views/assessments_view.ex index 20aad951f..359f42454 100644 --- a/lib/cadet_web/views/assessments_view.ex +++ b/lib/cadet_web/views/assessments_view.ex @@ -28,7 +28,8 @@ defmodule CadetWeb.AssessmentsView do private: &password_protected?(&1.password), isPublished: :is_published, questionCount: :question_count, - gradedCount: &(&1.graded_count || 0) + gradedCount: &(&1.graded_count || 0), + maxTeamSize: :max_team_size }) end diff --git a/priv/repo/migrations/20190510152804_drop_announcements_table.exs b/priv/repo/migrations/20190510152804_drop_announcements_table.exs old mode 100755 new mode 100644 diff --git a/priv/repo/migrations/20230704125701_add_max_team_size_to_assessments.exs b/priv/repo/migrations/20230704125701_add_max_team_size_to_assessments.exs new file mode 100644 index 000000000..6ecfa9cea --- /dev/null +++ b/priv/repo/migrations/20230704125701_add_max_team_size_to_assessments.exs @@ -0,0 +1,9 @@ +defmodule Cadet.Repo.Migrations.AddMaxTeamSizeToAssessments do + use Ecto.Migration + + def change do + alter table(:assessments) do + add(:max_team_size, :integer) + end + end +end \ No newline at end of file From 009045ea2fc3882decfb5091348e329f4e70e733 Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Wed, 5 Jul 2023 10:39:57 +0800 Subject: [PATCH 002/128] Update assessment schema and add max_team_size as a required field --- .credo.exs | 280 +- lib/cadet/accounts/notification.ex | 72 +- lib/cadet/assessments/assessment.ex | 186 +- lib/cadet/assessments/assessments.ex | 3346 ++++++++--------- .../admin_assessments_controller.ex | 446 +-- .../admin_views/admin_assessments_view.ex | 128 +- .../controllers/assessments_controller.ex | 806 ++-- mix.exs | 262 +- mix.lock | 180 +- ...0190510152804_drop_announcements_table.exs | 14 +- .../assessments_controller_test.exs | 3146 ++++++++-------- 11 files changed, 4433 insertions(+), 4433 deletions(-) diff --git a/.credo.exs b/.credo.exs index cc2e7c08b..0b50b3681 100644 --- a/.credo.exs +++ b/.credo.exs @@ -1,140 +1,140 @@ -# This file contains the configuration for Credo and you are probably reading -# this after creating it with `mix credo.gen.config`. -# -# If you find anything wrong or unclear in this file, please report an -# issue on GitHub: https://github.com/rrrene/credo/issues -# -%{ - # - # You can have as many configs as you like in the `configs:` field. - configs: [ - %{ - # - # Run any exec using `mix credo -C `. If no exec name is given - # "default" is used. - # - name: "default", - # - # These are the files included in the analysis: - files: %{ - # - # You can give explicit globs or simply directories. - # In the latter case `**/*.{ex,exs}` will be used. - included: ["lib/", "src/", "web/", "apps/", "test/"], - excluded: [~r"/_build/", ~r"/deps/"] - }, - # - # If you create your own checks, you must specify the source files for - # them here, so they can be loaded by Credo before running the analysis. - # - requires: [], - # - # If you want to enforce a style guide and need a more traditional linting - # experience, you can change `strict` to `true` below: - # - strict: true, - # - # If you want to use uncolored output by default, you can change `color` - # to `false` below: - # - color: true, - # - # You can customize the parameters of any check by adding a second element - # to the tuple. - # - # To disable a check put `false` as second element: - # - # {Credo.Check.Design.DuplicatedCode, false} - # - checks: [ - {Credo.Check.Consistency.ExceptionNames}, - {Credo.Check.Consistency.LineEndings}, - {Credo.Check.Consistency.ParameterPatternMatching}, - {Credo.Check.Consistency.SpaceAroundOperators}, - {Credo.Check.Consistency.SpaceInParentheses}, - {Credo.Check.Consistency.TabsOrSpaces}, - - # For some checks, like AliasUsage, you can only customize the priority - # Priority values are: `low, normal, high, higher` - # - {Credo.Check.Design.AliasUsage, - if_called_more_often_than: 2, excluded_namespaces: ["Faker"]}, - - # For others you can set parameters - - # If you don't want the `setup` and `test` macro calls in ExUnit tests - # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just - # set the `excluded_macros` parameter to `[:schema, :setup, :test]`. - # - {Credo.Check.Design.DuplicatedCode, excluded_macros: [], exit_status: 0}, - - # You can also customize the exit_status of each check. - # If you don't want TODO comments to cause `mix credo` to fail, just - # set this value to 0 (zero). - # - {Credo.Check.Design.TagTODO, exit_status: 0}, - {Credo.Check.Design.TagFIXME}, - {Credo.Check.Readability.AliasOrder, false}, - {Credo.Check.Readability.FunctionNames}, - {Credo.Check.Readability.LargeNumbers}, - {Credo.Check.Readability.MaxLineLength, max_length: 101}, - {Credo.Check.Readability.ModuleAttributeNames}, - {Credo.Check.Readability.ModuleDoc}, - {Credo.Check.Readability.ModuleNames}, - {Credo.Check.Readability.ParenthesesOnZeroArityDefs}, - {Credo.Check.Readability.ParenthesesInCondition}, - {Credo.Check.Readability.PredicateFunctionNames}, - {Credo.Check.Readability.PreferImplicitTry}, - {Credo.Check.Readability.RedundantBlankLines}, - {Credo.Check.Readability.StringSigils}, - {Credo.Check.Readability.TrailingBlankLine}, - {Credo.Check.Readability.TrailingWhiteSpace}, - {Credo.Check.Readability.VariableNames}, - {Credo.Check.Readability.Semicolons}, - {Credo.Check.Readability.SpaceAfterCommas}, - {Credo.Check.Refactor.DoubleBooleanNegation}, - {Credo.Check.Refactor.CondStatements}, - {Credo.Check.Refactor.CyclomaticComplexity}, - {Credo.Check.Refactor.FunctionArity}, - {Credo.Check.Refactor.LongQuoteBlocks}, - {Credo.Check.Refactor.MatchInCondition}, - {Credo.Check.Refactor.NegatedConditionsInUnless}, - {Credo.Check.Refactor.NegatedConditionsWithElse}, - {Credo.Check.Refactor.Nesting}, - {Credo.Check.Refactor.PipeChainStart}, - {Credo.Check.Refactor.UnlessWithElse}, - {Credo.Check.Warning.BoolOperationOnSameValues}, - {Credo.Check.Warning.IExPry}, - {Credo.Check.Warning.IoInspect}, - {Credo.Check.Warning.LazyLogging, false}, - {Credo.Check.Warning.OperationOnSameValues}, - {Credo.Check.Warning.OperationWithConstantResult}, - {Credo.Check.Warning.UnusedEnumOperation}, - {Credo.Check.Warning.UnusedFileOperation}, - {Credo.Check.Warning.UnusedKeywordOperation}, - {Credo.Check.Warning.UnusedListOperation}, - {Credo.Check.Warning.UnusedPathOperation}, - {Credo.Check.Warning.UnusedRegexOperation}, - {Credo.Check.Warning.UnusedStringOperation}, - {Credo.Check.Warning.UnusedTupleOperation}, - {Credo.Check.Warning.RaiseInsideRescue}, - - # Controversial and experimental checks (opt-in, just remove `, false`) - # - {Credo.Check.Refactor.ABCSize, false}, - {Credo.Check.Refactor.AppendSingleItem, false}, - {Credo.Check.Refactor.VariableRebinding, false}, - {Credo.Check.Warning.MapGetUnsafePass}, - {Credo.Check.Consistency.MultiAliasImportRequireUse}, - - # Deprecated checks (these will be deleted after a grace period) - # - {Credo.Check.Readability.Specs, false}, - {Credo.Check.Refactor.MapInto, false} - - # Custom checks can be created using `mix credo.gen.check`. - # - ] - } - ] -} +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any exec using `mix credo -C `. If no exec name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + included: ["lib/", "src/", "web/", "apps/", "test/"], + excluded: [~r"/_build/", ~r"/deps/"] + }, + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: [], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: true, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: [ + {Credo.Check.Consistency.ExceptionNames}, + {Credo.Check.Consistency.LineEndings}, + {Credo.Check.Consistency.ParameterPatternMatching}, + {Credo.Check.Consistency.SpaceAroundOperators}, + {Credo.Check.Consistency.SpaceInParentheses}, + {Credo.Check.Consistency.TabsOrSpaces}, + + # For some checks, like AliasUsage, you can only customize the priority + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, + if_called_more_often_than: 2, excluded_namespaces: ["Faker"]}, + + # For others you can set parameters + + # If you don't want the `setup` and `test` macro calls in ExUnit tests + # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just + # set the `excluded_macros` parameter to `[:schema, :setup, :test]`. + # + {Credo.Check.Design.DuplicatedCode, excluded_macros: [], exit_status: 0}, + + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, exit_status: 0}, + {Credo.Check.Design.TagFIXME}, + {Credo.Check.Readability.AliasOrder, false}, + {Credo.Check.Readability.FunctionNames}, + {Credo.Check.Readability.LargeNumbers}, + {Credo.Check.Readability.MaxLineLength, max_length: 101}, + {Credo.Check.Readability.ModuleAttributeNames}, + {Credo.Check.Readability.ModuleDoc}, + {Credo.Check.Readability.ModuleNames}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs}, + {Credo.Check.Readability.ParenthesesInCondition}, + {Credo.Check.Readability.PredicateFunctionNames}, + {Credo.Check.Readability.PreferImplicitTry}, + {Credo.Check.Readability.RedundantBlankLines}, + {Credo.Check.Readability.StringSigils}, + {Credo.Check.Readability.TrailingBlankLine}, + {Credo.Check.Readability.TrailingWhiteSpace}, + {Credo.Check.Readability.VariableNames}, + {Credo.Check.Readability.Semicolons}, + {Credo.Check.Readability.SpaceAfterCommas}, + {Credo.Check.Refactor.DoubleBooleanNegation}, + {Credo.Check.Refactor.CondStatements}, + {Credo.Check.Refactor.CyclomaticComplexity}, + {Credo.Check.Refactor.FunctionArity}, + {Credo.Check.Refactor.LongQuoteBlocks}, + {Credo.Check.Refactor.MatchInCondition}, + {Credo.Check.Refactor.NegatedConditionsInUnless}, + {Credo.Check.Refactor.NegatedConditionsWithElse}, + {Credo.Check.Refactor.Nesting}, + {Credo.Check.Refactor.PipeChainStart}, + {Credo.Check.Refactor.UnlessWithElse}, + {Credo.Check.Warning.BoolOperationOnSameValues}, + {Credo.Check.Warning.IExPry}, + {Credo.Check.Warning.IoInspect}, + {Credo.Check.Warning.LazyLogging, false}, + {Credo.Check.Warning.OperationOnSameValues}, + {Credo.Check.Warning.OperationWithConstantResult}, + {Credo.Check.Warning.UnusedEnumOperation}, + {Credo.Check.Warning.UnusedFileOperation}, + {Credo.Check.Warning.UnusedKeywordOperation}, + {Credo.Check.Warning.UnusedListOperation}, + {Credo.Check.Warning.UnusedPathOperation}, + {Credo.Check.Warning.UnusedRegexOperation}, + {Credo.Check.Warning.UnusedStringOperation}, + {Credo.Check.Warning.UnusedTupleOperation}, + {Credo.Check.Warning.RaiseInsideRescue}, + + # Controversial and experimental checks (opt-in, just remove `, false`) + # + {Credo.Check.Refactor.ABCSize, false}, + {Credo.Check.Refactor.AppendSingleItem, false}, + {Credo.Check.Refactor.VariableRebinding, false}, + {Credo.Check.Warning.MapGetUnsafePass}, + {Credo.Check.Consistency.MultiAliasImportRequireUse}, + + # Deprecated checks (these will be deleted after a grace period) + # + {Credo.Check.Readability.Specs, false}, + {Credo.Check.Refactor.MapInto, false} + + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } + ] +} diff --git a/lib/cadet/accounts/notification.ex b/lib/cadet/accounts/notification.ex index e6e552d1b..0b07e46a7 100644 --- a/lib/cadet/accounts/notification.ex +++ b/lib/cadet/accounts/notification.ex @@ -1,36 +1,36 @@ -defmodule Cadet.Accounts.Notification do - @moduledoc """ - The Notification entity represents a notification. - It stores information pertaining to the type of notification and who in which course it belongs to. - Each notification can have an assessment id or submission id, with optional question id. - This will be used to pinpoint where the notification will be showed on the frontend. - """ - use Cadet, :model - - alias Cadet.Accounts.{NotificationType, Role, CourseRegistration} - alias Cadet.Assessments.{Assessment, Submission} - - schema "notifications" do - field(:type, NotificationType) - field(:read, :boolean, default: false) - field(:role, Role, virtual: true) - - belongs_to(:course_reg, CourseRegistration) - belongs_to(:assessment, Assessment) - belongs_to(:submission, Submission) - - timestamps() - end - - @required_fields ~w(type read course_reg_id assessment_id)a - @optional_fields ~w(submission_id)a - - def changeset(answer, params) do - answer - |> cast(params, @required_fields ++ @optional_fields) - |> validate_required(@required_fields) - |> foreign_key_constraint(:course_reg_id) - |> foreign_key_constraint(:assessment_id) - |> foreign_key_constraint(:submission_id) - end -end +defmodule Cadet.Accounts.Notification do + @moduledoc """ + The Notification entity represents a notification. + It stores information pertaining to the type of notification and who in which course it belongs to. + Each notification can have an assessment id or submission id, with optional question id. + This will be used to pinpoint where the notification will be showed on the frontend. + """ + use Cadet, :model + + alias Cadet.Accounts.{NotificationType, Role, CourseRegistration} + alias Cadet.Assessments.{Assessment, Submission} + + schema "notifications" do + field(:type, NotificationType) + field(:read, :boolean, default: false) + field(:role, Role, virtual: true) + + belongs_to(:course_reg, CourseRegistration) + belongs_to(:assessment, Assessment) + belongs_to(:submission, Submission) + + timestamps() + end + + @required_fields ~w(type read course_reg_id assessment_id)a + @optional_fields ~w(submission_id)a + + def changeset(answer, params) do + answer + |> cast(params, @required_fields ++ @optional_fields) + |> validate_required(@required_fields) + |> foreign_key_constraint(:course_reg_id) + |> foreign_key_constraint(:assessment_id) + |> foreign_key_constraint(:submission_id) + end +end diff --git a/lib/cadet/assessments/assessment.ex b/lib/cadet/assessments/assessment.ex index 5e5c36233..fe30c0a45 100644 --- a/lib/cadet/assessments/assessment.ex +++ b/lib/cadet/assessments/assessment.ex @@ -1,93 +1,93 @@ -defmodule Cadet.Assessments.Assessment do - @moduledoc """ - The Assessment entity stores metadata of a students' assessment - (mission, sidequest, path, and contest) - """ - use Cadet, :model - use Arc.Ecto.Schema - - alias Cadet.Repo - alias Cadet.Assessments.{AssessmentAccess, Question, SubmissionStatus, Upload} - alias Cadet.Courses.{Course, AssessmentConfig} - - @type t :: %__MODULE__{} - - schema "assessments" do - field(:access, AssessmentAccess, virtual: true, default: :public) - field(:max_xp, :integer, virtual: true) - field(:xp, :integer, virtual: true, default: 0) - field(:user_status, SubmissionStatus, virtual: true) - field(:grading_status, :string, virtual: true) - field(:question_count, :integer, virtual: true) - field(:graded_count, :integer, virtual: true) - field(:title, :string) - field(:is_published, :boolean, default: false) - field(:summary_short, :string) - field(:summary_long, :string) - field(:open_at, :utc_datetime_usec) - field(:close_at, :utc_datetime_usec) - field(:cover_picture, :string) - field(:mission_pdf, Upload.Type) - field(:number, :string) - field(:story, :string) - field(:reading, :string) - field(:password, :string, default: nil) - field(:max_team_size, :integer, default: 1) - - belongs_to(:config, AssessmentConfig) - belongs_to(:course, Course) - - has_many(:questions, Question, on_delete: :delete_all) - timestamps() - end - - @required_fields ~w(title open_at close_at number course_id config_id max_team_size)a - @optional_fields ~w(reading summary_short summary_long - is_published story cover_picture access password)a - @optional_file_fields ~w(mission_pdf)a - - def changeset(assessment, params) do - params = - params - |> convert_date(:open_at) - |> convert_date(:close_at) - - assessment - |> cast_attachments(params, @optional_file_fields) - |> cast(params, @required_fields ++ @optional_fields) - |> validate_required(@required_fields) - |> add_belongs_to_id_from_model([:config, :course], params) - |> foreign_key_constraint(:config_id) - |> foreign_key_constraint(:course_id) - |> unique_constraint([:number, :course_id]) - |> validate_config_course - |> validate_open_close_date - end - - defp validate_config_course(changeset) do - config_id = get_field(changeset, :config_id) - course_id = get_field(changeset, :course_id) - - case Repo.get(AssessmentConfig, config_id) do - nil -> - add_error(changeset, :config, "does not exist") - - config -> - if config.course_id == course_id do - changeset - else - add_error(changeset, :config, "does not belong to the same course as this assessment") - end - end - end - - defp validate_open_close_date(changeset) do - validate_change(changeset, :open_at, fn :open_at, open_at -> - if Timex.before?(open_at, get_field(changeset, :close_at)) do - [] - else - [open_at: "Open date must be before close date"] - end - end) - end -end +defmodule Cadet.Assessments.Assessment do + @moduledoc """ + The Assessment entity stores metadata of a students' assessment + (mission, sidequest, path, and contest) + """ + use Cadet, :model + use Arc.Ecto.Schema + + alias Cadet.Repo + alias Cadet.Assessments.{AssessmentAccess, Question, SubmissionStatus, Upload} + alias Cadet.Courses.{Course, AssessmentConfig} + + @type t :: %__MODULE__{} + + schema "assessments" do + field(:access, AssessmentAccess, virtual: true, default: :public) + field(:max_xp, :integer, virtual: true) + field(:xp, :integer, virtual: true, default: 0) + field(:user_status, SubmissionStatus, virtual: true) + field(:grading_status, :string, virtual: true) + field(:question_count, :integer, virtual: true) + field(:graded_count, :integer, virtual: true) + field(:title, :string) + field(:is_published, :boolean, default: false) + field(:summary_short, :string) + field(:summary_long, :string) + field(:open_at, :utc_datetime_usec) + field(:close_at, :utc_datetime_usec) + field(:cover_picture, :string) + field(:mission_pdf, Upload.Type) + field(:number, :string) + field(:story, :string) + field(:reading, :string) + field(:password, :string, default: nil) + field(:max_team_size, :integer, default: 1) + + belongs_to(:config, AssessmentConfig) + belongs_to(:course, Course) + + has_many(:questions, Question, on_delete: :delete_all) + timestamps() + end + + @required_fields ~w(title open_at close_at number course_id config_id max_team_size)a + @optional_fields ~w(reading summary_short summary_long + is_published story cover_picture access password)a + @optional_file_fields ~w(mission_pdf)a + + def changeset(assessment, params) do + params = + params + |> convert_date(:open_at) + |> convert_date(:close_at) + + assessment + |> cast_attachments(params, @optional_file_fields) + |> cast(params, @required_fields ++ @optional_fields) + |> validate_required(@required_fields) + |> add_belongs_to_id_from_model([:config, :course], params) + |> foreign_key_constraint(:config_id) + |> foreign_key_constraint(:course_id) + |> unique_constraint([:number, :course_id]) + |> validate_config_course + |> validate_open_close_date + end + + defp validate_config_course(changeset) do + config_id = get_field(changeset, :config_id) + course_id = get_field(changeset, :course_id) + + case Repo.get(AssessmentConfig, config_id) do + nil -> + add_error(changeset, :config, "does not exist") + + config -> + if config.course_id == course_id do + changeset + else + add_error(changeset, :config, "does not belong to the same course as this assessment") + end + end + end + + defp validate_open_close_date(changeset) do + validate_change(changeset, :open_at, fn :open_at, open_at -> + if Timex.before?(open_at, get_field(changeset, :close_at)) do + [] + else + [open_at: "Open date must be before close date"] + end + end) + end +end diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index a16b66bb2..b0d685119 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -1,1673 +1,1673 @@ -defmodule Cadet.Assessments do - @moduledoc """ - Assessments context contains domain logic for assessments management such as - missions, sidequests, paths, etc. - """ - use Cadet, [:context, :display] - import Ecto.Query - - require Logger - - alias Cadet.Accounts.{ - Notification, - Notifications, - User, - CourseRegistration, - CourseRegistrations - } - - alias Cadet.Assessments.{Answer, Assessment, Query, Question, Submission, SubmissionVotes} - alias Cadet.Autograder.GradingJob - alias Cadet.Courses.{Group, AssessmentConfig} - alias Cadet.Jobs.Log - alias Cadet.ProgramAnalysis.Lexer - alias Ecto.Multi - alias Cadet.Incentives.Achievements - - require Decimal - - @open_all_assessment_roles ~w(staff admin)a - - # These roles can save and finalise answers for closed assessments and - # submitted answers - @bypass_closed_roles ~w(staff admin)a - - def delete_assessment(id) do - assessment = Repo.get(Assessment, id) - - Submission - |> where(assessment_id: ^id) - |> delete_submission_assocation(id) - - Question - |> where(assessment_id: ^id) - |> Repo.all() - |> Enum.each(fn q -> - delete_submission_votes_association(q) - end) - - Repo.delete(assessment) - end - - defp delete_submission_votes_association(question) do - SubmissionVotes - |> where(question_id: ^question.id) - |> Repo.delete_all() - end - - defp delete_submission_assocation(submissions, assessment_id) do - submissions - |> Repo.all() - |> Enum.each(fn submission -> - Answer - |> where(submission_id: ^submission.id) - |> Repo.delete_all() - end) - - Notification - |> where(assessment_id: ^assessment_id) - |> Repo.delete_all() - - Repo.delete_all(submissions) - end - - @spec user_max_xp(CourseRegistration.t()) :: integer() - def user_max_xp(%CourseRegistration{id: cr_id}) do - Submission - |> where(status: ^:submitted) - |> where(student_id: ^cr_id) - |> join( - :inner, - [s], - a in subquery(Query.all_assessments_with_max_xp()), - on: s.assessment_id == a.id - ) - |> select([_, a], sum(a.max_xp)) - |> Repo.one() - |> decimal_to_integer() - end - - def assessments_total_xp(%CourseRegistration{id: cr_id}) do - submission_xp = - Submission - |> where(student_id: ^cr_id) - |> join(:inner, [s], a in Answer, on: s.id == a.submission_id) - |> group_by([s], s.id) - |> select([s, a], %{ - # grouping by submission, so s.xp_bonus will be the same, but we need an - # aggregate function - total_xp: sum(a.xp) + sum(a.xp_adjustment) + max(s.xp_bonus) - }) - - total = - submission_xp - |> subquery - |> select([s], %{ - total_xp: sum(s.total_xp) - }) - |> Repo.one() - - # for {key, val} <- total, into: %{}, do: {key, decimal_to_integer(val)} - decimal_to_integer(total.total_xp) - end - - def user_total_xp(course_id, user_id, course_reg_id) do - user_course = CourseRegistrations.get_user_course(user_id, course_id) - - total_achievement_xp = Achievements.achievements_total_xp(course_id, course_reg_id) - total_assessment_xp = assessments_total_xp(user_course) - - total_achievement_xp + total_assessment_xp - end - - defp decimal_to_integer(decimal) do - if Decimal.is_decimal(decimal) do - Decimal.to_integer(decimal) - else - 0 - end - end - - def user_current_story(cr = %CourseRegistration{}) do - {:ok, %{result: story}} = - Multi.new() - |> Multi.run(:unattempted, fn _repo, _ -> - {:ok, get_user_story_by_type(cr, :unattempted)} - end) - |> Multi.run(:result, fn _repo, %{unattempted: unattempted_story} -> - if unattempted_story do - {:ok, %{play_story?: true, story: unattempted_story}} - else - {:ok, %{play_story?: false, story: get_user_story_by_type(cr, :attempted)}} - end - end) - |> Repo.transaction() - - story - end - - @spec get_user_story_by_type(CourseRegistration.t(), :unattempted | :attempted) :: - String.t() | nil - def get_user_story_by_type(%CourseRegistration{id: cr_id}, type) - when is_atom(type) do - filter_and_sort = fn query -> - case type do - :unattempted -> - query - |> where([_, s], is_nil(s.id)) - |> order_by([a], asc: a.open_at) - - :attempted -> - query |> order_by([a], desc: a.close_at) - end - end - - Assessment - |> where(is_published: true) - |> where([a], not is_nil(a.story)) - |> where([a], a.open_at <= from_now(0, "second") and a.close_at >= from_now(0, "second")) - |> join(:left, [a], s in Submission, on: s.assessment_id == a.id and s.student_id == ^cr_id) - |> filter_and_sort.() - |> order_by([a], a.config_id) - |> select([a], a.story) - |> first() - |> Repo.one() - end - - def assessment_with_questions_and_answers( - assessment = %Assessment{password: nil}, - cr = %CourseRegistration{}, - nil - ) do - assessment_with_questions_and_answers(assessment, cr) - end - - def assessment_with_questions_and_answers( - assessment = %Assessment{password: nil}, - cr = %CourseRegistration{}, - _ - ) do - assessment_with_questions_and_answers(assessment, cr) - end - - def assessment_with_questions_and_answers( - assessment = %Assessment{password: password}, - cr = %CourseRegistration{}, - given_password - ) do - cond do - Timex.compare(Timex.now(), assessment.close_at) >= 0 -> - assessment_with_questions_and_answers(assessment, cr) - - match?({:ok, _}, find_submission(cr, assessment)) -> - assessment_with_questions_and_answers(assessment, cr) - - given_password == nil -> - {:error, {:forbidden, "Missing Password."}} - - password == given_password -> - find_or_create_submission(cr, assessment) - assessment_with_questions_and_answers(assessment, cr) - - true -> - {:error, {:forbidden, "Invalid Password."}} - end - end - - def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}, password) - when is_ecto_id(id) do - role = cr.role - - assessment = - if role in @open_all_assessment_roles do - Assessment - |> where(id: ^id) - |> preload(:config) - |> Repo.one() - else - Assessment - |> where(id: ^id) - |> where(is_published: true) - |> preload(:config) - |> Repo.one() - end - - if assessment do - assessment_with_questions_and_answers(assessment, cr, password) - else - {:error, {:bad_request, "Assessment not found"}} - end - end - - def assessment_with_questions_and_answers( - assessment = %Assessment{id: id}, - course_reg = %CourseRegistration{role: role} - ) do - if Timex.compare(Timex.now(), assessment.open_at) >= 0 or role in @open_all_assessment_roles do - answer_query = - Answer - |> join(:inner, [a], s in assoc(a, :submission)) - |> where([_, s], s.student_id == ^course_reg.id) - - questions = - Question - |> where(assessment_id: ^id) - |> join(:left, [q], a in subquery(answer_query), on: q.id == a.question_id) - |> join(:left, [_, a], g in assoc(a, :grader)) - |> join(:left, [_, _, g], u in assoc(g, :user)) - |> select([q, a, g, u], {q, a, g, u}) - |> order_by(:display_order) - |> Repo.all() - |> Enum.map(fn - {q, nil, _, _} -> %{q | answer: %Answer{grader: nil}} - {q, a, nil, _} -> %{q | answer: %Answer{a | grader: nil}} - {q, a, g, u} -> %{q | answer: %Answer{a | grader: %CourseRegistration{g | user: u}}} - end) - |> load_contest_voting_entries(course_reg, assessment) - - assessment = assessment |> Map.put(:questions, questions) - {:ok, assessment} - else - {:error, {:unauthorized, "Assessment not open"}} - end - end - - def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}) do - assessment_with_questions_and_answers(id, cr, nil) - end - - @doc """ - Returns a list of assessments with all fields and an indicator showing whether it has been attempted - by the supplied user - """ - def all_assessments(cr = %CourseRegistration{}) do - submission_aggregates = - Submission - |> join(:left, [s], ans in Answer, on: ans.submission_id == s.id) - |> where([s], s.student_id == ^cr.id) - |> group_by([s], s.assessment_id) - |> select([s, ans], %{ - assessment_id: s.assessment_id, - # s.xp_bonus should be the same across the group, but we need an aggregate function here - xp: fragment("? + ? + ?", sum(ans.xp), sum(ans.xp_adjustment), max(s.xp_bonus)), - graded_count: ans.id |> count() |> filter(not is_nil(ans.grader_id)) - }) - - submission_status = - Submission - |> where([s], s.student_id == ^cr.id) - |> select([s], [:assessment_id, :status]) - - assessments = - cr.course_id - |> Query.all_assessments_with_aggregates() - |> subquery() - |> join( - :left, - [a], - sa in subquery(submission_aggregates), - on: a.id == sa.assessment_id - ) - |> join(:left, [a, _], s in subquery(submission_status), on: a.id == s.assessment_id) - |> select([a, sa, s], %{ - a - | xp: sa.xp, - graded_count: sa.graded_count, - user_status: s.status - }) - |> filter_published_assessments(cr) - |> order_by(:open_at) - |> preload(:config) - |> Repo.all() - - {:ok, assessments} - end - - def filter_published_assessments(assessments, cr) do - role = cr.role - - case role do - :student -> where(assessments, is_published: true) - _ -> assessments - end - end - - def create_assessment(params) do - %Assessment{} - |> Assessment.changeset(params) - |> Repo.insert() - end - - @doc """ - The main function that inserts or updates assessments from the XML Parser - """ - @spec insert_or_update_assessments_and_questions(map(), [map()], boolean()) :: - {:ok, any()} - | {:error, Ecto.Multi.name(), any(), %{optional(Ecto.Multi.name()) => any()}} - def insert_or_update_assessments_and_questions( - assessment_params, - questions_params, - force_update - ) do - assessment_multi = - Multi.insert_or_update( - Multi.new(), - :assessment, - insert_or_update_assessment_changeset(assessment_params, force_update) - ) - - if force_update and invalid_force_update(assessment_multi, questions_params) do - {:error, "Question count is different"} - else - questions_params - |> Enum.with_index(1) - |> Enum.reduce(assessment_multi, fn {question_params, index}, multi -> - Multi.run(multi, "question#{index}", fn _repo, %{assessment: %Assessment{id: id}} -> - question = - Question - |> where([q], q.display_order == ^index and q.assessment_id == ^id) - |> Repo.one() - - # the is_nil(question) check allows for force updating of brand new assessments - if !force_update or is_nil(question) do - {status, new_question} = - question_params - |> Map.put(:display_order, index) - |> build_question_changeset_for_assessment_id(id) - |> Repo.insert() - - if status == :ok and new_question.type == :voting do - insert_voting( - assessment_params.course_id, - question_params.question.contest_number, - new_question.id - ) - else - {status, new_question} - end - else - params = - question_params - |> Map.put_new(:max_xp, 0) - |> Map.put(:display_order, index) - - if question_params.type != Atom.to_string(question.type) do - {:error, - create_invalid_changeset_with_error( - :question, - "Question types should remain the same" - )} - else - question - |> Question.changeset(params) - |> Repo.update() - end - end - end) - end) - |> Repo.transaction() - end - end - - # Function that checks if the force update is invalid. The force update is only invalid - # if the new question count is different from the old question count. - defp invalid_force_update(assessment_multi, questions_params) do - assessment_id = - (assessment_multi.operations - |> List.first() - |> elem(1) - |> elem(1)).data.id - - if assessment_id do - open_date = Repo.get(Assessment, assessment_id).open_at - # check if assessment is already opened - if Timex.compare(open_date, Timex.now()) >= 0 do - false - else - existing_questions_count = - Question - |> where([q], q.assessment_id == ^assessment_id) - |> Repo.all() - |> Enum.count() - - new_questions_count = Enum.count(questions_params) - existing_questions_count != new_questions_count - end - else - false - end - end - - @spec insert_or_update_assessment_changeset(map(), boolean()) :: Ecto.Changeset.t() - defp insert_or_update_assessment_changeset( - params = %{number: number, course_id: course_id}, - force_update - ) do - Assessment - |> where(number: ^number) - |> where(course_id: ^course_id) - |> Repo.one() - |> case do - nil -> - Assessment.changeset(%Assessment{}, params) - - %{id: assessment_id} = assessment -> - answers_exist = - Answer - |> join(:inner, [a], q in assoc(a, :question)) - |> join(:inner, [a, q], asst in assoc(q, :assessment)) - |> where([a, q, asst], asst.id == ^assessment_id) - |> Repo.exists?() - - # Maintain the same open/close date when updating an assessment - params = - params - |> Map.delete(:open_at) - |> Map.delete(:close_at) - |> Map.delete(:is_published) - - cond do - not answers_exist -> - # Delete all realted submission_votes - SubmissionVotes - |> join(:inner, [sv, q], q in assoc(sv, :question)) - |> where([sv, q], q.assessment_id == ^assessment_id) - |> Repo.delete_all() - - # Delete all existing questions - Question - |> where(assessment_id: ^assessment_id) - |> Repo.delete_all() - - Assessment.changeset(assessment, params) - - force_update -> - Assessment.changeset(assessment, params) - - true -> - # if the assessment has submissions, don't edit - create_invalid_changeset_with_error(:assessment, "has submissions") - end - end - end - - @spec build_question_changeset_for_assessment_id(map(), number() | String.t()) :: - Ecto.Changeset.t() - defp build_question_changeset_for_assessment_id(params, assessment_id) - when is_ecto_id(assessment_id) do - params_with_assessment_id = Map.put_new(params, :assessment_id, assessment_id) - - Question.changeset(%Question{}, params_with_assessment_id) - end - - @doc """ - Generates and assigns contest entries for users with given usernames. - """ - def insert_voting( - course_id, - contest_number, - question_id - ) do - contest_assessment = Repo.get_by(Assessment, number: contest_number, course_id: course_id) - - if is_nil(contest_assessment) do - changeset = change(%Assessment{}, %{number: ""}) - - error_changeset = - Ecto.Changeset.add_error( - changeset, - :number, - "invalid contest number" - ) - - {:error, error_changeset} - else - # Returns contest submission ids with answers that contain "return" - contest_submission_ids = - Submission - |> join(:inner, [s], ans in assoc(s, :answers)) - |> join(:inner, [s, ans], cr in assoc(s, :student)) - |> where([s, ans, cr], cr.role == "student") - |> where([s, _], s.assessment_id == ^contest_assessment.id and s.status == "submitted") - |> where( - [_, ans, cr], - fragment( - "?->>'code' like ?", - ans.answer, - "%return%" - ) - ) - |> select([s, _ans], {s.student_id, s.id}) - |> Repo.all() - |> Enum.into(%{}) - - contest_submission_ids_length = Enum.count(contest_submission_ids) - - voter_ids = - CourseRegistration - |> where(role: "student", course_id: ^course_id) - |> select([cr], cr.id) - |> Repo.all() - - votes_per_user = min(contest_submission_ids_length, 10) - - votes_per_submission = - if Enum.empty?(contest_submission_ids) do - 0 - else - trunc(Float.ceil(votes_per_user * length(voter_ids) / contest_submission_ids_length)) - end - - submission_id_list = - contest_submission_ids - |> Enum.map(fn {_, s_id} -> s_id end) - |> Enum.shuffle() - |> List.duplicate(votes_per_submission) - |> List.flatten() - - {_submission_map, submission_votes_changesets} = - voter_ids - |> Enum.reduce({submission_id_list, []}, fn voter_id, acc -> - {submission_list, submission_votes} = acc - - user_contest_submission_id = Map.get(contest_submission_ids, voter_id) - - {votes, rest} = - submission_list - |> Enum.reduce_while({MapSet.new(), submission_list}, fn s_id, acc -> - {user_votes, submissions} = acc - - max_votes = - if votes_per_user == contest_submission_ids_length and - not is_nil(user_contest_submission_id) do - # no. of submssions is less than 10. Unable to find - votes_per_user - 1 - else - votes_per_user - end - - if MapSet.size(user_votes) < max_votes do - if s_id != user_contest_submission_id and not MapSet.member?(user_votes, s_id) do - new_user_votes = MapSet.put(user_votes, s_id) - new_submissions = List.delete(submissions, s_id) - {:cont, {new_user_votes, new_submissions}} - else - {:cont, {user_votes, submissions}} - end - else - {:halt, acc} - end - end) - - votes = MapSet.to_list(votes) - - new_submission_votes = - votes - |> Enum.map(fn s_id -> - %SubmissionVotes{voter_id: voter_id, submission_id: s_id, question_id: question_id} - end) - |> Enum.concat(submission_votes) - - {rest, new_submission_votes} - end) - - submission_votes_changesets - |> Enum.with_index() - |> Enum.reduce(Multi.new(), fn {changeset, index}, multi -> - Multi.insert(multi, Integer.to_string(index), changeset) - end) - |> Repo.transaction() - end - end - - def update_assessment(id, params) when is_ecto_id(id) do - IO.inspect(params) - simple_update( - Assessment, - id, - using: &Assessment.changeset/2, - params: params - ) - end - - def update_question(id, params) when is_ecto_id(id) do - simple_update( - Question, - id, - using: &Question.changeset/2, - params: params - ) - end - - def publish_assessment(id) when is_ecto_id(id) do - update_assessment(id, %{is_published: true}) - end - - def create_question_for_assessment(params, assessment_id) when is_ecto_id(assessment_id) do - assessment = - Assessment - |> where(id: ^assessment_id) - |> join(:left, [a], q in assoc(a, :questions)) - |> preload([_, q], questions: q) - |> Repo.one() - - if assessment do - params_with_assessment_id = Map.put_new(params, :assessment_id, assessment.id) - - %Question{} - |> Question.changeset(params_with_assessment_id) - |> put_display_order(assessment.questions) - |> Repo.insert() - else - {:error, "Assessment not found"} - end - end - - def get_question(id) when is_ecto_id(id) do - Question - |> where(id: ^id) - |> join(:inner, [q], assessment in assoc(q, :assessment)) - |> preload([_, a], assessment: a) - |> Repo.one() - end - - def delete_question(id) when is_ecto_id(id) do - question = Repo.get(Question, id) - Repo.delete(question) - end - - @doc """ - Public internal api to submit new answers for a question. Possible return values are: - `{:ok, nil}` -> success - `{:error, error}` -> failed. `error` is in the format of `{http_response_code, error message}` - - Note: In the event of `find_or_create_submission` failing due to a race condition, error will be: - `{:bad_request, "Missing or invalid parameter(s)"}` - - """ - def answer_question( - question = %Question{}, - cr = %CourseRegistration{id: cr_id}, - raw_answer, - force_submit - ) do - with {:ok, submission} <- find_or_create_submission(cr, question.assessment), - {:status, true} <- {:status, force_submit or submission.status != :submitted}, - {:ok, _answer} <- insert_or_update_answer(submission, question, raw_answer, cr_id) do - update_submission_status_router(submission, question) - - {:ok, nil} - else - {:status, _} -> - {:error, {:forbidden, "Assessment submission already finalised"}} - - {:error, :race_condition} -> - {:error, {:internal_server_error, "Please try again later."}} - - {:error, :invalid_vote} -> - {:error, {:bad_request, "Invalid vote! Vote is not saved."}} - - _ -> - {:error, {:bad_request, "Missing or invalid parameter(s)"}} - end - end - - def get_submission(assessment_id, %CourseRegistration{id: cr_id}) - when is_ecto_id(assessment_id) do - Submission - |> where(assessment_id: ^assessment_id) - |> where(student_id: ^cr_id) - |> join(:inner, [s], a in assoc(s, :assessment)) - |> preload([_, a], assessment: a) - |> Repo.one() - end - - def get_submission_by_id(submission_id) when is_ecto_id(submission_id) do - Submission - |> where(id: ^submission_id) - |> join(:inner, [s], a in assoc(s, :assessment)) - |> preload([_, a], assessment: a) - |> Repo.one() - end - - def finalise_submission(submission = %Submission{}) do - with {:status, :attempted} <- {:status, submission.status}, - {:ok, updated_submission} <- update_submission_status_and_xp_bonus(submission) do - # Couple with update_submission_status_and_xp_bonus to ensure notification is sent - Notifications.write_notification_when_student_submits(submission) - # Send email notification to avenger - %{notification_type: "assessment_submission", submission_id: updated_submission.id} - |> Cadet.Workers.NotificationWorker.new() - |> Oban.insert() - - # Begin autograding job - GradingJob.force_grade_individual_submission(updated_submission) - - {:ok, nil} - else - {:status, :attempting} -> - {:error, {:bad_request, "Some questions have not been attempted"}} - - {:status, :submitted} -> - {:error, {:forbidden, "Assessment has already been submitted"}} - - _ -> - {:error, {:internal_server_error, "Please try again later."}} - end - end - - def unsubmit_submission( - submission_id, - cr = %CourseRegistration{id: course_reg_id, role: role} - ) - when is_ecto_id(submission_id) do - submission = - Submission - |> join(:inner, [s], a in assoc(s, :assessment)) - |> preload([_, a], assessment: a) - |> Repo.get(submission_id) - - # allows staff to unsubmit own assessment - bypass = role in @bypass_closed_roles and submission.student_id == course_reg_id - - with {:submission_found?, true} <- {:submission_found?, is_map(submission)}, - {:is_open?, true} <- {:is_open?, bypass or is_open?(submission.assessment)}, - {:status, :submitted} <- {:status, submission.status}, - {:allowed_to_unsubmit?, true} <- - {:allowed_to_unsubmit?, - role == :admin or bypass or - Cadet.Accounts.Query.avenger_of?(cr, submission.student_id)} do - Multi.new() - |> Multi.run( - :rollback_submission, - fn _repo, _ -> - submission - |> Submission.changeset(%{ - status: :attempted, - xp_bonus: 0, - unsubmitted_by_id: course_reg_id, - unsubmitted_at: Timex.now() - }) - |> Repo.update() - end - ) - |> Multi.run(:rollback_answers, fn _repo, _ -> - Answer - |> join(:inner, [a], q in assoc(a, :question)) - |> join(:inner, [a, _], s in assoc(a, :submission)) - |> preload([_, q, s], question: q, submission: s) - |> where(submission_id: ^submission.id) - |> Repo.all() - |> Enum.reduce_while({:ok, nil}, fn answer, acc -> - case acc do - {:error, _} -> - {:halt, acc} - - {:ok, _} -> - {:cont, - answer - |> Answer.grading_changeset(%{ - xp: 0, - xp_adjustment: 0, - autograding_status: :none, - autograding_results: [] - }) - |> Repo.update()} - end - end) - end) - |> Repo.transaction() - - Cadet.Accounts.Notifications.handle_unsubmit_notifications( - submission.assessment.id, - Repo.get(CourseRegistration, submission.student_id) - ) - - {:ok, nil} - else - {:submission_found?, false} -> - {:error, {:not_found, "Submission not found"}} - - {:is_open?, false} -> - {:error, {:forbidden, "Assessment not open"}} - - {:status, :attempting} -> - {:error, {:bad_request, "Some questions have not been attempted"}} - - {:status, :attempted} -> - {:error, {:bad_request, "Assessment has not been submitted"}} - - {:allowed_to_unsubmit?, false} -> - {:error, {:forbidden, "Only Avenger of student or Admin is permitted to unsubmit"}} - - _ -> - {:error, {:internal_server_error, "Please try again later."}} - end - end - - @spec update_submission_status_and_xp_bonus(Submission.t()) :: - {:ok, Submission.t()} | {:error, Ecto.Changeset.t()} - defp update_submission_status_and_xp_bonus(submission = %Submission{}) do - assessment = submission.assessment - assessment_conifg = Repo.get_by(AssessmentConfig, id: assessment.config_id) - - max_bonus_xp = assessment_conifg.early_submission_xp - early_hours = assessment_conifg.hours_before_early_xp_decay - - xp_bonus = - if Timex.before?(Timex.now(), Timex.shift(assessment.open_at, hours: early_hours)) do - max_bonus_xp - else - # This logic interpolates from max bonus at early hour to 0 bonus at close time - decaying_hours = Timex.diff(assessment.close_at, assessment.open_at, :hours) - early_hours - remaining_hours = Enum.max([0, Timex.diff(assessment.close_at, Timex.now(), :hours)]) - proportion = if(decaying_hours > 0, do: remaining_hours / decaying_hours, else: 1) - bonus_xp = round(max_bonus_xp * proportion) - Enum.max([0, bonus_xp]) - end - - submission - |> Submission.changeset(%{status: :submitted, xp_bonus: xp_bonus}) - |> Repo.update() - end - - defp update_submission_status_router(submission = %Submission{}, question = %Question{}) do - case question.type do - :voting -> update_contest_voting_submission_status(submission, question) - :mcq -> update_submission_status(submission, question.assessment) - :programming -> update_submission_status(submission, question.assessment) - end - end - - defp update_submission_status(submission = %Submission{}, assessment = %Assessment{}) do - model_assoc_count = fn model, assoc, id -> - model - |> where(id: ^id) - |> join(:inner, [m], a in assoc(m, ^assoc)) - |> select([_, a], count(a.id)) - |> Repo.one() - end - - Multi.new() - |> Multi.run(:assessment, fn _repo, _ -> - {:ok, model_assoc_count.(Assessment, :questions, assessment.id)} - end) - |> Multi.run(:submission, fn _repo, _ -> - {:ok, model_assoc_count.(Submission, :answers, submission.id)} - end) - |> Multi.run(:update, fn _repo, %{submission: s_count, assessment: a_count} -> - if s_count == a_count do - submission |> Submission.changeset(%{status: :attempted}) |> Repo.update() - else - {:ok, nil} - end - end) - |> Repo.transaction() - end - - defp update_contest_voting_submission_status(submission = %Submission{}, question = %Question{}) do - has_nil_entries = - SubmissionVotes - |> where(question_id: ^question.id) - |> where(voter_id: ^submission.student_id) - |> where([sv], is_nil(sv.score)) - |> Repo.exists?() - - unless has_nil_entries do - submission |> Submission.changeset(%{status: :attempted}) |> Repo.update() - end - end - - defp load_contest_voting_entries( - questions, - %CourseRegistration{role: role, course_id: course_id, id: voter_id}, - assessment - ) do - Enum.map( - questions, - fn q -> - if q.type == :voting do - submission_votes = all_submission_votes_by_question_id_and_voter_id(q.id, voter_id) - # fetch top 10 contest voting entries with the contest question id - question_id = fetch_associated_contest_question_id(course_id, q) - - leaderboard_results = - if is_nil(question_id) do - [] - else - if leaderboard_open?(assessment, q) or role in @open_all_assessment_roles do - fetch_top_relative_score_answers(question_id, 10) - else - [] - end - end - - # populate entries to vote for and leaderboard data into the question - voting_question = - q.question - |> Map.put(:contest_entries, submission_votes) - |> Map.put( - :contest_leaderboard, - leaderboard_results - ) - - Map.put(q, :question, voting_question) - else - q - end - end - ) - end - - defp all_submission_votes_by_question_id_and_voter_id(question_id, voter_id) do - SubmissionVotes - |> where([v], v.voter_id == ^voter_id and v.question_id == ^question_id) - |> join(:inner, [v], s in assoc(v, :submission)) - |> join(:inner, [v, s], a in assoc(s, :answers)) - |> select([v, s, a], %{submission_id: v.submission_id, answer: a.answer, score: v.score}) - |> Repo.all() - end - - # Finds the contest_question_id associated with the given voting_question id - defp fetch_associated_contest_question_id(course_id, voting_question) do - contest_number = voting_question.question["contest_number"] - - if is_nil(contest_number) do - nil - else - Assessment - |> where(number: ^contest_number, course_id: ^course_id) - |> join(:inner, [a], q in assoc(a, :questions)) - |> order_by([a, q], q.display_order) - |> select([a, q], q.id) - |> Repo.one() - end - end - - defp leaderboard_open?(assessment, voting_question) do - Timex.before?( - Timex.shift(assessment.close_at, hours: voting_question.question["reveal_hours"]), - Timex.now() - ) - end - - @doc """ - Fetches top answers for the given question, based on the contest relative_score - - Used for contest leaderboard fetching - """ - def fetch_top_relative_score_answers(question_id, number_of_answers) do - Answer - |> where(question_id: ^question_id) - |> where( - [a], - fragment( - "?->>'code' like ?", - a.answer, - "%return%" - ) - ) - |> order_by(desc: :relative_score) - |> join(:left, [a], s in assoc(a, :submission)) - |> join(:left, [a, s], student in assoc(s, :student)) - |> join(:inner, [a, s, student], student_user in assoc(student, :user)) - |> where([a, s, student], student.role == "student") - |> select([a, s, student, student_user], %{ - submission_id: a.submission_id, - answer: a.answer, - relative_score: a.relative_score, - student_name: student_user.name - }) - |> limit(^number_of_answers) - |> Repo.all() - end - - @doc """ - Computes rolling leaderboard for contest votes that are still open. - """ - def update_rolling_contest_leaderboards do - # 115 = 2 hours - 5 minutes is default. - if Log.log_execution("update_rolling_contest_leaderboards", Timex.Duration.from_minutes(115)) do - Logger.info("Started update_rolling_contest_leaderboards") - - voting_questions_to_update = fetch_active_voting_questions() - - _ = - voting_questions_to_update - |> Enum.map(fn qn -> compute_relative_score(qn.id) end) - - Logger.info("Successfully update_rolling_contest_leaderboards") - end - end - - def fetch_active_voting_questions do - Question - |> join(:left, [q], a in assoc(q, :assessment)) - |> where([q, a], q.type == "voting") - |> where([q, a], a.is_published) - |> where([q, a], a.open_at <= ^Timex.now() and a.close_at >= ^Timex.now()) - |> Repo.all() - end - - @doc """ - Computes final leaderboard for contest votes that have closed. - """ - def update_final_contest_leaderboards do - # 1435 = 24 hours - 5 minutes - if Log.log_execution("update_final_contest_leaderboards", Timex.Duration.from_minutes(1435)) do - Logger.info("Started update_final_contest_leaderboards") - - voting_questions_to_update = fetch_voting_questions_due_yesterday() - - _ = - voting_questions_to_update - |> Enum.map(fn qn -> compute_relative_score(qn.id) end) - - Logger.info("Successfully update_final_contest_leaderboards") - end - end - - def fetch_voting_questions_due_yesterday do - Question - |> join(:left, [q], a in assoc(q, :assessment)) - |> where([q, a], q.type == "voting") - |> where([q, a], a.is_published) - |> where([q, a], a.open_at <= ^Timex.now()) - |> where( - [q, a], - a.close_at < ^Timex.now() and a.close_at >= ^Timex.shift(Timex.now(), days: -1) - ) - |> Repo.all() - end - - @doc """ - Computes the current relative_score of each voting submission answer - based on current submitted votes. - """ - def compute_relative_score(contest_voting_question_id) do - # query all records from submission votes tied to the question id -> - # map score to user id -> - # store as grade -> - # query grade for contest question id. - eligible_votes = - SubmissionVotes - |> where(question_id: ^contest_voting_question_id) - |> where([sv], not is_nil(sv.score)) - |> join(:inner, [sv], ans in Answer, on: sv.submission_id == ans.submission_id) - |> select( - [sv, ans], - %{ans_id: ans.id, score: sv.score, ans: ans.answer["code"]} - ) - |> Repo.all() - - entry_scores = map_eligible_votes_to_entry_score(eligible_votes) - - entry_scores - |> Enum.map(fn {ans_id, relative_score} -> - %Answer{id: ans_id} - |> Answer.contest_score_update_changeset(%{ - relative_score: relative_score - }) - end) - |> Enum.map(fn changeset -> - op_key = "answer_#{changeset.data.id}" - Multi.update(Multi.new(), op_key, changeset) - end) - |> Enum.reduce(Multi.new(), &Multi.append/2) - |> Repo.transaction() - end - - defp map_eligible_votes_to_entry_score(eligible_votes) do - # converts eligible votes to the {total cumulative score, number of votes, tokens} - entry_vote_data = - Enum.reduce(eligible_votes, %{}, fn %{ans_id: ans_id, score: score, ans: ans}, tracker -> - {prev_score, prev_count, _ans_tokens} = Map.get(tracker, ans_id, {0, 0, 0}) - - Map.put( - tracker, - ans_id, - # assume each voter is assigned 10 entries which will make it fair. - {prev_score + score, prev_count + 1, Lexer.count_tokens(ans)} - ) - end) - - # calculate the score based on formula {ans_id, score} - Enum.map( - entry_vote_data, - fn {ans_id, {sum_of_scores, number_of_voters, tokens}} -> - {ans_id, calculate_formula_score(sum_of_scores, number_of_voters, tokens)} - end - ) - end - - # Calculate the score based on formula - # score(v,t) = v - 2^(t/50) where v is the normalized_voting_score - # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 - defp calculate_formula_score(sum_of_scores, number_of_voters, tokens) do - normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 - normalized_voting_score - :math.pow(2, min(1023.5, tokens / 50)) - end - - @doc """ - Function returning submissions under a grader. This function returns only the - fields that are exposed in the /grading endpoint. The reason we select only - those fields is to reduce the memory usage especially when the number of - submissions is large i.e. > 25000 submissions. - - The input parameters are the user and group_only. group_only is used to check - whether only the groups under the grader should be returned. The parameter is - a boolean which is false by default. - - The return value is {:ok, submissions} if no errors, else it is {:error, - {:unauthorized, "Forbidden."}} - """ - @spec all_submissions_by_grader_for_index(CourseRegistration.t()) :: - {:ok, String.t()} - def all_submissions_by_grader_for_index( - grader = %CourseRegistration{course_id: course_id}, - group_only \\ false, - ungraded_only \\ false - ) do - show_all = not group_only - - group_where = - if show_all, - do: "", - else: - "where s.student_id in (select cr.id from course_registrations cr inner join groups g on cr.group_id = g.id where g.leader_id = $2) or s.student_id = $2" - - ungraded_where = - if ungraded_only, - do: "where s.\"gradedCount\" < assts.\"questionCount\"", - else: "" - - params = if show_all, do: [course_id], else: [course_id, grader.id] - - # We bypass Ecto here and use a raw query to generate JSON directly from - # PostgreSQL, because doing it in Elixir/Erlang is too inefficient. - - case Repo.query( - """ - select json_agg(q)::TEXT from - ( - select - s.id, - s.status, - s."unsubmittedAt", - s.xp, - s."xpAdjustment", - s."xpBonus", - s."gradedCount", - assts.jsn as assessment, - students.jsn as student, - unsubmitters.jsn as "unsubmittedBy" - from - (select - s.id, - s.student_id, - s.assessment_id, - s.status, - s.unsubmitted_at as "unsubmittedAt", - s.unsubmitted_by_id, - sum(ans.xp) as xp, - sum(ans.xp_adjustment) as "xpAdjustment", - s.xp_bonus as "xpBonus", - count(ans.id) filter (where ans.grader_id is not null) as "gradedCount" - from submissions s - left join - answers ans on s.id = ans.submission_id - #{group_where} - group by s.id) s - inner join - (select - a.id, a."questionCount", to_json(a) as jsn - from - (select - a.id, - a.title, - bool_or(ac.is_manually_graded) as "isManuallyGraded", - max(ac.type) as "type", - sum(q.max_xp) as "maxXp", - count(q.id) as "questionCount" - from assessments a - left join - questions q on a.id = q.assessment_id - inner join - assessment_configs ac on ac.id = a.config_id - where a.course_id = $1 - group by a.id) a) assts on assts.id = s.assessment_id - inner join - (select - cr.id, to_json(cr) as jsn - from - (select - cr.id, - u.name as "name", - g.name as "groupName", - g.leader_id as "groupLeaderId" - from course_registrations cr - left join - groups g on g.id = cr.group_id - inner join - users u on u.id = cr.user_id) cr) students on students.id = s.student_id - left join - (select - cr.id, to_json(cr) as jsn - from - (select - cr.id, - u.name - from course_registrations cr - inner join - users u on u.id = cr.user_id) cr) unsubmitters on s.unsubmitted_by_id = unsubmitters.id - #{ungraded_where} - ) q - """, - params - ) do - {:ok, %{rows: [[nil]]}} -> {:ok, "[]"} - {:ok, %{rows: [[json]]}} -> {:ok, json} - end - end - - @spec get_answers_in_submission(integer() | String.t()) :: - {:ok, [Answer.t()]} | {:error, {:bad_request | :unauthorized, String.t()}} - def get_answers_in_submission(id) when is_ecto_id(id) do - answer_query = - Answer - |> where(submission_id: ^id) - |> join(:inner, [a], q in assoc(a, :question)) - |> join(:inner, [_, q], ast in assoc(q, :assessment)) - |> join(:inner, [a, ..., ast], ac in assoc(ast, :config)) - |> join(:left, [a, ...], g in assoc(a, :grader)) - |> join(:left, [a, ..., g], gu in assoc(g, :user)) - |> join(:inner, [a, ...], s in assoc(a, :submission)) - |> join(:inner, [a, ..., s], st in assoc(s, :student)) - |> join(:inner, [a, ..., st], u in assoc(st, :user)) - |> preload([_, q, ast, ac, g, gu, s, st, u], - question: {q, assessment: {ast, config: ac}}, - grader: {g, user: gu}, - submission: {s, student: {st, user: u}} - ) - - answers = - answer_query - |> Repo.all() - |> Enum.sort_by(& &1.question.display_order) - |> Enum.map(fn ans -> - if ans.question.type == :voting do - empty_contest_entries = Map.put(ans.question.question, :contest_entries, []) - empty_contest_leaderboard = Map.put(empty_contest_entries, :contest_leaderboard, []) - question = Map.put(ans.question, :question, empty_contest_leaderboard) - Map.put(ans, :question, question) - else - ans - end - end) - - if answers == [] do - {:error, {:bad_request, "Submission is not found."}} - else - {:ok, answers} - end - end - - defp is_fully_graded?(%Answer{submission_id: submission_id}) do - submission = - Submission - |> Repo.get_by(id: submission_id) - - question_count = - Question - |> where(assessment_id: ^submission.assessment_id) - |> select([q], count(q.id)) - |> Repo.one() - - graded_count = - Answer - |> where([a], submission_id: ^submission_id) - |> where([a], not is_nil(a.grader_id)) - |> select([a], count(a.id)) - |> Repo.one() - - question_count == graded_count - end - - @spec update_grading_info( - %{submission_id: integer() | String.t(), question_id: integer() | String.t()}, - %{}, - CourseRegistration.t() - ) :: - {:ok, nil} - | {:error, {:unauthorized | :bad_request | :internal_server_error, String.t()}} - def update_grading_info( - %{submission_id: submission_id, question_id: question_id}, - attrs, - %CourseRegistration{id: grader_id} - ) - when is_ecto_id(submission_id) and is_ecto_id(question_id) do - attrs = Map.put(attrs, "grader_id", grader_id) - - answer_query = - Answer - |> where(submission_id: ^submission_id) - |> where(question_id: ^question_id) - - answer_query = - answer_query - |> join(:inner, [a], s in assoc(a, :submission)) - |> preload([_, s], submission: s) - - answer = Repo.one(answer_query) - - is_own_submission = grader_id == answer.submission.student_id - - with {:answer_found?, true} <- {:answer_found?, is_map(answer)}, - {:status, true} <- - {:status, answer.submission.status == :submitted or is_own_submission}, - {:valid, changeset = %Ecto.Changeset{valid?: true}} <- - {:valid, Answer.grading_changeset(answer, attrs)}, - {:ok, _} <- Repo.update(changeset) do - if is_fully_graded?(answer) and not is_own_submission do - # Every answer in this submission has been graded manually - Notifications.write_notification_when_graded(submission_id, :graded) - else - {:ok, nil} - end - else - {:answer_found?, false} -> - {:error, {:bad_request, "Answer not found or user not permitted to grade."}} - - {:valid, changeset} -> - {:error, {:bad_request, full_error_messages(changeset)}} - - {:status, _} -> - {:error, {:method_not_allowed, "Submission is not submitted yet."}} - - {:error, _} -> - {:error, {:internal_server_error, "Please try again later."}} - end - end - - def update_grading_info( - _, - _, - _ - ) do - {:error, {:unauthorized, "User is not permitted to grade."}} - end - - @spec force_regrade_submission(integer() | String.t(), CourseRegistration.t()) :: - {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}} - def force_regrade_submission( - submission_id, - _requesting_user = %CourseRegistration{id: grader_id} - ) - when is_ecto_id(submission_id) do - with {:get, sub} when not is_nil(sub) <- {:get, Repo.get(Submission, submission_id)}, - {:status, true} <- {:status, sub.student_id == grader_id or sub.status == :submitted} do - GradingJob.force_grade_individual_submission(sub, true) - {:ok, nil} - else - {:get, nil} -> - {:error, {:not_found, "Submission not found"}} - - {:status, false} -> - {:error, {:bad_request, "Submission not submitted yet"}} - end - end - - def force_regrade_submission(_, _) do - {:error, {:forbidden, "User is not permitted to grade."}} - end - - @spec force_regrade_answer( - integer() | String.t(), - integer() | String.t(), - CourseRegistration.t() - ) :: - {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}} - def force_regrade_answer( - submission_id, - question_id, - _requesting_user = %CourseRegistration{id: grader_id} - ) - when is_ecto_id(submission_id) and is_ecto_id(question_id) do - answer = - Answer - |> where(submission_id: ^submission_id, question_id: ^question_id) - |> preload([:question, :submission]) - |> Repo.one() - - with {:get, answer} when not is_nil(answer) <- {:get, answer}, - {:status, true} <- - {:status, - answer.submission.student_id == grader_id or answer.submission.status == :submitted} do - GradingJob.grade_answer(answer, answer.question, true) - {:ok, nil} - else - {:get, nil} -> - {:error, {:not_found, "Answer not found"}} - - {:status, false} -> - {:error, {:bad_request, "Submission not submitted yet"}} - end - end - - def force_regrade_answer(_, _, _) do - {:error, {:forbidden, "User is not permitted to grade."}} - end - - defp find_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do - submission = - Submission - |> where(student_id: ^cr.id) - |> where(assessment_id: ^assessment.id) - |> Repo.one() - - if submission do - {:ok, submission} - else - {:error, nil} - end - end - - # Checks if an assessment is open and published. - @spec is_open?(Assessment.t()) :: boolean() - def is_open?(%Assessment{open_at: open_at, close_at: close_at, is_published: is_published}) do - Timex.between?(Timex.now(), open_at, close_at, inclusive: :start) and is_published - end - - @spec get_group_grading_summary(integer()) :: - {:ok, [String.t(), ...], []} - def get_group_grading_summary(course_id) do - subs = - Answer - |> join(:left, [ans], s in Submission, on: s.id == ans.submission_id) - |> join(:left, [ans, s], st in CourseRegistration, on: s.student_id == st.id) - |> join(:left, [ans, s, st], a in Assessment, on: a.id == s.assessment_id) - |> join(:inner, [ans, s, st, a], ac in AssessmentConfig, on: ac.id == a.config_id) - |> where( - [ans, s, st, a, ac], - not is_nil(st.group_id) and s.status == ^:submitted and - ac.show_grading_summary and a.course_id == ^course_id - ) - |> group_by([ans, s, st, a, ac], s.id) - |> select([ans, s, st, a, ac], %{ - group_id: max(st.group_id), - config_id: max(ac.id), - config_type: max(ac.type), - num_submitted: count(), - num_ungraded: filter(count(), is_nil(ans.grader_id)) - }) - - raw_data = - subs - |> subquery() - |> join(:left, [t], g in Group, on: t.group_id == g.id) - |> join(:left, [t, g], l in CourseRegistration, on: l.id == g.leader_id) - |> join(:left, [t, g, l], lu in User, on: lu.id == l.user_id) - |> group_by([t, g, l, lu], [t.group_id, t.config_id, t.config_type, g.name, lu.name]) - |> select([t, g, l, lu], %{ - group_name: g.name, - leader_name: lu.name, - config_id: t.config_id, - config_type: t.config_type, - ungraded: filter(count(), t.num_ungraded > 0), - submitted: count() - }) - |> Repo.all() - - showing_configs = - AssessmentConfig - |> where([ac], ac.course_id == ^course_id and ac.show_grading_summary) - |> order_by(:order) - |> group_by([ac], ac.id) - |> select([ac], %{ - id: ac.id, - type: ac.type - }) - |> Repo.all() - - data_by_groups = - raw_data - |> Enum.reduce(%{}, fn raw, acc -> - if Map.has_key?(acc, raw.group_name) do - acc - |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded) - |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted) - else - acc - |> put_in([raw.group_name], %{}) - |> put_in([raw.group_name, "groupName"], raw.group_name) - |> put_in([raw.group_name, "leaderName"], raw.leader_name) - |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded) - |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted) - end - end) - - headings = - showing_configs - |> Enum.reduce([], fn config, acc -> - acc ++ ["submitted" <> config.type, "ungraded" <> config.type] - end) - - default_row_data = - headings - |> Enum.reduce(%{}, fn heading, acc -> - put_in(acc, [heading], 0) - end) - - rows = data_by_groups |> Enum.map(fn {_k, row} -> Map.merge(default_row_data, row) end) - cols = ["groupName", "leaderName"] ++ headings - - {:ok, cols, rows} - end - - defp create_empty_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do - %Submission{} - |> Submission.changeset(%{student: cr, assessment: assessment}) - |> Repo.insert() - |> case do - {:ok, submission} -> {:ok, submission} - {:error, _} -> {:error, :race_condition} - end - end - - defp find_or_create_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do - case find_submission(cr, assessment) do - {:ok, submission} -> {:ok, submission} - {:error, _} -> create_empty_submission(cr, assessment) - end - end - - defp insert_or_update_answer( - submission = %Submission{}, - question = %Question{}, - raw_answer, - course_reg_id - ) do - answer_content = build_answer_content(raw_answer, question.type) - - if question.type == :voting do - insert_or_update_voting_answer(submission.id, course_reg_id, question.id, answer_content) - else - answer_changeset = - %Answer{} - |> Answer.changeset(%{ - answer: answer_content, - question_id: question.id, - submission_id: submission.id, - type: question.type - }) - - Repo.insert( - answer_changeset, - on_conflict: [set: [answer: get_change(answer_changeset, :answer)]], - conflict_target: [:submission_id, :question_id] - ) - end - end - - def insert_or_update_voting_answer(submission_id, course_reg_id, question_id, answer_content) do - set_score_to_nil = - SubmissionVotes - |> where(voter_id: ^course_reg_id, question_id: ^question_id) - - voting_multi = - Multi.new() - |> Multi.update_all(:set_score_to_nil, set_score_to_nil, set: [score: nil]) - - answer_content - |> Enum.with_index(1) - |> Enum.reduce(voting_multi, fn {entry, index}, multi -> - multi - |> Multi.run("update#{index}", fn _repo, _ -> - SubmissionVotes - |> Repo.get_by( - voter_id: course_reg_id, - submission_id: entry.submission_id - ) - |> SubmissionVotes.changeset(%{score: entry.score}) - |> Repo.insert_or_update() - end) - end) - |> Multi.run("insert into answer table", fn _repo, _ -> - Answer - |> Repo.get_by(submission_id: submission_id, question_id: question_id) - |> case do - nil -> - Repo.insert(%Answer{ - answer: %{completed: true}, - submission_id: submission_id, - question_id: question_id, - type: :voting - }) - - _ -> - {:ok, nil} - end - end) - |> Repo.transaction() - |> case do - {:ok, _result} -> {:ok, nil} - {:error, _name, _changeset, _error} -> {:error, :invalid_vote} - end - end - - defp build_answer_content(raw_answer, question_type) do - case question_type do - :mcq -> - %{choice_id: raw_answer} - - :programming -> - %{code: raw_answer} - - :voting -> - raw_answer - |> Enum.map(fn ans -> - for {key, value} <- ans, into: %{}, do: {String.to_existing_atom(key), value} - end) - end - end -end +defmodule Cadet.Assessments do + @moduledoc """ + Assessments context contains domain logic for assessments management such as + missions, sidequests, paths, etc. + """ + use Cadet, [:context, :display] + import Ecto.Query + + require Logger + + alias Cadet.Accounts.{ + Notification, + Notifications, + User, + CourseRegistration, + CourseRegistrations + } + + alias Cadet.Assessments.{Answer, Assessment, Query, Question, Submission, SubmissionVotes} + alias Cadet.Autograder.GradingJob + alias Cadet.Courses.{Group, AssessmentConfig} + alias Cadet.Jobs.Log + alias Cadet.ProgramAnalysis.Lexer + alias Ecto.Multi + alias Cadet.Incentives.Achievements + + require Decimal + + @open_all_assessment_roles ~w(staff admin)a + + # These roles can save and finalise answers for closed assessments and + # submitted answers + @bypass_closed_roles ~w(staff admin)a + + def delete_assessment(id) do + assessment = Repo.get(Assessment, id) + + Submission + |> where(assessment_id: ^id) + |> delete_submission_assocation(id) + + Question + |> where(assessment_id: ^id) + |> Repo.all() + |> Enum.each(fn q -> + delete_submission_votes_association(q) + end) + + Repo.delete(assessment) + end + + defp delete_submission_votes_association(question) do + SubmissionVotes + |> where(question_id: ^question.id) + |> Repo.delete_all() + end + + defp delete_submission_assocation(submissions, assessment_id) do + submissions + |> Repo.all() + |> Enum.each(fn submission -> + Answer + |> where(submission_id: ^submission.id) + |> Repo.delete_all() + end) + + Notification + |> where(assessment_id: ^assessment_id) + |> Repo.delete_all() + + Repo.delete_all(submissions) + end + + @spec user_max_xp(CourseRegistration.t()) :: integer() + def user_max_xp(%CourseRegistration{id: cr_id}) do + Submission + |> where(status: ^:submitted) + |> where(student_id: ^cr_id) + |> join( + :inner, + [s], + a in subquery(Query.all_assessments_with_max_xp()), + on: s.assessment_id == a.id + ) + |> select([_, a], sum(a.max_xp)) + |> Repo.one() + |> decimal_to_integer() + end + + def assessments_total_xp(%CourseRegistration{id: cr_id}) do + submission_xp = + Submission + |> where(student_id: ^cr_id) + |> join(:inner, [s], a in Answer, on: s.id == a.submission_id) + |> group_by([s], s.id) + |> select([s, a], %{ + # grouping by submission, so s.xp_bonus will be the same, but we need an + # aggregate function + total_xp: sum(a.xp) + sum(a.xp_adjustment) + max(s.xp_bonus) + }) + + total = + submission_xp + |> subquery + |> select([s], %{ + total_xp: sum(s.total_xp) + }) + |> Repo.one() + + # for {key, val} <- total, into: %{}, do: {key, decimal_to_integer(val)} + decimal_to_integer(total.total_xp) + end + + def user_total_xp(course_id, user_id, course_reg_id) do + user_course = CourseRegistrations.get_user_course(user_id, course_id) + + total_achievement_xp = Achievements.achievements_total_xp(course_id, course_reg_id) + total_assessment_xp = assessments_total_xp(user_course) + + total_achievement_xp + total_assessment_xp + end + + defp decimal_to_integer(decimal) do + if Decimal.is_decimal(decimal) do + Decimal.to_integer(decimal) + else + 0 + end + end + + def user_current_story(cr = %CourseRegistration{}) do + {:ok, %{result: story}} = + Multi.new() + |> Multi.run(:unattempted, fn _repo, _ -> + {:ok, get_user_story_by_type(cr, :unattempted)} + end) + |> Multi.run(:result, fn _repo, %{unattempted: unattempted_story} -> + if unattempted_story do + {:ok, %{play_story?: true, story: unattempted_story}} + else + {:ok, %{play_story?: false, story: get_user_story_by_type(cr, :attempted)}} + end + end) + |> Repo.transaction() + + story + end + + @spec get_user_story_by_type(CourseRegistration.t(), :unattempted | :attempted) :: + String.t() | nil + def get_user_story_by_type(%CourseRegistration{id: cr_id}, type) + when is_atom(type) do + filter_and_sort = fn query -> + case type do + :unattempted -> + query + |> where([_, s], is_nil(s.id)) + |> order_by([a], asc: a.open_at) + + :attempted -> + query |> order_by([a], desc: a.close_at) + end + end + + Assessment + |> where(is_published: true) + |> where([a], not is_nil(a.story)) + |> where([a], a.open_at <= from_now(0, "second") and a.close_at >= from_now(0, "second")) + |> join(:left, [a], s in Submission, on: s.assessment_id == a.id and s.student_id == ^cr_id) + |> filter_and_sort.() + |> order_by([a], a.config_id) + |> select([a], a.story) + |> first() + |> Repo.one() + end + + def assessment_with_questions_and_answers( + assessment = %Assessment{password: nil}, + cr = %CourseRegistration{}, + nil + ) do + assessment_with_questions_and_answers(assessment, cr) + end + + def assessment_with_questions_and_answers( + assessment = %Assessment{password: nil}, + cr = %CourseRegistration{}, + _ + ) do + assessment_with_questions_and_answers(assessment, cr) + end + + def assessment_with_questions_and_answers( + assessment = %Assessment{password: password}, + cr = %CourseRegistration{}, + given_password + ) do + cond do + Timex.compare(Timex.now(), assessment.close_at) >= 0 -> + assessment_with_questions_and_answers(assessment, cr) + + match?({:ok, _}, find_submission(cr, assessment)) -> + assessment_with_questions_and_answers(assessment, cr) + + given_password == nil -> + {:error, {:forbidden, "Missing Password."}} + + password == given_password -> + find_or_create_submission(cr, assessment) + assessment_with_questions_and_answers(assessment, cr) + + true -> + {:error, {:forbidden, "Invalid Password."}} + end + end + + def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}, password) + when is_ecto_id(id) do + role = cr.role + + assessment = + if role in @open_all_assessment_roles do + Assessment + |> where(id: ^id) + |> preload(:config) + |> Repo.one() + else + Assessment + |> where(id: ^id) + |> where(is_published: true) + |> preload(:config) + |> Repo.one() + end + + if assessment do + assessment_with_questions_and_answers(assessment, cr, password) + else + {:error, {:bad_request, "Assessment not found"}} + end + end + + def assessment_with_questions_and_answers( + assessment = %Assessment{id: id}, + course_reg = %CourseRegistration{role: role} + ) do + if Timex.compare(Timex.now(), assessment.open_at) >= 0 or role in @open_all_assessment_roles do + answer_query = + Answer + |> join(:inner, [a], s in assoc(a, :submission)) + |> where([_, s], s.student_id == ^course_reg.id) + + questions = + Question + |> where(assessment_id: ^id) + |> join(:left, [q], a in subquery(answer_query), on: q.id == a.question_id) + |> join(:left, [_, a], g in assoc(a, :grader)) + |> join(:left, [_, _, g], u in assoc(g, :user)) + |> select([q, a, g, u], {q, a, g, u}) + |> order_by(:display_order) + |> Repo.all() + |> Enum.map(fn + {q, nil, _, _} -> %{q | answer: %Answer{grader: nil}} + {q, a, nil, _} -> %{q | answer: %Answer{a | grader: nil}} + {q, a, g, u} -> %{q | answer: %Answer{a | grader: %CourseRegistration{g | user: u}}} + end) + |> load_contest_voting_entries(course_reg, assessment) + + assessment = assessment |> Map.put(:questions, questions) + {:ok, assessment} + else + {:error, {:unauthorized, "Assessment not open"}} + end + end + + def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}) do + assessment_with_questions_and_answers(id, cr, nil) + end + + @doc """ + Returns a list of assessments with all fields and an indicator showing whether it has been attempted + by the supplied user + """ + def all_assessments(cr = %CourseRegistration{}) do + submission_aggregates = + Submission + |> join(:left, [s], ans in Answer, on: ans.submission_id == s.id) + |> where([s], s.student_id == ^cr.id) + |> group_by([s], s.assessment_id) + |> select([s, ans], %{ + assessment_id: s.assessment_id, + # s.xp_bonus should be the same across the group, but we need an aggregate function here + xp: fragment("? + ? + ?", sum(ans.xp), sum(ans.xp_adjustment), max(s.xp_bonus)), + graded_count: ans.id |> count() |> filter(not is_nil(ans.grader_id)) + }) + + submission_status = + Submission + |> where([s], s.student_id == ^cr.id) + |> select([s], [:assessment_id, :status]) + + assessments = + cr.course_id + |> Query.all_assessments_with_aggregates() + |> subquery() + |> join( + :left, + [a], + sa in subquery(submission_aggregates), + on: a.id == sa.assessment_id + ) + |> join(:left, [a, _], s in subquery(submission_status), on: a.id == s.assessment_id) + |> select([a, sa, s], %{ + a + | xp: sa.xp, + graded_count: sa.graded_count, + user_status: s.status + }) + |> filter_published_assessments(cr) + |> order_by(:open_at) + |> preload(:config) + |> Repo.all() + + {:ok, assessments} + end + + def filter_published_assessments(assessments, cr) do + role = cr.role + + case role do + :student -> where(assessments, is_published: true) + _ -> assessments + end + end + + def create_assessment(params) do + %Assessment{} + |> Assessment.changeset(params) + |> Repo.insert() + end + + @doc """ + The main function that inserts or updates assessments from the XML Parser + """ + @spec insert_or_update_assessments_and_questions(map(), [map()], boolean()) :: + {:ok, any()} + | {:error, Ecto.Multi.name(), any(), %{optional(Ecto.Multi.name()) => any()}} + def insert_or_update_assessments_and_questions( + assessment_params, + questions_params, + force_update + ) do + assessment_multi = + Multi.insert_or_update( + Multi.new(), + :assessment, + insert_or_update_assessment_changeset(assessment_params, force_update) + ) + + if force_update and invalid_force_update(assessment_multi, questions_params) do + {:error, "Question count is different"} + else + questions_params + |> Enum.with_index(1) + |> Enum.reduce(assessment_multi, fn {question_params, index}, multi -> + Multi.run(multi, "question#{index}", fn _repo, %{assessment: %Assessment{id: id}} -> + question = + Question + |> where([q], q.display_order == ^index and q.assessment_id == ^id) + |> Repo.one() + + # the is_nil(question) check allows for force updating of brand new assessments + if !force_update or is_nil(question) do + {status, new_question} = + question_params + |> Map.put(:display_order, index) + |> build_question_changeset_for_assessment_id(id) + |> Repo.insert() + + if status == :ok and new_question.type == :voting do + insert_voting( + assessment_params.course_id, + question_params.question.contest_number, + new_question.id + ) + else + {status, new_question} + end + else + params = + question_params + |> Map.put_new(:max_xp, 0) + |> Map.put(:display_order, index) + + if question_params.type != Atom.to_string(question.type) do + {:error, + create_invalid_changeset_with_error( + :question, + "Question types should remain the same" + )} + else + question + |> Question.changeset(params) + |> Repo.update() + end + end + end) + end) + |> Repo.transaction() + end + end + + # Function that checks if the force update is invalid. The force update is only invalid + # if the new question count is different from the old question count. + defp invalid_force_update(assessment_multi, questions_params) do + assessment_id = + (assessment_multi.operations + |> List.first() + |> elem(1) + |> elem(1)).data.id + + if assessment_id do + open_date = Repo.get(Assessment, assessment_id).open_at + # check if assessment is already opened + if Timex.compare(open_date, Timex.now()) >= 0 do + false + else + existing_questions_count = + Question + |> where([q], q.assessment_id == ^assessment_id) + |> Repo.all() + |> Enum.count() + + new_questions_count = Enum.count(questions_params) + existing_questions_count != new_questions_count + end + else + false + end + end + + @spec insert_or_update_assessment_changeset(map(), boolean()) :: Ecto.Changeset.t() + defp insert_or_update_assessment_changeset( + params = %{number: number, course_id: course_id}, + force_update + ) do + Assessment + |> where(number: ^number) + |> where(course_id: ^course_id) + |> Repo.one() + |> case do + nil -> + Assessment.changeset(%Assessment{}, params) + + %{id: assessment_id} = assessment -> + answers_exist = + Answer + |> join(:inner, [a], q in assoc(a, :question)) + |> join(:inner, [a, q], asst in assoc(q, :assessment)) + |> where([a, q, asst], asst.id == ^assessment_id) + |> Repo.exists?() + + # Maintain the same open/close date when updating an assessment + params = + params + |> Map.delete(:open_at) + |> Map.delete(:close_at) + |> Map.delete(:is_published) + + cond do + not answers_exist -> + # Delete all realted submission_votes + SubmissionVotes + |> join(:inner, [sv, q], q in assoc(sv, :question)) + |> where([sv, q], q.assessment_id == ^assessment_id) + |> Repo.delete_all() + + # Delete all existing questions + Question + |> where(assessment_id: ^assessment_id) + |> Repo.delete_all() + + Assessment.changeset(assessment, params) + + force_update -> + Assessment.changeset(assessment, params) + + true -> + # if the assessment has submissions, don't edit + create_invalid_changeset_with_error(:assessment, "has submissions") + end + end + end + + @spec build_question_changeset_for_assessment_id(map(), number() | String.t()) :: + Ecto.Changeset.t() + defp build_question_changeset_for_assessment_id(params, assessment_id) + when is_ecto_id(assessment_id) do + params_with_assessment_id = Map.put_new(params, :assessment_id, assessment_id) + + Question.changeset(%Question{}, params_with_assessment_id) + end + + @doc """ + Generates and assigns contest entries for users with given usernames. + """ + def insert_voting( + course_id, + contest_number, + question_id + ) do + contest_assessment = Repo.get_by(Assessment, number: contest_number, course_id: course_id) + + if is_nil(contest_assessment) do + changeset = change(%Assessment{}, %{number: ""}) + + error_changeset = + Ecto.Changeset.add_error( + changeset, + :number, + "invalid contest number" + ) + + {:error, error_changeset} + else + # Returns contest submission ids with answers that contain "return" + contest_submission_ids = + Submission + |> join(:inner, [s], ans in assoc(s, :answers)) + |> join(:inner, [s, ans], cr in assoc(s, :student)) + |> where([s, ans, cr], cr.role == "student") + |> where([s, _], s.assessment_id == ^contest_assessment.id and s.status == "submitted") + |> where( + [_, ans, cr], + fragment( + "?->>'code' like ?", + ans.answer, + "%return%" + ) + ) + |> select([s, _ans], {s.student_id, s.id}) + |> Repo.all() + |> Enum.into(%{}) + + contest_submission_ids_length = Enum.count(contest_submission_ids) + + voter_ids = + CourseRegistration + |> where(role: "student", course_id: ^course_id) + |> select([cr], cr.id) + |> Repo.all() + + votes_per_user = min(contest_submission_ids_length, 10) + + votes_per_submission = + if Enum.empty?(contest_submission_ids) do + 0 + else + trunc(Float.ceil(votes_per_user * length(voter_ids) / contest_submission_ids_length)) + end + + submission_id_list = + contest_submission_ids + |> Enum.map(fn {_, s_id} -> s_id end) + |> Enum.shuffle() + |> List.duplicate(votes_per_submission) + |> List.flatten() + + {_submission_map, submission_votes_changesets} = + voter_ids + |> Enum.reduce({submission_id_list, []}, fn voter_id, acc -> + {submission_list, submission_votes} = acc + + user_contest_submission_id = Map.get(contest_submission_ids, voter_id) + + {votes, rest} = + submission_list + |> Enum.reduce_while({MapSet.new(), submission_list}, fn s_id, acc -> + {user_votes, submissions} = acc + + max_votes = + if votes_per_user == contest_submission_ids_length and + not is_nil(user_contest_submission_id) do + # no. of submssions is less than 10. Unable to find + votes_per_user - 1 + else + votes_per_user + end + + if MapSet.size(user_votes) < max_votes do + if s_id != user_contest_submission_id and not MapSet.member?(user_votes, s_id) do + new_user_votes = MapSet.put(user_votes, s_id) + new_submissions = List.delete(submissions, s_id) + {:cont, {new_user_votes, new_submissions}} + else + {:cont, {user_votes, submissions}} + end + else + {:halt, acc} + end + end) + + votes = MapSet.to_list(votes) + + new_submission_votes = + votes + |> Enum.map(fn s_id -> + %SubmissionVotes{voter_id: voter_id, submission_id: s_id, question_id: question_id} + end) + |> Enum.concat(submission_votes) + + {rest, new_submission_votes} + end) + + submission_votes_changesets + |> Enum.with_index() + |> Enum.reduce(Multi.new(), fn {changeset, index}, multi -> + Multi.insert(multi, Integer.to_string(index), changeset) + end) + |> Repo.transaction() + end + end + + def update_assessment(id, params) when is_ecto_id(id) do + IO.inspect(params) + simple_update( + Assessment, + id, + using: &Assessment.changeset/2, + params: params + ) + end + + def update_question(id, params) when is_ecto_id(id) do + simple_update( + Question, + id, + using: &Question.changeset/2, + params: params + ) + end + + def publish_assessment(id) when is_ecto_id(id) do + update_assessment(id, %{is_published: true}) + end + + def create_question_for_assessment(params, assessment_id) when is_ecto_id(assessment_id) do + assessment = + Assessment + |> where(id: ^assessment_id) + |> join(:left, [a], q in assoc(a, :questions)) + |> preload([_, q], questions: q) + |> Repo.one() + + if assessment do + params_with_assessment_id = Map.put_new(params, :assessment_id, assessment.id) + + %Question{} + |> Question.changeset(params_with_assessment_id) + |> put_display_order(assessment.questions) + |> Repo.insert() + else + {:error, "Assessment not found"} + end + end + + def get_question(id) when is_ecto_id(id) do + Question + |> where(id: ^id) + |> join(:inner, [q], assessment in assoc(q, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.one() + end + + def delete_question(id) when is_ecto_id(id) do + question = Repo.get(Question, id) + Repo.delete(question) + end + + @doc """ + Public internal api to submit new answers for a question. Possible return values are: + `{:ok, nil}` -> success + `{:error, error}` -> failed. `error` is in the format of `{http_response_code, error message}` + + Note: In the event of `find_or_create_submission` failing due to a race condition, error will be: + `{:bad_request, "Missing or invalid parameter(s)"}` + + """ + def answer_question( + question = %Question{}, + cr = %CourseRegistration{id: cr_id}, + raw_answer, + force_submit + ) do + with {:ok, submission} <- find_or_create_submission(cr, question.assessment), + {:status, true} <- {:status, force_submit or submission.status != :submitted}, + {:ok, _answer} <- insert_or_update_answer(submission, question, raw_answer, cr_id) do + update_submission_status_router(submission, question) + + {:ok, nil} + else + {:status, _} -> + {:error, {:forbidden, "Assessment submission already finalised"}} + + {:error, :race_condition} -> + {:error, {:internal_server_error, "Please try again later."}} + + {:error, :invalid_vote} -> + {:error, {:bad_request, "Invalid vote! Vote is not saved."}} + + _ -> + {:error, {:bad_request, "Missing or invalid parameter(s)"}} + end + end + + def get_submission(assessment_id, %CourseRegistration{id: cr_id}) + when is_ecto_id(assessment_id) do + Submission + |> where(assessment_id: ^assessment_id) + |> where(student_id: ^cr_id) + |> join(:inner, [s], a in assoc(s, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.one() + end + + def get_submission_by_id(submission_id) when is_ecto_id(submission_id) do + Submission + |> where(id: ^submission_id) + |> join(:inner, [s], a in assoc(s, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.one() + end + + def finalise_submission(submission = %Submission{}) do + with {:status, :attempted} <- {:status, submission.status}, + {:ok, updated_submission} <- update_submission_status_and_xp_bonus(submission) do + # Couple with update_submission_status_and_xp_bonus to ensure notification is sent + Notifications.write_notification_when_student_submits(submission) + # Send email notification to avenger + %{notification_type: "assessment_submission", submission_id: updated_submission.id} + |> Cadet.Workers.NotificationWorker.new() + |> Oban.insert() + + # Begin autograding job + GradingJob.force_grade_individual_submission(updated_submission) + + {:ok, nil} + else + {:status, :attempting} -> + {:error, {:bad_request, "Some questions have not been attempted"}} + + {:status, :submitted} -> + {:error, {:forbidden, "Assessment has already been submitted"}} + + _ -> + {:error, {:internal_server_error, "Please try again later."}} + end + end + + def unsubmit_submission( + submission_id, + cr = %CourseRegistration{id: course_reg_id, role: role} + ) + when is_ecto_id(submission_id) do + submission = + Submission + |> join(:inner, [s], a in assoc(s, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.get(submission_id) + + # allows staff to unsubmit own assessment + bypass = role in @bypass_closed_roles and submission.student_id == course_reg_id + + with {:submission_found?, true} <- {:submission_found?, is_map(submission)}, + {:is_open?, true} <- {:is_open?, bypass or is_open?(submission.assessment)}, + {:status, :submitted} <- {:status, submission.status}, + {:allowed_to_unsubmit?, true} <- + {:allowed_to_unsubmit?, + role == :admin or bypass or + Cadet.Accounts.Query.avenger_of?(cr, submission.student_id)} do + Multi.new() + |> Multi.run( + :rollback_submission, + fn _repo, _ -> + submission + |> Submission.changeset(%{ + status: :attempted, + xp_bonus: 0, + unsubmitted_by_id: course_reg_id, + unsubmitted_at: Timex.now() + }) + |> Repo.update() + end + ) + |> Multi.run(:rollback_answers, fn _repo, _ -> + Answer + |> join(:inner, [a], q in assoc(a, :question)) + |> join(:inner, [a, _], s in assoc(a, :submission)) + |> preload([_, q, s], question: q, submission: s) + |> where(submission_id: ^submission.id) + |> Repo.all() + |> Enum.reduce_while({:ok, nil}, fn answer, acc -> + case acc do + {:error, _} -> + {:halt, acc} + + {:ok, _} -> + {:cont, + answer + |> Answer.grading_changeset(%{ + xp: 0, + xp_adjustment: 0, + autograding_status: :none, + autograding_results: [] + }) + |> Repo.update()} + end + end) + end) + |> Repo.transaction() + + Cadet.Accounts.Notifications.handle_unsubmit_notifications( + submission.assessment.id, + Repo.get(CourseRegistration, submission.student_id) + ) + + {:ok, nil} + else + {:submission_found?, false} -> + {:error, {:not_found, "Submission not found"}} + + {:is_open?, false} -> + {:error, {:forbidden, "Assessment not open"}} + + {:status, :attempting} -> + {:error, {:bad_request, "Some questions have not been attempted"}} + + {:status, :attempted} -> + {:error, {:bad_request, "Assessment has not been submitted"}} + + {:allowed_to_unsubmit?, false} -> + {:error, {:forbidden, "Only Avenger of student or Admin is permitted to unsubmit"}} + + _ -> + {:error, {:internal_server_error, "Please try again later."}} + end + end + + @spec update_submission_status_and_xp_bonus(Submission.t()) :: + {:ok, Submission.t()} | {:error, Ecto.Changeset.t()} + defp update_submission_status_and_xp_bonus(submission = %Submission{}) do + assessment = submission.assessment + assessment_conifg = Repo.get_by(AssessmentConfig, id: assessment.config_id) + + max_bonus_xp = assessment_conifg.early_submission_xp + early_hours = assessment_conifg.hours_before_early_xp_decay + + xp_bonus = + if Timex.before?(Timex.now(), Timex.shift(assessment.open_at, hours: early_hours)) do + max_bonus_xp + else + # This logic interpolates from max bonus at early hour to 0 bonus at close time + decaying_hours = Timex.diff(assessment.close_at, assessment.open_at, :hours) - early_hours + remaining_hours = Enum.max([0, Timex.diff(assessment.close_at, Timex.now(), :hours)]) + proportion = if(decaying_hours > 0, do: remaining_hours / decaying_hours, else: 1) + bonus_xp = round(max_bonus_xp * proportion) + Enum.max([0, bonus_xp]) + end + + submission + |> Submission.changeset(%{status: :submitted, xp_bonus: xp_bonus}) + |> Repo.update() + end + + defp update_submission_status_router(submission = %Submission{}, question = %Question{}) do + case question.type do + :voting -> update_contest_voting_submission_status(submission, question) + :mcq -> update_submission_status(submission, question.assessment) + :programming -> update_submission_status(submission, question.assessment) + end + end + + defp update_submission_status(submission = %Submission{}, assessment = %Assessment{}) do + model_assoc_count = fn model, assoc, id -> + model + |> where(id: ^id) + |> join(:inner, [m], a in assoc(m, ^assoc)) + |> select([_, a], count(a.id)) + |> Repo.one() + end + + Multi.new() + |> Multi.run(:assessment, fn _repo, _ -> + {:ok, model_assoc_count.(Assessment, :questions, assessment.id)} + end) + |> Multi.run(:submission, fn _repo, _ -> + {:ok, model_assoc_count.(Submission, :answers, submission.id)} + end) + |> Multi.run(:update, fn _repo, %{submission: s_count, assessment: a_count} -> + if s_count == a_count do + submission |> Submission.changeset(%{status: :attempted}) |> Repo.update() + else + {:ok, nil} + end + end) + |> Repo.transaction() + end + + defp update_contest_voting_submission_status(submission = %Submission{}, question = %Question{}) do + has_nil_entries = + SubmissionVotes + |> where(question_id: ^question.id) + |> where(voter_id: ^submission.student_id) + |> where([sv], is_nil(sv.score)) + |> Repo.exists?() + + unless has_nil_entries do + submission |> Submission.changeset(%{status: :attempted}) |> Repo.update() + end + end + + defp load_contest_voting_entries( + questions, + %CourseRegistration{role: role, course_id: course_id, id: voter_id}, + assessment + ) do + Enum.map( + questions, + fn q -> + if q.type == :voting do + submission_votes = all_submission_votes_by_question_id_and_voter_id(q.id, voter_id) + # fetch top 10 contest voting entries with the contest question id + question_id = fetch_associated_contest_question_id(course_id, q) + + leaderboard_results = + if is_nil(question_id) do + [] + else + if leaderboard_open?(assessment, q) or role in @open_all_assessment_roles do + fetch_top_relative_score_answers(question_id, 10) + else + [] + end + end + + # populate entries to vote for and leaderboard data into the question + voting_question = + q.question + |> Map.put(:contest_entries, submission_votes) + |> Map.put( + :contest_leaderboard, + leaderboard_results + ) + + Map.put(q, :question, voting_question) + else + q + end + end + ) + end + + defp all_submission_votes_by_question_id_and_voter_id(question_id, voter_id) do + SubmissionVotes + |> where([v], v.voter_id == ^voter_id and v.question_id == ^question_id) + |> join(:inner, [v], s in assoc(v, :submission)) + |> join(:inner, [v, s], a in assoc(s, :answers)) + |> select([v, s, a], %{submission_id: v.submission_id, answer: a.answer, score: v.score}) + |> Repo.all() + end + + # Finds the contest_question_id associated with the given voting_question id + defp fetch_associated_contest_question_id(course_id, voting_question) do + contest_number = voting_question.question["contest_number"] + + if is_nil(contest_number) do + nil + else + Assessment + |> where(number: ^contest_number, course_id: ^course_id) + |> join(:inner, [a], q in assoc(a, :questions)) + |> order_by([a, q], q.display_order) + |> select([a, q], q.id) + |> Repo.one() + end + end + + defp leaderboard_open?(assessment, voting_question) do + Timex.before?( + Timex.shift(assessment.close_at, hours: voting_question.question["reveal_hours"]), + Timex.now() + ) + end + + @doc """ + Fetches top answers for the given question, based on the contest relative_score + + Used for contest leaderboard fetching + """ + def fetch_top_relative_score_answers(question_id, number_of_answers) do + Answer + |> where(question_id: ^question_id) + |> where( + [a], + fragment( + "?->>'code' like ?", + a.answer, + "%return%" + ) + ) + |> order_by(desc: :relative_score) + |> join(:left, [a], s in assoc(a, :submission)) + |> join(:left, [a, s], student in assoc(s, :student)) + |> join(:inner, [a, s, student], student_user in assoc(student, :user)) + |> where([a, s, student], student.role == "student") + |> select([a, s, student, student_user], %{ + submission_id: a.submission_id, + answer: a.answer, + relative_score: a.relative_score, + student_name: student_user.name + }) + |> limit(^number_of_answers) + |> Repo.all() + end + + @doc """ + Computes rolling leaderboard for contest votes that are still open. + """ + def update_rolling_contest_leaderboards do + # 115 = 2 hours - 5 minutes is default. + if Log.log_execution("update_rolling_contest_leaderboards", Timex.Duration.from_minutes(115)) do + Logger.info("Started update_rolling_contest_leaderboards") + + voting_questions_to_update = fetch_active_voting_questions() + + _ = + voting_questions_to_update + |> Enum.map(fn qn -> compute_relative_score(qn.id) end) + + Logger.info("Successfully update_rolling_contest_leaderboards") + end + end + + def fetch_active_voting_questions do + Question + |> join(:left, [q], a in assoc(q, :assessment)) + |> where([q, a], q.type == "voting") + |> where([q, a], a.is_published) + |> where([q, a], a.open_at <= ^Timex.now() and a.close_at >= ^Timex.now()) + |> Repo.all() + end + + @doc """ + Computes final leaderboard for contest votes that have closed. + """ + def update_final_contest_leaderboards do + # 1435 = 24 hours - 5 minutes + if Log.log_execution("update_final_contest_leaderboards", Timex.Duration.from_minutes(1435)) do + Logger.info("Started update_final_contest_leaderboards") + + voting_questions_to_update = fetch_voting_questions_due_yesterday() + + _ = + voting_questions_to_update + |> Enum.map(fn qn -> compute_relative_score(qn.id) end) + + Logger.info("Successfully update_final_contest_leaderboards") + end + end + + def fetch_voting_questions_due_yesterday do + Question + |> join(:left, [q], a in assoc(q, :assessment)) + |> where([q, a], q.type == "voting") + |> where([q, a], a.is_published) + |> where([q, a], a.open_at <= ^Timex.now()) + |> where( + [q, a], + a.close_at < ^Timex.now() and a.close_at >= ^Timex.shift(Timex.now(), days: -1) + ) + |> Repo.all() + end + + @doc """ + Computes the current relative_score of each voting submission answer + based on current submitted votes. + """ + def compute_relative_score(contest_voting_question_id) do + # query all records from submission votes tied to the question id -> + # map score to user id -> + # store as grade -> + # query grade for contest question id. + eligible_votes = + SubmissionVotes + |> where(question_id: ^contest_voting_question_id) + |> where([sv], not is_nil(sv.score)) + |> join(:inner, [sv], ans in Answer, on: sv.submission_id == ans.submission_id) + |> select( + [sv, ans], + %{ans_id: ans.id, score: sv.score, ans: ans.answer["code"]} + ) + |> Repo.all() + + entry_scores = map_eligible_votes_to_entry_score(eligible_votes) + + entry_scores + |> Enum.map(fn {ans_id, relative_score} -> + %Answer{id: ans_id} + |> Answer.contest_score_update_changeset(%{ + relative_score: relative_score + }) + end) + |> Enum.map(fn changeset -> + op_key = "answer_#{changeset.data.id}" + Multi.update(Multi.new(), op_key, changeset) + end) + |> Enum.reduce(Multi.new(), &Multi.append/2) + |> Repo.transaction() + end + + defp map_eligible_votes_to_entry_score(eligible_votes) do + # converts eligible votes to the {total cumulative score, number of votes, tokens} + entry_vote_data = + Enum.reduce(eligible_votes, %{}, fn %{ans_id: ans_id, score: score, ans: ans}, tracker -> + {prev_score, prev_count, _ans_tokens} = Map.get(tracker, ans_id, {0, 0, 0}) + + Map.put( + tracker, + ans_id, + # assume each voter is assigned 10 entries which will make it fair. + {prev_score + score, prev_count + 1, Lexer.count_tokens(ans)} + ) + end) + + # calculate the score based on formula {ans_id, score} + Enum.map( + entry_vote_data, + fn {ans_id, {sum_of_scores, number_of_voters, tokens}} -> + {ans_id, calculate_formula_score(sum_of_scores, number_of_voters, tokens)} + end + ) + end + + # Calculate the score based on formula + # score(v,t) = v - 2^(t/50) where v is the normalized_voting_score + # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 + defp calculate_formula_score(sum_of_scores, number_of_voters, tokens) do + normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 + normalized_voting_score - :math.pow(2, min(1023.5, tokens / 50)) + end + + @doc """ + Function returning submissions under a grader. This function returns only the + fields that are exposed in the /grading endpoint. The reason we select only + those fields is to reduce the memory usage especially when the number of + submissions is large i.e. > 25000 submissions. + + The input parameters are the user and group_only. group_only is used to check + whether only the groups under the grader should be returned. The parameter is + a boolean which is false by default. + + The return value is {:ok, submissions} if no errors, else it is {:error, + {:unauthorized, "Forbidden."}} + """ + @spec all_submissions_by_grader_for_index(CourseRegistration.t()) :: + {:ok, String.t()} + def all_submissions_by_grader_for_index( + grader = %CourseRegistration{course_id: course_id}, + group_only \\ false, + ungraded_only \\ false + ) do + show_all = not group_only + + group_where = + if show_all, + do: "", + else: + "where s.student_id in (select cr.id from course_registrations cr inner join groups g on cr.group_id = g.id where g.leader_id = $2) or s.student_id = $2" + + ungraded_where = + if ungraded_only, + do: "where s.\"gradedCount\" < assts.\"questionCount\"", + else: "" + + params = if show_all, do: [course_id], else: [course_id, grader.id] + + # We bypass Ecto here and use a raw query to generate JSON directly from + # PostgreSQL, because doing it in Elixir/Erlang is too inefficient. + + case Repo.query( + """ + select json_agg(q)::TEXT from + ( + select + s.id, + s.status, + s."unsubmittedAt", + s.xp, + s."xpAdjustment", + s."xpBonus", + s."gradedCount", + assts.jsn as assessment, + students.jsn as student, + unsubmitters.jsn as "unsubmittedBy" + from + (select + s.id, + s.student_id, + s.assessment_id, + s.status, + s.unsubmitted_at as "unsubmittedAt", + s.unsubmitted_by_id, + sum(ans.xp) as xp, + sum(ans.xp_adjustment) as "xpAdjustment", + s.xp_bonus as "xpBonus", + count(ans.id) filter (where ans.grader_id is not null) as "gradedCount" + from submissions s + left join + answers ans on s.id = ans.submission_id + #{group_where} + group by s.id) s + inner join + (select + a.id, a."questionCount", to_json(a) as jsn + from + (select + a.id, + a.title, + bool_or(ac.is_manually_graded) as "isManuallyGraded", + max(ac.type) as "type", + sum(q.max_xp) as "maxXp", + count(q.id) as "questionCount" + from assessments a + left join + questions q on a.id = q.assessment_id + inner join + assessment_configs ac on ac.id = a.config_id + where a.course_id = $1 + group by a.id) a) assts on assts.id = s.assessment_id + inner join + (select + cr.id, to_json(cr) as jsn + from + (select + cr.id, + u.name as "name", + g.name as "groupName", + g.leader_id as "groupLeaderId" + from course_registrations cr + left join + groups g on g.id = cr.group_id + inner join + users u on u.id = cr.user_id) cr) students on students.id = s.student_id + left join + (select + cr.id, to_json(cr) as jsn + from + (select + cr.id, + u.name + from course_registrations cr + inner join + users u on u.id = cr.user_id) cr) unsubmitters on s.unsubmitted_by_id = unsubmitters.id + #{ungraded_where} + ) q + """, + params + ) do + {:ok, %{rows: [[nil]]}} -> {:ok, "[]"} + {:ok, %{rows: [[json]]}} -> {:ok, json} + end + end + + @spec get_answers_in_submission(integer() | String.t()) :: + {:ok, [Answer.t()]} | {:error, {:bad_request | :unauthorized, String.t()}} + def get_answers_in_submission(id) when is_ecto_id(id) do + answer_query = + Answer + |> where(submission_id: ^id) + |> join(:inner, [a], q in assoc(a, :question)) + |> join(:inner, [_, q], ast in assoc(q, :assessment)) + |> join(:inner, [a, ..., ast], ac in assoc(ast, :config)) + |> join(:left, [a, ...], g in assoc(a, :grader)) + |> join(:left, [a, ..., g], gu in assoc(g, :user)) + |> join(:inner, [a, ...], s in assoc(a, :submission)) + |> join(:inner, [a, ..., s], st in assoc(s, :student)) + |> join(:inner, [a, ..., st], u in assoc(st, :user)) + |> preload([_, q, ast, ac, g, gu, s, st, u], + question: {q, assessment: {ast, config: ac}}, + grader: {g, user: gu}, + submission: {s, student: {st, user: u}} + ) + + answers = + answer_query + |> Repo.all() + |> Enum.sort_by(& &1.question.display_order) + |> Enum.map(fn ans -> + if ans.question.type == :voting do + empty_contest_entries = Map.put(ans.question.question, :contest_entries, []) + empty_contest_leaderboard = Map.put(empty_contest_entries, :contest_leaderboard, []) + question = Map.put(ans.question, :question, empty_contest_leaderboard) + Map.put(ans, :question, question) + else + ans + end + end) + + if answers == [] do + {:error, {:bad_request, "Submission is not found."}} + else + {:ok, answers} + end + end + + defp is_fully_graded?(%Answer{submission_id: submission_id}) do + submission = + Submission + |> Repo.get_by(id: submission_id) + + question_count = + Question + |> where(assessment_id: ^submission.assessment_id) + |> select([q], count(q.id)) + |> Repo.one() + + graded_count = + Answer + |> where([a], submission_id: ^submission_id) + |> where([a], not is_nil(a.grader_id)) + |> select([a], count(a.id)) + |> Repo.one() + + question_count == graded_count + end + + @spec update_grading_info( + %{submission_id: integer() | String.t(), question_id: integer() | String.t()}, + %{}, + CourseRegistration.t() + ) :: + {:ok, nil} + | {:error, {:unauthorized | :bad_request | :internal_server_error, String.t()}} + def update_grading_info( + %{submission_id: submission_id, question_id: question_id}, + attrs, + %CourseRegistration{id: grader_id} + ) + when is_ecto_id(submission_id) and is_ecto_id(question_id) do + attrs = Map.put(attrs, "grader_id", grader_id) + + answer_query = + Answer + |> where(submission_id: ^submission_id) + |> where(question_id: ^question_id) + + answer_query = + answer_query + |> join(:inner, [a], s in assoc(a, :submission)) + |> preload([_, s], submission: s) + + answer = Repo.one(answer_query) + + is_own_submission = grader_id == answer.submission.student_id + + with {:answer_found?, true} <- {:answer_found?, is_map(answer)}, + {:status, true} <- + {:status, answer.submission.status == :submitted or is_own_submission}, + {:valid, changeset = %Ecto.Changeset{valid?: true}} <- + {:valid, Answer.grading_changeset(answer, attrs)}, + {:ok, _} <- Repo.update(changeset) do + if is_fully_graded?(answer) and not is_own_submission do + # Every answer in this submission has been graded manually + Notifications.write_notification_when_graded(submission_id, :graded) + else + {:ok, nil} + end + else + {:answer_found?, false} -> + {:error, {:bad_request, "Answer not found or user not permitted to grade."}} + + {:valid, changeset} -> + {:error, {:bad_request, full_error_messages(changeset)}} + + {:status, _} -> + {:error, {:method_not_allowed, "Submission is not submitted yet."}} + + {:error, _} -> + {:error, {:internal_server_error, "Please try again later."}} + end + end + + def update_grading_info( + _, + _, + _ + ) do + {:error, {:unauthorized, "User is not permitted to grade."}} + end + + @spec force_regrade_submission(integer() | String.t(), CourseRegistration.t()) :: + {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}} + def force_regrade_submission( + submission_id, + _requesting_user = %CourseRegistration{id: grader_id} + ) + when is_ecto_id(submission_id) do + with {:get, sub} when not is_nil(sub) <- {:get, Repo.get(Submission, submission_id)}, + {:status, true} <- {:status, sub.student_id == grader_id or sub.status == :submitted} do + GradingJob.force_grade_individual_submission(sub, true) + {:ok, nil} + else + {:get, nil} -> + {:error, {:not_found, "Submission not found"}} + + {:status, false} -> + {:error, {:bad_request, "Submission not submitted yet"}} + end + end + + def force_regrade_submission(_, _) do + {:error, {:forbidden, "User is not permitted to grade."}} + end + + @spec force_regrade_answer( + integer() | String.t(), + integer() | String.t(), + CourseRegistration.t() + ) :: + {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}} + def force_regrade_answer( + submission_id, + question_id, + _requesting_user = %CourseRegistration{id: grader_id} + ) + when is_ecto_id(submission_id) and is_ecto_id(question_id) do + answer = + Answer + |> where(submission_id: ^submission_id, question_id: ^question_id) + |> preload([:question, :submission]) + |> Repo.one() + + with {:get, answer} when not is_nil(answer) <- {:get, answer}, + {:status, true} <- + {:status, + answer.submission.student_id == grader_id or answer.submission.status == :submitted} do + GradingJob.grade_answer(answer, answer.question, true) + {:ok, nil} + else + {:get, nil} -> + {:error, {:not_found, "Answer not found"}} + + {:status, false} -> + {:error, {:bad_request, "Submission not submitted yet"}} + end + end + + def force_regrade_answer(_, _, _) do + {:error, {:forbidden, "User is not permitted to grade."}} + end + + defp find_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do + submission = + Submission + |> where(student_id: ^cr.id) + |> where(assessment_id: ^assessment.id) + |> Repo.one() + + if submission do + {:ok, submission} + else + {:error, nil} + end + end + + # Checks if an assessment is open and published. + @spec is_open?(Assessment.t()) :: boolean() + def is_open?(%Assessment{open_at: open_at, close_at: close_at, is_published: is_published}) do + Timex.between?(Timex.now(), open_at, close_at, inclusive: :start) and is_published + end + + @spec get_group_grading_summary(integer()) :: + {:ok, [String.t(), ...], []} + def get_group_grading_summary(course_id) do + subs = + Answer + |> join(:left, [ans], s in Submission, on: s.id == ans.submission_id) + |> join(:left, [ans, s], st in CourseRegistration, on: s.student_id == st.id) + |> join(:left, [ans, s, st], a in Assessment, on: a.id == s.assessment_id) + |> join(:inner, [ans, s, st, a], ac in AssessmentConfig, on: ac.id == a.config_id) + |> where( + [ans, s, st, a, ac], + not is_nil(st.group_id) and s.status == ^:submitted and + ac.show_grading_summary and a.course_id == ^course_id + ) + |> group_by([ans, s, st, a, ac], s.id) + |> select([ans, s, st, a, ac], %{ + group_id: max(st.group_id), + config_id: max(ac.id), + config_type: max(ac.type), + num_submitted: count(), + num_ungraded: filter(count(), is_nil(ans.grader_id)) + }) + + raw_data = + subs + |> subquery() + |> join(:left, [t], g in Group, on: t.group_id == g.id) + |> join(:left, [t, g], l in CourseRegistration, on: l.id == g.leader_id) + |> join(:left, [t, g, l], lu in User, on: lu.id == l.user_id) + |> group_by([t, g, l, lu], [t.group_id, t.config_id, t.config_type, g.name, lu.name]) + |> select([t, g, l, lu], %{ + group_name: g.name, + leader_name: lu.name, + config_id: t.config_id, + config_type: t.config_type, + ungraded: filter(count(), t.num_ungraded > 0), + submitted: count() + }) + |> Repo.all() + + showing_configs = + AssessmentConfig + |> where([ac], ac.course_id == ^course_id and ac.show_grading_summary) + |> order_by(:order) + |> group_by([ac], ac.id) + |> select([ac], %{ + id: ac.id, + type: ac.type + }) + |> Repo.all() + + data_by_groups = + raw_data + |> Enum.reduce(%{}, fn raw, acc -> + if Map.has_key?(acc, raw.group_name) do + acc + |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded) + |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted) + else + acc + |> put_in([raw.group_name], %{}) + |> put_in([raw.group_name, "groupName"], raw.group_name) + |> put_in([raw.group_name, "leaderName"], raw.leader_name) + |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded) + |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted) + end + end) + + headings = + showing_configs + |> Enum.reduce([], fn config, acc -> + acc ++ ["submitted" <> config.type, "ungraded" <> config.type] + end) + + default_row_data = + headings + |> Enum.reduce(%{}, fn heading, acc -> + put_in(acc, [heading], 0) + end) + + rows = data_by_groups |> Enum.map(fn {_k, row} -> Map.merge(default_row_data, row) end) + cols = ["groupName", "leaderName"] ++ headings + + {:ok, cols, rows} + end + + defp create_empty_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do + %Submission{} + |> Submission.changeset(%{student: cr, assessment: assessment}) + |> Repo.insert() + |> case do + {:ok, submission} -> {:ok, submission} + {:error, _} -> {:error, :race_condition} + end + end + + defp find_or_create_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do + case find_submission(cr, assessment) do + {:ok, submission} -> {:ok, submission} + {:error, _} -> create_empty_submission(cr, assessment) + end + end + + defp insert_or_update_answer( + submission = %Submission{}, + question = %Question{}, + raw_answer, + course_reg_id + ) do + answer_content = build_answer_content(raw_answer, question.type) + + if question.type == :voting do + insert_or_update_voting_answer(submission.id, course_reg_id, question.id, answer_content) + else + answer_changeset = + %Answer{} + |> Answer.changeset(%{ + answer: answer_content, + question_id: question.id, + submission_id: submission.id, + type: question.type + }) + + Repo.insert( + answer_changeset, + on_conflict: [set: [answer: get_change(answer_changeset, :answer)]], + conflict_target: [:submission_id, :question_id] + ) + end + end + + def insert_or_update_voting_answer(submission_id, course_reg_id, question_id, answer_content) do + set_score_to_nil = + SubmissionVotes + |> where(voter_id: ^course_reg_id, question_id: ^question_id) + + voting_multi = + Multi.new() + |> Multi.update_all(:set_score_to_nil, set_score_to_nil, set: [score: nil]) + + answer_content + |> Enum.with_index(1) + |> Enum.reduce(voting_multi, fn {entry, index}, multi -> + multi + |> Multi.run("update#{index}", fn _repo, _ -> + SubmissionVotes + |> Repo.get_by( + voter_id: course_reg_id, + submission_id: entry.submission_id + ) + |> SubmissionVotes.changeset(%{score: entry.score}) + |> Repo.insert_or_update() + end) + end) + |> Multi.run("insert into answer table", fn _repo, _ -> + Answer + |> Repo.get_by(submission_id: submission_id, question_id: question_id) + |> case do + nil -> + Repo.insert(%Answer{ + answer: %{completed: true}, + submission_id: submission_id, + question_id: question_id, + type: :voting + }) + + _ -> + {:ok, nil} + end + end) + |> Repo.transaction() + |> case do + {:ok, _result} -> {:ok, nil} + {:error, _name, _changeset, _error} -> {:error, :invalid_vote} + end + end + + defp build_answer_content(raw_answer, question_type) do + case question_type do + :mcq -> + %{choice_id: raw_answer} + + :programming -> + %{code: raw_answer} + + :voting -> + raw_answer + |> Enum.map(fn ans -> + for {key, value} <- ans, into: %{}, do: {String.to_existing_atom(key), value} + end) + end + end +end diff --git a/lib/cadet_web/admin_controllers/admin_assessments_controller.ex b/lib/cadet_web/admin_controllers/admin_assessments_controller.ex index 31de1f4ea..4a73c4ee8 100644 --- a/lib/cadet_web/admin_controllers/admin_assessments_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_assessments_controller.ex @@ -1,223 +1,223 @@ -defmodule CadetWeb.AdminAssessmentsController do - use CadetWeb, :controller - - use PhoenixSwagger - - import Ecto.Query, only: [where: 2] - import Cadet.Updater.XMLParser, only: [parse_xml: 4] - - alias Cadet.{Assessments, Repo} - alias Cadet.Assessments.Assessment - alias Cadet.Accounts.CourseRegistration - - def index(conn, %{"course_reg_id" => course_reg_id}) do - course_reg = Repo.get(CourseRegistration, course_reg_id) - {:ok, assessments} = Assessments.all_assessments(course_reg) - - render(conn, "index.json", assessments: assessments) - end - - def get_assessment(conn, %{"course_reg_id" => course_reg_id, "assessmentid" => assessment_id}) - when is_ecto_id(assessment_id) do - course_reg = Repo.get(CourseRegistration, course_reg_id) - - case Assessments.assessment_with_questions_and_answers(assessment_id, course_reg) do - {:ok, assessment} -> render(conn, "show.json", assessment: assessment) - {:error, {status, message}} -> send_resp(conn, status, message) - end - end - - def create(conn, %{ - "course_id" => course_id, - "assessment" => assessment, - "forceUpdate" => force_update, - "assessmentConfigId" => assessment_config_id - }) do - file = - assessment["file"].path - |> File.read!() - - result = - case force_update do - "true" -> parse_xml(file, course_id, assessment_config_id, true) - "false" -> parse_xml(file, course_id, assessment_config_id, false) - end - - case result do - :ok -> - if force_update == "true" do - text(conn, "Force update OK") - else - text(conn, "OK") - end - - {:ok, warning_message} -> - text(conn, warning_message) - - {:error, {status, message}} -> - conn - |> put_status(status) - |> text(message) - end - end - - def delete(conn, %{"course_id" => course_id, "assessmentid" => assessment_id}) do - with {:same_course, true} <- {:same_course, is_same_course(course_id, assessment_id)}, - {:ok, _} <- Assessments.delete_assessment(assessment_id) do - text(conn, "OK") - else - {:same_course, false} -> - conn - |> put_status(403) - |> text("User not allow to delete assessments from another course") - - {:error, {status, message}} -> - conn - |> put_status(status) - |> text(message) - end - end - - def update(conn, params = %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do - open_at = params |> Map.get("openAt") - close_at = params |> Map.get("closeAt") - is_published = params |> Map.get("isPublished") - max_team_size = params |> Map.get("maxTeamSize") - - updated_assessment = - if is_nil(is_published) do - %{} - else - %{:is_published => is_published} - end - - updated_assessment = - if is_nil(max_team_size) do - updated_assessment - else - Map.put(updated_assessment, :max_team_size, max_team_size) - end - - IO.inspect(updated_assessment) - with {:ok, assessment} <- check_dates(open_at, close_at, updated_assessment), - {:ok, _nil} <- Assessments.update_assessment(assessment_id, assessment) do - text(conn, "OK") - else - {:error, {status, message}} -> - conn - |> put_status(status) - |> text(message) - end - end - - defp check_dates(open_at, close_at, assessment) do - if is_nil(open_at) and is_nil(close_at) do - {:ok, assessment} - else - formatted_open_date = elem(DateTime.from_iso8601(open_at), 1) - formatted_close_date = elem(DateTime.from_iso8601(close_at), 1) - - if Timex.before?(formatted_close_date, formatted_open_date) do - {:error, {:bad_request, "New end date should occur after new opening date"}} - else - assessment = Map.put(assessment, :open_at, formatted_open_date) - assessment = Map.put(assessment, :close_at, formatted_close_date) - IO.inspect("good") - {:ok, assessment} - end - end - end - - defp is_same_course(course_id, assessment_id) do - Assessment - |> where(id: ^assessment_id) - |> where(course_id: ^course_id) - |> Repo.exists?() - end - - swagger_path :index do - get("/admin/users/{courseRegId}/assessments") - - summary("Fetches assessment overviews of a user") - - security([%{JWT: []}]) - - parameters do - courseRegId(:path, :integer, "Course Reg ID", required: true) - end - - response(200, "OK", Schema.array(:AssessmentsList)) - response(401, "Unauthorised") - response(403, "Forbidden") - end - - swagger_path :create do - post("/admin/assessments") - - summary("Creates a new assessment or updates an existing assessment") - - security([%{JWT: []}]) - - consumes("multipart/form-data") - - parameters do - assessment(:formData, :file, "Assessment to create or update", required: true) - forceUpdate(:formData, :boolean, "Force update", required: true) - end - - response(200, "OK") - response(400, "XML parse error") - response(403, "Forbidden") - end - - swagger_path :delete do - PhoenixSwagger.Path.delete("/admin/assessments/{assessmentId}") - - summary("Deletes an assessment") - - security([%{JWT: []}]) - - parameters do - assessmentId(:path, :integer, "Assessment ID", required: true) - end - - response(200, "OK") - response(403, "Forbidden") - end - - swagger_path :update do - post("/admin/assessments/{assessmentId}") - - summary("Updates an assessment") - - security([%{JWT: []}]) - - consumes("application/json") - - parameters do - assessmentId(:path, :integer, "Assessment ID", required: true) - - assessment(:body, Schema.ref(:AdminUpdateAssessmentPayload), "Updated assessment details", - required: true - ) - end - - response(200, "OK") - response(401, "Assessment is already opened") - response(403, "Forbidden") - end - - def swagger_definitions do - %{ - # Schemas for payloads to modify data - AdminUpdateAssessmentPayload: - swagger_schema do - properties do - closeAt(:string, "Open date", required: false) - openAt(:string, "Close date", required: false) - isPublished(:boolean, "Whether the assessment is published", required: false) - end - end - } - end -end +defmodule CadetWeb.AdminAssessmentsController do + use CadetWeb, :controller + + use PhoenixSwagger + + import Ecto.Query, only: [where: 2] + import Cadet.Updater.XMLParser, only: [parse_xml: 4] + + alias Cadet.{Assessments, Repo} + alias Cadet.Assessments.Assessment + alias Cadet.Accounts.CourseRegistration + + def index(conn, %{"course_reg_id" => course_reg_id}) do + course_reg = Repo.get(CourseRegistration, course_reg_id) + {:ok, assessments} = Assessments.all_assessments(course_reg) + + render(conn, "index.json", assessments: assessments) + end + + def get_assessment(conn, %{"course_reg_id" => course_reg_id, "assessmentid" => assessment_id}) + when is_ecto_id(assessment_id) do + course_reg = Repo.get(CourseRegistration, course_reg_id) + + case Assessments.assessment_with_questions_and_answers(assessment_id, course_reg) do + {:ok, assessment} -> render(conn, "show.json", assessment: assessment) + {:error, {status, message}} -> send_resp(conn, status, message) + end + end + + def create(conn, %{ + "course_id" => course_id, + "assessment" => assessment, + "forceUpdate" => force_update, + "assessmentConfigId" => assessment_config_id + }) do + file = + assessment["file"].path + |> File.read!() + + result = + case force_update do + "true" -> parse_xml(file, course_id, assessment_config_id, true) + "false" -> parse_xml(file, course_id, assessment_config_id, false) + end + + case result do + :ok -> + if force_update == "true" do + text(conn, "Force update OK") + else + text(conn, "OK") + end + + {:ok, warning_message} -> + text(conn, warning_message) + + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + end + end + + def delete(conn, %{"course_id" => course_id, "assessmentid" => assessment_id}) do + with {:same_course, true} <- {:same_course, is_same_course(course_id, assessment_id)}, + {:ok, _} <- Assessments.delete_assessment(assessment_id) do + text(conn, "OK") + else + {:same_course, false} -> + conn + |> put_status(403) + |> text("User not allow to delete assessments from another course") + + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + end + end + + def update(conn, params = %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do + open_at = params |> Map.get("openAt") + close_at = params |> Map.get("closeAt") + is_published = params |> Map.get("isPublished") + max_team_size = params |> Map.get("maxTeamSize") + + updated_assessment = + if is_nil(is_published) do + %{} + else + %{:is_published => is_published} + end + + updated_assessment = + if is_nil(max_team_size) do + updated_assessment + else + Map.put(updated_assessment, :max_team_size, max_team_size) + end + + IO.inspect(updated_assessment) + with {:ok, assessment} <- check_dates(open_at, close_at, updated_assessment), + {:ok, _nil} <- Assessments.update_assessment(assessment_id, assessment) do + text(conn, "OK") + else + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + end + end + + defp check_dates(open_at, close_at, assessment) do + if is_nil(open_at) and is_nil(close_at) do + {:ok, assessment} + else + formatted_open_date = elem(DateTime.from_iso8601(open_at), 1) + formatted_close_date = elem(DateTime.from_iso8601(close_at), 1) + + if Timex.before?(formatted_close_date, formatted_open_date) do + {:error, {:bad_request, "New end date should occur after new opening date"}} + else + assessment = Map.put(assessment, :open_at, formatted_open_date) + assessment = Map.put(assessment, :close_at, formatted_close_date) + IO.inspect("good") + {:ok, assessment} + end + end + end + + defp is_same_course(course_id, assessment_id) do + Assessment + |> where(id: ^assessment_id) + |> where(course_id: ^course_id) + |> Repo.exists?() + end + + swagger_path :index do + get("/admin/users/{courseRegId}/assessments") + + summary("Fetches assessment overviews of a user") + + security([%{JWT: []}]) + + parameters do + courseRegId(:path, :integer, "Course Reg ID", required: true) + end + + response(200, "OK", Schema.array(:AssessmentsList)) + response(401, "Unauthorised") + response(403, "Forbidden") + end + + swagger_path :create do + post("/admin/assessments") + + summary("Creates a new assessment or updates an existing assessment") + + security([%{JWT: []}]) + + consumes("multipart/form-data") + + parameters do + assessment(:formData, :file, "Assessment to create or update", required: true) + forceUpdate(:formData, :boolean, "Force update", required: true) + end + + response(200, "OK") + response(400, "XML parse error") + response(403, "Forbidden") + end + + swagger_path :delete do + PhoenixSwagger.Path.delete("/admin/assessments/{assessmentId}") + + summary("Deletes an assessment") + + security([%{JWT: []}]) + + parameters do + assessmentId(:path, :integer, "Assessment ID", required: true) + end + + response(200, "OK") + response(403, "Forbidden") + end + + swagger_path :update do + post("/admin/assessments/{assessmentId}") + + summary("Updates an assessment") + + security([%{JWT: []}]) + + consumes("application/json") + + parameters do + assessmentId(:path, :integer, "Assessment ID", required: true) + + assessment(:body, Schema.ref(:AdminUpdateAssessmentPayload), "Updated assessment details", + required: true + ) + end + + response(200, "OK") + response(401, "Assessment is already opened") + response(403, "Forbidden") + end + + def swagger_definitions do + %{ + # Schemas for payloads to modify data + AdminUpdateAssessmentPayload: + swagger_schema do + properties do + closeAt(:string, "Open date", required: false) + openAt(:string, "Close date", required: false) + isPublished(:boolean, "Whether the assessment is published", required: false) + end + end + } + end +end diff --git a/lib/cadet_web/admin_views/admin_assessments_view.ex b/lib/cadet_web/admin_views/admin_assessments_view.ex index 477ea1b38..71043d1ab 100644 --- a/lib/cadet_web/admin_views/admin_assessments_view.ex +++ b/lib/cadet_web/admin_views/admin_assessments_view.ex @@ -1,64 +1,64 @@ -defmodule CadetWeb.AdminAssessmentsView do - use CadetWeb, :view - use Timex - import CadetWeb.AssessmentsHelpers - - def render("index.json", %{assessments: assessments}) do - render_many(assessments, CadetWeb.AdminAssessmentsView, "overview.json", as: :assessment) - end - - def render("overview.json", %{assessment: assessment}) do - transform_map_for_view(assessment, %{ - id: :id, - courseId: :course_id, - title: :title, - shortSummary: :summary_short, - openAt: &format_datetime(&1.open_at), - closeAt: &format_datetime(&1.close_at), - type: & &1.config.type, - isManuallyGraded: & &1.config.is_manually_graded, - story: :story, - number: :number, - reading: :reading, - status: &(&1.user_status || "not_attempted"), - maxXp: :max_xp, - xp: &(&1.xp || 0), - coverImage: :cover_picture, - private: &password_protected?(&1.password), - isPublished: :is_published, - questionCount: :question_count, - gradedCount: &(&1.graded_count || 0), - maxTeamSize: :max_team_size - }) - end - - def render("show.json", %{assessment: assessment}) do - transform_map_for_view( - assessment, - %{ - id: :id, - courseId: :course_id, - title: :title, - type: & &1.config.type, - story: :story, - number: :number, - reading: :reading, - longSummary: :summary_long, - missionPDF: &Cadet.Assessments.Upload.url({&1.mission_pdf, &1}), - questions: - &Enum.map(&1.questions, fn question -> - map = - build_question_with_answer_and_solution_if_ungraded(%{ - question: question - }) - - map - end) - } - ) - end - - defp password_protected?(nil), do: false - - defp password_protected?(_), do: true -end +defmodule CadetWeb.AdminAssessmentsView do + use CadetWeb, :view + use Timex + import CadetWeb.AssessmentsHelpers + + def render("index.json", %{assessments: assessments}) do + render_many(assessments, CadetWeb.AdminAssessmentsView, "overview.json", as: :assessment) + end + + def render("overview.json", %{assessment: assessment}) do + transform_map_for_view(assessment, %{ + id: :id, + courseId: :course_id, + title: :title, + shortSummary: :summary_short, + openAt: &format_datetime(&1.open_at), + closeAt: &format_datetime(&1.close_at), + type: & &1.config.type, + isManuallyGraded: & &1.config.is_manually_graded, + story: :story, + number: :number, + reading: :reading, + status: &(&1.user_status || "not_attempted"), + maxXp: :max_xp, + xp: &(&1.xp || 0), + coverImage: :cover_picture, + private: &password_protected?(&1.password), + isPublished: :is_published, + questionCount: :question_count, + gradedCount: &(&1.graded_count || 0), + maxTeamSize: :max_team_size + }) + end + + def render("show.json", %{assessment: assessment}) do + transform_map_for_view( + assessment, + %{ + id: :id, + courseId: :course_id, + title: :title, + type: & &1.config.type, + story: :story, + number: :number, + reading: :reading, + longSummary: :summary_long, + missionPDF: &Cadet.Assessments.Upload.url({&1.mission_pdf, &1}), + questions: + &Enum.map(&1.questions, fn question -> + map = + build_question_with_answer_and_solution_if_ungraded(%{ + question: question + }) + + map + end) + } + ) + end + + defp password_protected?(nil), do: false + + defp password_protected?(_), do: true +end diff --git a/lib/cadet_web/controllers/assessments_controller.ex b/lib/cadet_web/controllers/assessments_controller.ex index d5a4ea5e5..e8b5d3df3 100644 --- a/lib/cadet_web/controllers/assessments_controller.ex +++ b/lib/cadet_web/controllers/assessments_controller.ex @@ -1,403 +1,403 @@ -defmodule CadetWeb.AssessmentsController do - use CadetWeb, :controller - - use PhoenixSwagger - - alias Cadet.Assessments - - # These roles can save and finalise answers for closed assessments and - # submitted answers - @bypass_closed_roles ~w(staff admin)a - - def submit(conn, %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do - cr = conn.assigns.course_reg - - with {:submission, submission} when not is_nil(submission) <- - {:submission, Assessments.get_submission(assessment_id, cr)}, - {:is_open?, true} <- - {:is_open?, - cr.role in @bypass_closed_roles or Assessments.is_open?(submission.assessment)}, - {:ok, _nil} <- Assessments.finalise_submission(submission) do - text(conn, "OK") - else - {:submission, nil} -> - conn - |> put_status(:not_found) - |> text("Submission not found") - - {:is_open?, false} -> - conn - |> put_status(:forbidden) - |> text("Assessment not open") - - {:error, {status, message}} -> - conn - |> put_status(status) - |> text(message) - end - end - - def index(conn, _) do - cr = conn.assigns.course_reg - {:ok, assessments} = Assessments.all_assessments(cr) - - render(conn, "index.json", assessments: assessments) - end - - def show(conn, %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do - cr = conn.assigns.course_reg - - case Assessments.assessment_with_questions_and_answers(assessment_id, cr) do - {:ok, assessment} -> render(conn, "show.json", assessment: assessment) - {:error, {status, message}} -> send_resp(conn, status, message) - end - end - - def unlock(conn, %{"assessmentid" => assessment_id, "password" => password}) - when is_ecto_id(assessment_id) do - cr = conn.assigns.course_reg - - case Assessments.assessment_with_questions_and_answers(assessment_id, cr, password) do - {:ok, assessment} -> render(conn, "show.json", assessment: assessment) - {:error, {status, message}} -> send_resp(conn, status, message) - end - end - - swagger_path :submit do - post("/assessments/{assessmentId}/submit") - summary("Finalise submission for an assessment") - security([%{JWT: []}]) - - parameters do - assessmentId(:path, :integer, "Assessment ID", required: true) - end - - response(200, "OK") - - response( - 400, - "Invalid parameters or incomplete submission (submission with unanswered questions)" - ) - - response(403, "User not permitted to answer questions or assessment not open") - response(404, "Submission not found") - end - - swagger_path :index do - get("/assessments") - - summary("Get a list of all assessments") - - security([%{JWT: []}]) - - produces("application/json") - - response(200, "OK", Schema.ref(:AssessmentsList)) - response(401, "Unauthorised") - end - - swagger_path :show do - get("/assessments/{assessmentId}") - - summary("Get information about one particular assessment") - - security([%{JWT: []}]) - - consumes("application/json") - produces("application/json") - - parameters do - assessmentId(:path, :integer, "Assessment ID", required: true) - end - - response(200, "OK", Schema.ref(:Assessment)) - response(400, "Missing parameter(s) or invalid assessmentId") - response(401, "Unauthorised") - end - - swagger_path :unlock do - post("/assessments/{assessmentId}/unlock") - - summary("Unlocks a password-protected assessment and returns its information") - - security([%{JWT: []}]) - - consumes("application/json") - produces("application/json") - - parameters do - assessmentId(:path, :integer, "Assessment ID", required: true) - - password(:body, Schema.ref(:UnlockAssessmentPayload), "Password to unlock assessment", - required: true - ) - end - - response(200, "OK", Schema.ref(:Assessment)) - response(400, "Missing parameter(s) or invalid assessmentId") - response(401, "Unauthorised") - response(403, "Password incorrect") - end - - def swagger_definitions do - %{ - AssessmentsList: - swagger_schema do - description("A list of all assessments") - type(:array) - items(Schema.ref(:AssessmentOverview)) - end, - AssessmentOverview: - swagger_schema do - properties do - id(:integer, "The assessment ID", required: true) - title(:string, "The title of the assessment", required: true) - - config(Schema.ref(:AssessmentConfig), "The assessment config", required: true) - - shortSummary(:string, "Short summary", required: true) - - number( - :string, - "The string identifying the relative position of this assessment", - required: true - ) - - story(:string, "The story that should be shown for this assessment") - reading(:string, "The reading for this assessment") - openAt(:string, "The opening date", format: "date-time", required: true) - closeAt(:string, "The closing date", format: "date-time", required: true) - - status( - Schema.ref(:AssessmentStatus), - "One of 'not_attempted/attempting/attempted/submitted' indicating whether the assessment has been attempted by the current user", - required: true - ) - - maxXp( - :integer, - "The maximum XP for this assessment", - required: true - ) - - xp(:integer, "The XP earned for this assessment", required: true) - - coverImage(:string, "The URL to the cover picture", required: true) - - private(:boolean, "Is this an private assessment?", required: true) - - isPublished(:boolean, "Is the assessment published?", required: true) - - questionCount(:integer, "The number of questions in this assessment", required: true) - - gradedCount( - :integer, - "The number of answers in the submission which have been graded", - required: true - ) - - maxTeamSize(:integer, "The maximum team size allowed", required: true) - end - end, - Assessment: - swagger_schema do - properties do - id(:integer, "The assessment ID", required: true) - title(:string, "The title of the assessment", required: true) - - config(Schema.ref(:AssessmentConfig), "The assessment config", required: true) - - number( - :string, - "The string identifying the relative position of this assessment", - required: true - ) - - story(:string, "The story that should be shown for this assessment") - reading(:string, "The reading for this assessment") - longSummary(:string, "Long summary", required: true) - missionPDF(:string, "The URL to the assessment pdf") - - questions(Schema.ref(:Questions), "The list of questions for this assessment") - end - end, - AssessmentConfig: - swagger_schema do - description("Assessment config") - type(:string) - enum([:mission, :sidequest, :path, :contest, :practical]) - end, - AssessmentStatus: - swagger_schema do - type(:string) - enum([:not_attempted, :attempting, :attempted, :submitted]) - end, - Questions: - swagger_schema do - description("A list of questions") - type(:array) - items(Schema.ref(:Question)) - end, - Question: - swagger_schema do - properties do - id(:integer, "The question ID", required: true) - type(:string, "The question type (mcq/programming)", required: true) - content(:string, "The question content", required: true) - - choices( - Schema.new do - type(:array) - items(Schema.ref(:MCQChoice)) - end, - "MCQ choices if question type is mcq" - ) - - solution(:integer, "Solution to a mcq question if it belongs to path assessment") - - answer( - # Note: this is technically an invalid type in Swagger/OpenAPI 2.0, - # but represents that a string or integer could be returned. - :string_or_integer, - "Previous answer for this question (string/int) depending on question type", - required: true - ) - - library( - Schema.ref(:Library), - "The library used for this question" - ) - - prepend(:string, "Prepend program for programming questions") - solutionTemplate(:string, "Solution template for programming questions") - postpend(:string, "Postpend program for programming questions") - - testcases( - Schema.new do - type(:array) - items(Schema.ref(:Testcase)) - end, - "Testcase programs for programming questions" - ) - - grader(Schema.ref(:GraderInfo)) - - gradedAt(:string, "Last graded at", format: "date-time", required: false) - - xp(:integer, "Final XP given to this question. Only provided for students.") - grade(:integer, "Final grade given to this question. Only provided for students.") - comments(:string, "String of comments given to a student's answer", required: false) - - maxGrade( - :integer, - "The max grade for this question", - required: true - ) - - maxXp( - :integer, - "The max xp for this question", - required: true - ) - - autogradingStatus(Schema.ref(:AutogradingStatus), "The status of the autograder") - - autogradingResults( - Schema.new do - type(:array) - items(Schema.ref(:AutogradingResult)) - end - ) - end - end, - MCQChoice: - swagger_schema do - properties do - content(:string, "The choice content", required: true) - hint(:string, "The hint", required: true) - end - end, - ExternalLibrary: - swagger_schema do - properties do - name(:string, "Name of the external library", required: true) - - symbols( - Schema.new do - type(:array) - - items( - Schema.new do - type(:string) - end - ) - end - ) - end - end, - Library: - swagger_schema do - properties do - chapter(:integer) - - globals( - Schema.new do - type(:array) - - items( - Schema.new do - type(:string) - end - ) - end - ) - - external( - Schema.ref(:ExternalLibrary), - "The external library for this question" - ) - end - end, - Testcase: - swagger_schema do - properties do - answer(:string) - score(:integer) - program(:string) - type(Schema.ref(:TestcaseType), "One of public/opaque/secret") - end - end, - TestcaseType: - swagger_schema do - type(:string) - enum([:public, :opaque, :secret]) - end, - AutogradingResult: - swagger_schema do - properties do - resultType(Schema.ref(:AutogradingResultType), "One of pass/fail/error") - expected(:string) - actual(:string) - end - end, - AutogradingResultType: - swagger_schema do - type(:string) - enum([:pass, :fail, :error]) - end, - AutogradingStatus: - swagger_schema do - type(:string) - enum([:none, :processing, :success, :failed]) - end, - - # Schemas for payloads to modify data - UnlockAssessmentPayload: - swagger_schema do - properties do - password(:string, "Password", required: true) - end - end - } - end -end +defmodule CadetWeb.AssessmentsController do + use CadetWeb, :controller + + use PhoenixSwagger + + alias Cadet.Assessments + + # These roles can save and finalise answers for closed assessments and + # submitted answers + @bypass_closed_roles ~w(staff admin)a + + def submit(conn, %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do + cr = conn.assigns.course_reg + + with {:submission, submission} when not is_nil(submission) <- + {:submission, Assessments.get_submission(assessment_id, cr)}, + {:is_open?, true} <- + {:is_open?, + cr.role in @bypass_closed_roles or Assessments.is_open?(submission.assessment)}, + {:ok, _nil} <- Assessments.finalise_submission(submission) do + text(conn, "OK") + else + {:submission, nil} -> + conn + |> put_status(:not_found) + |> text("Submission not found") + + {:is_open?, false} -> + conn + |> put_status(:forbidden) + |> text("Assessment not open") + + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + end + end + + def index(conn, _) do + cr = conn.assigns.course_reg + {:ok, assessments} = Assessments.all_assessments(cr) + + render(conn, "index.json", assessments: assessments) + end + + def show(conn, %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do + cr = conn.assigns.course_reg + + case Assessments.assessment_with_questions_and_answers(assessment_id, cr) do + {:ok, assessment} -> render(conn, "show.json", assessment: assessment) + {:error, {status, message}} -> send_resp(conn, status, message) + end + end + + def unlock(conn, %{"assessmentid" => assessment_id, "password" => password}) + when is_ecto_id(assessment_id) do + cr = conn.assigns.course_reg + + case Assessments.assessment_with_questions_and_answers(assessment_id, cr, password) do + {:ok, assessment} -> render(conn, "show.json", assessment: assessment) + {:error, {status, message}} -> send_resp(conn, status, message) + end + end + + swagger_path :submit do + post("/assessments/{assessmentId}/submit") + summary("Finalise submission for an assessment") + security([%{JWT: []}]) + + parameters do + assessmentId(:path, :integer, "Assessment ID", required: true) + end + + response(200, "OK") + + response( + 400, + "Invalid parameters or incomplete submission (submission with unanswered questions)" + ) + + response(403, "User not permitted to answer questions or assessment not open") + response(404, "Submission not found") + end + + swagger_path :index do + get("/assessments") + + summary("Get a list of all assessments") + + security([%{JWT: []}]) + + produces("application/json") + + response(200, "OK", Schema.ref(:AssessmentsList)) + response(401, "Unauthorised") + end + + swagger_path :show do + get("/assessments/{assessmentId}") + + summary("Get information about one particular assessment") + + security([%{JWT: []}]) + + consumes("application/json") + produces("application/json") + + parameters do + assessmentId(:path, :integer, "Assessment ID", required: true) + end + + response(200, "OK", Schema.ref(:Assessment)) + response(400, "Missing parameter(s) or invalid assessmentId") + response(401, "Unauthorised") + end + + swagger_path :unlock do + post("/assessments/{assessmentId}/unlock") + + summary("Unlocks a password-protected assessment and returns its information") + + security([%{JWT: []}]) + + consumes("application/json") + produces("application/json") + + parameters do + assessmentId(:path, :integer, "Assessment ID", required: true) + + password(:body, Schema.ref(:UnlockAssessmentPayload), "Password to unlock assessment", + required: true + ) + end + + response(200, "OK", Schema.ref(:Assessment)) + response(400, "Missing parameter(s) or invalid assessmentId") + response(401, "Unauthorised") + response(403, "Password incorrect") + end + + def swagger_definitions do + %{ + AssessmentsList: + swagger_schema do + description("A list of all assessments") + type(:array) + items(Schema.ref(:AssessmentOverview)) + end, + AssessmentOverview: + swagger_schema do + properties do + id(:integer, "The assessment ID", required: true) + title(:string, "The title of the assessment", required: true) + + config(Schema.ref(:AssessmentConfig), "The assessment config", required: true) + + shortSummary(:string, "Short summary", required: true) + + number( + :string, + "The string identifying the relative position of this assessment", + required: true + ) + + story(:string, "The story that should be shown for this assessment") + reading(:string, "The reading for this assessment") + openAt(:string, "The opening date", format: "date-time", required: true) + closeAt(:string, "The closing date", format: "date-time", required: true) + + status( + Schema.ref(:AssessmentStatus), + "One of 'not_attempted/attempting/attempted/submitted' indicating whether the assessment has been attempted by the current user", + required: true + ) + + maxXp( + :integer, + "The maximum XP for this assessment", + required: true + ) + + xp(:integer, "The XP earned for this assessment", required: true) + + coverImage(:string, "The URL to the cover picture", required: true) + + private(:boolean, "Is this an private assessment?", required: true) + + isPublished(:boolean, "Is the assessment published?", required: true) + + questionCount(:integer, "The number of questions in this assessment", required: true) + + gradedCount( + :integer, + "The number of answers in the submission which have been graded", + required: true + ) + + maxTeamSize(:integer, "The maximum team size allowed", required: true) + end + end, + Assessment: + swagger_schema do + properties do + id(:integer, "The assessment ID", required: true) + title(:string, "The title of the assessment", required: true) + + config(Schema.ref(:AssessmentConfig), "The assessment config", required: true) + + number( + :string, + "The string identifying the relative position of this assessment", + required: true + ) + + story(:string, "The story that should be shown for this assessment") + reading(:string, "The reading for this assessment") + longSummary(:string, "Long summary", required: true) + missionPDF(:string, "The URL to the assessment pdf") + + questions(Schema.ref(:Questions), "The list of questions for this assessment") + end + end, + AssessmentConfig: + swagger_schema do + description("Assessment config") + type(:string) + enum([:mission, :sidequest, :path, :contest, :practical]) + end, + AssessmentStatus: + swagger_schema do + type(:string) + enum([:not_attempted, :attempting, :attempted, :submitted]) + end, + Questions: + swagger_schema do + description("A list of questions") + type(:array) + items(Schema.ref(:Question)) + end, + Question: + swagger_schema do + properties do + id(:integer, "The question ID", required: true) + type(:string, "The question type (mcq/programming)", required: true) + content(:string, "The question content", required: true) + + choices( + Schema.new do + type(:array) + items(Schema.ref(:MCQChoice)) + end, + "MCQ choices if question type is mcq" + ) + + solution(:integer, "Solution to a mcq question if it belongs to path assessment") + + answer( + # Note: this is technically an invalid type in Swagger/OpenAPI 2.0, + # but represents that a string or integer could be returned. + :string_or_integer, + "Previous answer for this question (string/int) depending on question type", + required: true + ) + + library( + Schema.ref(:Library), + "The library used for this question" + ) + + prepend(:string, "Prepend program for programming questions") + solutionTemplate(:string, "Solution template for programming questions") + postpend(:string, "Postpend program for programming questions") + + testcases( + Schema.new do + type(:array) + items(Schema.ref(:Testcase)) + end, + "Testcase programs for programming questions" + ) + + grader(Schema.ref(:GraderInfo)) + + gradedAt(:string, "Last graded at", format: "date-time", required: false) + + xp(:integer, "Final XP given to this question. Only provided for students.") + grade(:integer, "Final grade given to this question. Only provided for students.") + comments(:string, "String of comments given to a student's answer", required: false) + + maxGrade( + :integer, + "The max grade for this question", + required: true + ) + + maxXp( + :integer, + "The max xp for this question", + required: true + ) + + autogradingStatus(Schema.ref(:AutogradingStatus), "The status of the autograder") + + autogradingResults( + Schema.new do + type(:array) + items(Schema.ref(:AutogradingResult)) + end + ) + end + end, + MCQChoice: + swagger_schema do + properties do + content(:string, "The choice content", required: true) + hint(:string, "The hint", required: true) + end + end, + ExternalLibrary: + swagger_schema do + properties do + name(:string, "Name of the external library", required: true) + + symbols( + Schema.new do + type(:array) + + items( + Schema.new do + type(:string) + end + ) + end + ) + end + end, + Library: + swagger_schema do + properties do + chapter(:integer) + + globals( + Schema.new do + type(:array) + + items( + Schema.new do + type(:string) + end + ) + end + ) + + external( + Schema.ref(:ExternalLibrary), + "The external library for this question" + ) + end + end, + Testcase: + swagger_schema do + properties do + answer(:string) + score(:integer) + program(:string) + type(Schema.ref(:TestcaseType), "One of public/opaque/secret") + end + end, + TestcaseType: + swagger_schema do + type(:string) + enum([:public, :opaque, :secret]) + end, + AutogradingResult: + swagger_schema do + properties do + resultType(Schema.ref(:AutogradingResultType), "One of pass/fail/error") + expected(:string) + actual(:string) + end + end, + AutogradingResultType: + swagger_schema do + type(:string) + enum([:pass, :fail, :error]) + end, + AutogradingStatus: + swagger_schema do + type(:string) + enum([:none, :processing, :success, :failed]) + end, + + # Schemas for payloads to modify data + UnlockAssessmentPayload: + swagger_schema do + properties do + password(:string, "Password", required: true) + end + end + } + end +end diff --git a/mix.exs b/mix.exs index 0e7a00f3a..005ac2832 100644 --- a/mix.exs +++ b/mix.exs @@ -1,131 +1,131 @@ -defmodule Cadet.Mixfile do - use Mix.Project - - def project do - [ - app: :cadet, - version: "0.0.1", - elixir: "~> 1.10", - elixirc_paths: elixirc_paths(Mix.env()), - compilers: [:phoenix, :gettext] ++ Mix.compilers() ++ [:phoenix_swagger], - start_permanent: Mix.env() == :prod, - test_coverage: [tool: ExCoveralls], - preferred_cli_env: [ - coveralls: :test, - "coveralls.detail": :test, - "coveralls.post": :test, - "coveralls.html": :test - ], - aliases: aliases(), - deps: deps(), - dialyzer: [ - plt_add_apps: [:mix, :ex_unit], - plt_local_path: "priv/plts", - plt_core_path: "priv/plts" - ], - releases: [ - cadet: [ - steps: [:assemble, :tar] - ] - ] - ] - end - - # Configuration for the OTP application. - # - # Type `mix help compile.app` for more information. - def application do - [ - mod: {Cadet.Application, []}, - extra_applications: [:sentry, :logger, :que, :runtime_tools] - ] - end - - # Specifies which paths to compile per environment. - defp elixirc_paths(:test), do: ["lib", "test/support", "test/factories"] - defp elixirc_paths(:dev), do: ["lib", "test/factories"] - defp elixirc_paths(_), do: ["lib"] - - # Specifies your project dependencies. - # - # Type `mix help deps` for examples and options. - defp deps do - [ - {:arc, "~> 0.11"}, - {:arc_ecto, "~> 0.11"}, - {:corsica, "~> 1.1"}, - {:csv, "~> 2.3"}, - {:ecto_enum, "~> 1.0"}, - {:ex_aws, "~> 2.1", override: true}, - {:ex_aws_lambda, "~> 2.0"}, - {:ex_aws_s3, "~> 2.0"}, - {:ex_aws_secretsmanager, "~> 2.0"}, - {:ex_aws_sts, "~> 2.1"}, - {:ex_json_schema, "~> 0.7.4"}, - {:ex_machina, "~> 2.3"}, - {:guardian, "~> 2.0"}, - {:guardian_db, "~> 2.0"}, - {:hackney, "~> 1.6"}, - {:httpoison, "~> 1.6"}, - {:jason, "~> 1.2"}, - {:openid_connect, "~> 0.2"}, - {:phoenix, "~> 1.5"}, - {:phoenix_ecto, "~> 4.0"}, - {:phoenix_swagger, "~> 0.8"}, - {:plug_cowboy, "~> 2.0"}, - {:postgrex, ">= 0.0.0"}, - {:quantum, "~> 3.0"}, - {:que, "~> 0.10"}, - {:recase, "~> 0.7", override: true}, - {:sentry, "~> 8.0"}, - {:sweet_xml, "~> 0.6"}, - {:timex, "~> 3.7"}, - - # notifiations system dependencies - {:phoenix_html, "~> 3.0"}, - {:bamboo, "~> 2.3.0"}, - {:bamboo_ses, "~> 0.3.0"}, - {:bamboo_phoenix, "~> 1.0.0"}, - {:oban, "~> 2.13"}, - - # development dependencies - {:configparser_ex, "~> 4.0", only: [:dev, :test]}, - {:credo, "~> 1.0", only: [:dev, :test], runtime: false}, - {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, - {:distillery, "~> 2.1", runtime: false}, - {:faker, "~> 0.10", only: [:dev, :test]}, - {:git_hooks, "~> 0.4", only: [:dev, :test]}, - - # RC to fix https://github.com/rrrene/inch_ex/pull/68 - {:inch_ex, "~> 2.1-rc", only: [:dev, :test]}, - - # unit testing dependencies - {:bypass, "~> 2.1", only: :test}, - {:excoveralls, "~> 0.8", only: :test}, - {:exvcr, "~> 0.10", only: :test}, - {:mock, "~> 0.3.0", only: :test}, - - # The following are indirect dependencies, but we need to override the - # versions due to conflicts - {:jsx, "~> 3.1", override: true}, - {:xml_builder, "~> 2.1", override: true} - ] - end - - # Aliases are shortcuts or tasks specific to the current project. - # For example, to create, migrate and run the seeds file at once: - # - # $ mix ecto.setup - # - # See the documentation for `Mix` for more info on aliases. - defp aliases do - [ - "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], - "ecto.reset": ["ecto.drop", "ecto.setup"], - test: ["ecto.create --quiet", "ecto.migrate", "test"], - "phx.server": ["cadet.server"], - "phx.digest": ["cadet.digest"], - sentry_recompile: ["deps.compile sentry --force", "compile"] - ] - end -end +defmodule Cadet.Mixfile do + use Mix.Project + + def project do + [ + app: :cadet, + version: "0.0.1", + elixir: "~> 1.10", + elixirc_paths: elixirc_paths(Mix.env()), + compilers: [:phoenix, :gettext] ++ Mix.compilers() ++ [:phoenix_swagger], + start_permanent: Mix.env() == :prod, + test_coverage: [tool: ExCoveralls], + preferred_cli_env: [ + coveralls: :test, + "coveralls.detail": :test, + "coveralls.post": :test, + "coveralls.html": :test + ], + aliases: aliases(), + deps: deps(), + dialyzer: [ + plt_add_apps: [:mix, :ex_unit], + plt_local_path: "priv/plts", + plt_core_path: "priv/plts" + ], + releases: [ + cadet: [ + steps: [:assemble, :tar] + ] + ] + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {Cadet.Application, []}, + extra_applications: [:sentry, :logger, :que, :runtime_tools] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support", "test/factories"] + defp elixirc_paths(:dev), do: ["lib", "test/factories"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:arc, "~> 0.11"}, + {:arc_ecto, "~> 0.11"}, + {:corsica, "~> 1.1"}, + {:csv, "~> 2.3"}, + {:ecto_enum, "~> 1.0"}, + {:ex_aws, "~> 2.1", override: true}, + {:ex_aws_lambda, "~> 2.0"}, + {:ex_aws_s3, "~> 2.0"}, + {:ex_aws_secretsmanager, "~> 2.0"}, + {:ex_aws_sts, "~> 2.1"}, + {:ex_json_schema, "~> 0.7.4"}, + {:ex_machina, "~> 2.3"}, + {:guardian, "~> 2.0"}, + {:guardian_db, "~> 2.0"}, + {:hackney, "~> 1.6"}, + {:httpoison, "~> 1.6"}, + {:jason, "~> 1.2"}, + {:openid_connect, "~> 0.2"}, + {:phoenix, "~> 1.5"}, + {:phoenix_ecto, "~> 4.0"}, + {:phoenix_swagger, "~> 0.8"}, + {:plug_cowboy, "~> 2.0"}, + {:postgrex, ">= 0.0.0"}, + {:quantum, "~> 3.0"}, + {:que, "~> 0.10"}, + {:recase, "~> 0.7", override: true}, + {:sentry, "~> 8.0"}, + {:sweet_xml, "~> 0.6"}, + {:timex, "~> 3.7"}, + + # notifiations system dependencies + {:phoenix_html, "~> 3.0"}, + {:bamboo, "~> 2.3.0"}, + {:bamboo_ses, "~> 0.3.0"}, + {:bamboo_phoenix, "~> 1.0.0"}, + {:oban, "~> 2.13"}, + + # development dependencies + {:configparser_ex, "~> 4.0", only: [:dev, :test]}, + {:credo, "~> 1.0", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, + {:distillery, "~> 2.1", runtime: false}, + {:faker, "~> 0.10", only: [:dev, :test]}, + {:git_hooks, "~> 0.4", only: [:dev, :test]}, + + # RC to fix https://github.com/rrrene/inch_ex/pull/68 + {:inch_ex, "~> 2.1-rc", only: [:dev, :test]}, + + # unit testing dependencies + {:bypass, "~> 2.1", only: :test}, + {:excoveralls, "~> 0.8", only: :test}, + {:exvcr, "~> 0.10", only: :test}, + {:mock, "~> 0.3.0", only: :test}, + + # The following are indirect dependencies, but we need to override the + # versions due to conflicts + {:jsx, "~> 3.1", override: true}, + {:xml_builder, "~> 2.1", override: true} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to create, migrate and run the seeds file at once: + # + # $ mix ecto.setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate", "test"], + "phx.server": ["cadet.server"], + "phx.digest": ["cadet.digest"], + sentry_recompile: ["deps.compile sentry --force", "compile"] + ] + end +end diff --git a/mix.lock b/mix.lock index 45a64308d..1eb412fe1 100644 --- a/mix.lock +++ b/mix.lock @@ -1,90 +1,90 @@ -%{ - "arc": {:hex, :arc, "0.11.0", "ac7a0cc03035317b6fef9fe94c97d7d9bd183a3e7ce1606aa0c175cfa8d1ba6d", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:ex_aws_s3, "~> 2.0", [hex: :ex_aws_s3, repo: "hexpm", optional: true]}, {:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.1", [hex: :poison, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "e91a8bd676fca716f6e46275ae81fb96c0bbc7a9d5b96cac511ae190588eddd0"}, - "arc_ecto": {:hex, :arc_ecto, "0.11.3", "52f278330fe3a29472ce5d9682514ca09eaed4b33453cbaedb5241a491464f7d", [:mix], [{:arc, "~> 0.11.0", [hex: :arc, repo: "hexpm", optional: false]}, {:ecto, ">= 2.1.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "24beed35003707434a778caece7d71e46e911d46d1e82e7787345264fc8e96d0"}, - "artificery": {:hex, :artificery, "0.4.3", "0bc4260f988dcb9dda4b23f9fc3c6c8b99a6220a331534fdf5bf2fd0d4333b02", [:mix], [], "hexpm", "12e95333a30e20884e937abdbefa3e7f5e05609c2ba8cf37b33f000b9ffc0504"}, - "bamboo": {:hex, :bamboo, "2.3.0", "d2392a2cabe91edf488553d3c70638b532e8db7b76b84b0a39e3dfe492ffd6fc", [:mix], [{:hackney, ">= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.4 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dd0037e68e108fd04d0e8773921512c940e35d981e097b5793543e3b2f9cd3f6"}, - "bamboo_phoenix": {:hex, :bamboo_phoenix, "1.0.0", "f3cc591ffb163ed0bf935d256f1f4645cd870cf436545601215745fb9cc9953f", [:mix], [{:bamboo, ">= 2.0.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.3.0", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "6db88fbb26019c84a47994bb2bd879c0887c29ce6c559bc6385fd54eb8b37dee"}, - "bamboo_ses": {:hex, :bamboo_ses, "0.3.1", "3c172fc5bf2bbb1f9eec632750496ae1e6468cec4c2f0ac2a6b04351a674e2f2", [:mix], [{:bamboo, "~> 2.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:ex_aws, "~> 2.4.1", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "0.2.2", [hex: :mail, repo: "hexpm", optional: false]}], "hexpm", "7e67997479115501da674627b15322a570b41042fc0031be8a5c80e734354c26"}, - "blankable": {:hex, :blankable, "1.0.0", "89ab564a63c55af117e115144e3b3b57eb53ad43ba0f15553357eb283e0ed425", [:mix], [], "hexpm", "7cf11aac0e44f4eedbee0c15c1d37d94c090cb72a8d9fddf9f7aec30f9278899"}, - "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, - "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, - "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, - "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, - "configparser_ex": {:hex, :configparser_ex, "4.0.0", "17e2b831cfa33a08c56effc610339b2986f0d82a9caa0ed18880a07658292ab6", [:mix], [], "hexpm", "02e6d1a559361a063cba7b75bc3eb2d6ad7e62730c551cc4703541fd11e65e5b"}, - "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, - "corsica": {:hex, :corsica, "1.2.0", "5774cb77fd1d66ab89ffc2f04b2249f8e386bc37790a9f4bf101330ca247c02d", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c71f870555ce7a3eded55bbe937234cc48c546e73ce75745df9f59531687a759"}, - "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, - "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, - "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, - "credo": {:hex, :credo, "1.6.4", "ddd474afb6e8c240313f3a7b0d025cc3213f0d171879429bf8535d7021d9ad78", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "c28f910b61e1ff829bffa056ef7293a8db50e87f2c57a9b5c3f57eee124536b7"}, - "crontab": {:hex, :crontab, "1.1.11", "4028ced51b813a5061f85b689d4391ef0c27550c8ab09aaf139e4295c3d93ea4", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "ecb045f9ac14a3e2990e54368f70cdb6e2f2abafc5bc329d6c31f0c74b653787"}, - "csv": {:hex, :csv, "2.4.1", "50e32749953b6bf9818dbfed81cf1190e38cdf24f95891303108087486c5925e", [:mix], [{:parallel_stream, "~> 1.0.4", [hex: :parallel_stream, repo: "hexpm", optional: false]}], "hexpm", "54508938ac67e27966b10ef49606e3ad5995d665d7fc2688efb3eab1307c9079"}, - "db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"}, - "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, - "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, - "distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm", "bbc7008b0161a6f130d8d903b5b3232351fccc9c31a991f8fcbf2a12ace22995"}, - "ecto": {:hex, :ecto, "3.8.4", "e06b8b87e62b27fea17fd2ff6041572ddd10339fd16cdf58446e402c6c90a74b", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f9244288b8d42db40515463a008cf3f4e0e564bb9c249fe87bf28a6d79fe82d4"}, - "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, - "ecto_sql": {:hex, :ecto_sql, "3.8.3", "a7d22c624202546a39d615ed7a6b784580391e65723f2d24f65941b4dd73d471", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.8.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "348cb17fb9e6daf6f251a87049eafcb57805e2892e5e6a0f5dea0985d367329b"}, - "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, - "ex_aws": {:hex, :ex_aws, "2.4.2", "d2686c34b69287cc8dd7629e70131aec05fef3cd3eae13698c9422933f7bc9ee", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0a2c07bd1541b0bef315f67e050d3cb9f947ab1a281896a8c35e3ee4976889f6"}, - "ex_aws_lambda": {:hex, :ex_aws_lambda, "2.1.0", "f28bffae6dde34ba17ef815ef50a82a1e7f3af26d36fe31dbda3a7657e0de449", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "25608630b8b45fe22b0237696662f88be724bc89f77ee42708a5871511f531a0"}, - "ex_aws_s3": {:hex, :ex_aws_s3, "2.3.3", "61412e524616ea31d3f31675d8bc4c73f277e367dee0ae8245610446f9b778aa", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "0044f0b6f9ce925666021eafd630de64c2b3404d79c85245cc7c8a9a32d7f104"}, - "ex_aws_secretsmanager": {:hex, :ex_aws_secretsmanager, "2.0.0", "deff8c12335f0160882afeb9687e55a97fddcd7d9a82fc3a6fbb270797374773", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "8b2838af536c32263ff797012b29e87bad73ef34f43cfa60ebca8e84576f6d45"}, - "ex_aws_sts": {:hex, :ex_aws_sts, "2.3.0", "ce48c4cba7f1595a7d544458d0202ca313124026dba7b1a0021bbb1baa3d66d0", [:mix], [{:ex_aws, "~> 2.2", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "f14e4c7da3454514bf253b331e9422d25825485c211896ab3b81d2a4bdbf62f5"}, - "ex_json_schema": {:hex, :ex_json_schema, "0.7.4", "09eb5b0c8184e5702bc89625a9d0c05c7a0a845d382e9f6f406a0fc1c9a8cc3f", [:mix], [], "hexpm", "45c67fa840f0d719a2b5578126dc29bcdc1f92499c0f61bcb8a3bcb5935f9684"}, - "ex_machina": {:hex, :ex_machina, "2.7.0", "b792cc3127fd0680fecdb6299235b4727a4944a09ff0fa904cc639272cd92dc7", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "419aa7a39bde11894c87a615c4ecaa52d8f107bbdd81d810465186f783245bf8"}, - "ex_utils": {:hex, :ex_utils, "0.1.7", "2c133e0bcdc49a858cf8dacf893308ebc05bc5fba501dc3d2935e65365ec0bf3", [:mix], [], "hexpm", "66d4fe75285948f2d1e69c2a5ddd651c398c813574f8d36a9eef11dc20356ef6"}, - "exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], [], "hexpm", "1222419f706e01bfa1095aec9acf6421367dcfab798a6f67c54cf784733cd6b5"}, - "excoveralls": {:hex, :excoveralls, "0.16.1", "0bd42ed05c7d2f4d180331a20113ec537be509da31fed5c8f7047ce59ee5a7c5", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dae763468e2008cf7075a64cb1249c97cb4bc71e236c5c2b5e5cdf1cfa2bf138"}, - "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm", "32e95820a97cffea67830e91514a2ad53b888850442d6d395f53a1ac60c82e07"}, - "exvcr": {:hex, :exvcr, "0.13.3", "fcd5f54ea0ebd41db7fe16701f3c67871d1b51c3c104ab88f11135a173d47134", [:mix], [{:exactor, "~> 2.2", [hex: :exactor, repo: "hexpm", optional: false]}, {:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.1", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "db61057447388b7adc4443a55047d11d09acc75eeb5548507c775a8402e02689"}, - "faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"}, - "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, - "gen_stage": {:hex, :gen_stage, "1.1.2", "b1656cd4ba431ed02c5656fe10cb5423820847113a07218da68eae5d6a260c23", [:mix], [], "hexpm", "9e39af23140f704e2b07a3e29d8f05fd21c2aaf4088ff43cb82be4b9e3148d02"}, - "gettext": {:hex, :gettext, "0.19.1", "564953fd21f29358e68b91634799d9d26989f8d039d7512622efb3c3b1c97892", [:mix], [], "hexpm", "10c656c0912b8299adba9b061c06947511e3f109ab0d18b44a866a4498e77222"}, - "git_hooks": {:hex, :git_hooks, "0.7.3", "09489e94d88dfc767662e22aff2b6208bd7cf555a19dd0e1477cca4683ce0701", [:mix], [{:blankable, "~> 1.0.0", [hex: :blankable, repo: "hexpm", optional: false]}, {:recase, "~> 0.7.0", [hex: :recase, repo: "hexpm", optional: false]}], "hexpm", "d6ddedeb4d3a8602bc3f84e087a38f6150a86d9e790628ed8bc70e6d90681659"}, - "guardian": {:hex, :guardian, "2.2.4", "3dafdc19665411c96b2796d184064d691bc08813a132da5119e39302a252b755", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "6f83d4309c16ec2469da8606bb2a9815512cc2fac1595ad34b79940a224eb110"}, - "guardian_db": {:hex, :guardian_db, "2.1.0", "ec95a9d99cdd1e550555d09a7bb4a340d8887aad0697f594590c2fd74be02426", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:guardian, "~> 1.0 or ~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "f8e7d543ac92c395f3a7fd5acbe6829faeade57d688f7562e2f0fca8f94a0d70"}, - "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, - "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "inch_ex": {:hex, :inch_ex, "2.1.0-rc.1", "7642a8902c0d2ed5d9b5754b2fc88fedf630500d630fc03db7caca2e92dedb36", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4ceee988760f9382d1c1d0b93ea5875727f6071693e89a0a3c49c456ef1be75d"}, - "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, - "jose": {:hex, :jose, "1.11.2", "f4c018ccf4fdce22c71e44d471f15f723cb3efab5d909ab2ba202b5bf35557b3", [:mix, :rebar3], [], "hexpm", "98143fbc48d55f3a18daba82d34fe48959d44538e9697c08f34200fa5f0947d2"}, - "jsx": {:hex, :jsx, "3.1.0", "d12516baa0bb23a59bb35dccaf02a1bd08243fcbb9efe24f2d9d056ccff71268", [:rebar3], [], "hexpm", "0c5cc8fdc11b53cc25cf65ac6705ad39e54ecc56d1c22e4adb8f5a53fb9427f3"}, - "mail": {:hex, :mail, "0.2.2", "b1d31beaa2a7b23d7b84b2794f037ef4dfdaba9e66d877142bedbaf0625b9c16", [:mix], [], "hexpm", "1c9d31548a60c44ded1806369e07a7dd4d05737eb47fa3238bbf2436b3da8a32"}, - "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, - "memento": {:hex, :memento, "0.3.2", "38cfc8ff9bcb1adff7cbd0f3b78a762636b86dff764729d1c82d0464c539bdd0", [:mix], [], "hexpm", "25cf691a98a0cb70262f4a7543c04bab24648cb2041d937eb64154a8d6f8012b"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, - "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, - "mock": {:hex, :mock, "0.3.7", "75b3bbf1466d7e486ea2052a73c6e062c6256fb429d6797999ab02fa32f29e03", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "4da49a4609e41fd99b7836945c26f373623ea968cfb6282742bcb94440cf7e5c"}, - "oban": {:hex, :oban, "2.14.1", "99e28a814ca9faa759cd3f88d9adc56eb5dd0b8d4a5dabb8d2e989cb57c86f52", [:mix], [{:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6c368b5face9b1e96ba42a1d39710c5193f4b38b62c8aeb651e37897aa3feecd"}, - "openid_connect": {:hex, :openid_connect, "0.2.2", "c05055363330deab39ffd89e609db6b37752f255a93802006d83b45596189c0b", [:mix], [{:httpoison, "~> 1.2", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "735769b6d592124b58edd0582554ce638524c0214cd783d8903d33357d74cc13"}, - "parallel_stream": {:hex, :parallel_stream, "1.0.6", "b967be2b23f0f6787fab7ed681b4c45a215a81481fb62b01a5b750fa8f30f76c", [:mix], [], "hexpm", "639b2e8749e11b87b9eb42f2ad325d161c170b39b288ac8d04c4f31f8f0823eb"}, - "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, - "phoenix": {:hex, :phoenix, "1.6.10", "7a9e8348c5c62e7fd2f74a1884b88d98251f87186a430048bfbdbab3e3f46736", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "08cf70d42f61dd0ea381805bac3cddef57b7b92ade5acc6f6036aa25ecaca9a2"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, - "phoenix_html": {:hex, :phoenix_html, "3.3.0", "bf451c71ebdaac8d2f40d3b703435e819ccfbb9ff243140ca3bd10c155f134cc", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "272c5c1533499f0132309936c619186480bafcc2246588f99a69ce85095556ef"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, - "phoenix_swagger": {:hex, :phoenix_swagger, "0.8.3", "298d6204802409d3b0b4fc1013873839478707cf3a62532a9e10fec0e26d0e37", [:mix], [{:ex_json_schema, "~> 0.7.1", [hex: :ex_json_schema, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "3bc0fa9f5b679b8a61b90a52b2c67dd932320e9a84a6f91a4af872a0ab367337"}, - "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, - "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, - "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, - "postgrex": {:hex, :postgrex, "0.16.3", "fac79a81a9a234b11c44235a4494d8565303fa4b9147acf57e48978a074971db", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "aeaae1d2d1322da4e5fe90d241b0a564ce03a3add09d7270fb85362166194590"}, - "quantum": {:hex, :quantum, "3.5.0", "8d2c5ba68c55991e8975aca368e3ab844ba01f4b87c4185a7403280e2c99cf34", [:mix], [{:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.14 or ~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.2", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "cab737d1d9779f43cb1d701f46dd05ea58146fd96238d91c9e0da662c1982bb6"}, - "que": {:hex, :que, "0.10.1", "788ed0ec92ed69bdf9cfb29bf41a94ca6355b8d44959bd0669cf706e557ac891", [:mix], [{:ex_utils, "~> 0.1.6", [hex: :ex_utils, repo: "hexpm", optional: false]}, {:memento, "~> 0.3.0", [hex: :memento, repo: "hexpm", optional: false]}], "hexpm", "a737b365253e75dbd24b2d51acc1d851049e87baae08cd0c94e2bc5cd65088d5"}, - "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, - "recase": {:hex, :recase, "0.7.0", "3f2f719f0886c7a3b7fe469058ec539cb7bbe0023604ae3bce920e186305e5ae", [:mix], [], "hexpm", "36f5756a9f552f4a94b54a695870e32f4e72d5fad9c25e61bc4a3151c08a4e0c"}, - "sentry": {:hex, :sentry, "8.0.6", "c8de1bf0523bc120ec37d596c55260901029ecb0994e7075b0973328779ceef7", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.3", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "051a2d0472162f3137787c7c9d6e6e4ef239de9329c8c45b1f1bf1e9379e1883"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, - "sweet_xml": {:hex, :sweet_xml, "0.7.3", "debb256781c75ff6a8c5cbf7981146312b66f044a2898f453709a53e5031b45b", [:mix], [], "hexpm", "e110c867a1b3fe74bfc7dd9893aa851f0eed5518d0d7cad76d7baafd30e4f5ba"}, - "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, - "telemetry_registry": {:hex, :telemetry_registry, "0.3.0", "6768f151ea53fc0fbca70dbff5b20a8d663ee4e0c0b2ae589590e08658e76f1e", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "492e2adbc609f3e79ece7f29fec363a97a2c484ac78a83098535d6564781e917"}, - "timex": {:hex, :timex, "3.7.8", "0e6e8bf7c0aba95f1e13204889b2446e7a5297b1c8e408f15ab58b2c8dc85f81", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8f3b8edc5faab5205d69e5255a1d64a83b190bab7f16baa78aefcb897cf81435"}, - "tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, - "xml_builder": {:hex, :xml_builder, "2.2.0", "cc5f1eeefcfcde6e90a9b77fb6c490a20bc1b856a7010ce6396f6da9719cbbab", [:mix], [], "hexpm", "9d66d52fb917565d358166a4314078d39ef04d552904de96f8e73f68f64a62c9"}, -} +%{ + "arc": {:hex, :arc, "0.11.0", "ac7a0cc03035317b6fef9fe94c97d7d9bd183a3e7ce1606aa0c175cfa8d1ba6d", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:ex_aws_s3, "~> 2.0", [hex: :ex_aws_s3, repo: "hexpm", optional: true]}, {:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.1", [hex: :poison, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "e91a8bd676fca716f6e46275ae81fb96c0bbc7a9d5b96cac511ae190588eddd0"}, + "arc_ecto": {:hex, :arc_ecto, "0.11.3", "52f278330fe3a29472ce5d9682514ca09eaed4b33453cbaedb5241a491464f7d", [:mix], [{:arc, "~> 0.11.0", [hex: :arc, repo: "hexpm", optional: false]}, {:ecto, ">= 2.1.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "24beed35003707434a778caece7d71e46e911d46d1e82e7787345264fc8e96d0"}, + "artificery": {:hex, :artificery, "0.4.3", "0bc4260f988dcb9dda4b23f9fc3c6c8b99a6220a331534fdf5bf2fd0d4333b02", [:mix], [], "hexpm", "12e95333a30e20884e937abdbefa3e7f5e05609c2ba8cf37b33f000b9ffc0504"}, + "bamboo": {:hex, :bamboo, "2.3.0", "d2392a2cabe91edf488553d3c70638b532e8db7b76b84b0a39e3dfe492ffd6fc", [:mix], [{:hackney, ">= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.4 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dd0037e68e108fd04d0e8773921512c940e35d981e097b5793543e3b2f9cd3f6"}, + "bamboo_phoenix": {:hex, :bamboo_phoenix, "1.0.0", "f3cc591ffb163ed0bf935d256f1f4645cd870cf436545601215745fb9cc9953f", [:mix], [{:bamboo, ">= 2.0.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.3.0", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "6db88fbb26019c84a47994bb2bd879c0887c29ce6c559bc6385fd54eb8b37dee"}, + "bamboo_ses": {:hex, :bamboo_ses, "0.3.1", "3c172fc5bf2bbb1f9eec632750496ae1e6468cec4c2f0ac2a6b04351a674e2f2", [:mix], [{:bamboo, "~> 2.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:ex_aws, "~> 2.4.1", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "0.2.2", [hex: :mail, repo: "hexpm", optional: false]}], "hexpm", "7e67997479115501da674627b15322a570b41042fc0031be8a5c80e734354c26"}, + "blankable": {:hex, :blankable, "1.0.0", "89ab564a63c55af117e115144e3b3b57eb53ad43ba0f15553357eb283e0ed425", [:mix], [], "hexpm", "7cf11aac0e44f4eedbee0c15c1d37d94c090cb72a8d9fddf9f7aec30f9278899"}, + "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, + "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, + "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, + "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, + "configparser_ex": {:hex, :configparser_ex, "4.0.0", "17e2b831cfa33a08c56effc610339b2986f0d82a9caa0ed18880a07658292ab6", [:mix], [], "hexpm", "02e6d1a559361a063cba7b75bc3eb2d6ad7e62730c551cc4703541fd11e65e5b"}, + "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, + "corsica": {:hex, :corsica, "1.2.0", "5774cb77fd1d66ab89ffc2f04b2249f8e386bc37790a9f4bf101330ca247c02d", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c71f870555ce7a3eded55bbe937234cc48c546e73ce75745df9f59531687a759"}, + "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, + "credo": {:hex, :credo, "1.6.4", "ddd474afb6e8c240313f3a7b0d025cc3213f0d171879429bf8535d7021d9ad78", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "c28f910b61e1ff829bffa056ef7293a8db50e87f2c57a9b5c3f57eee124536b7"}, + "crontab": {:hex, :crontab, "1.1.11", "4028ced51b813a5061f85b689d4391ef0c27550c8ab09aaf139e4295c3d93ea4", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "ecb045f9ac14a3e2990e54368f70cdb6e2f2abafc5bc329d6c31f0c74b653787"}, + "csv": {:hex, :csv, "2.4.1", "50e32749953b6bf9818dbfed81cf1190e38cdf24f95891303108087486c5925e", [:mix], [{:parallel_stream, "~> 1.0.4", [hex: :parallel_stream, repo: "hexpm", optional: false]}], "hexpm", "54508938ac67e27966b10ef49606e3ad5995d665d7fc2688efb3eab1307c9079"}, + "db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"}, + "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, + "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, + "distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm", "bbc7008b0161a6f130d8d903b5b3232351fccc9c31a991f8fcbf2a12ace22995"}, + "ecto": {:hex, :ecto, "3.8.4", "e06b8b87e62b27fea17fd2ff6041572ddd10339fd16cdf58446e402c6c90a74b", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f9244288b8d42db40515463a008cf3f4e0e564bb9c249fe87bf28a6d79fe82d4"}, + "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, + "ecto_sql": {:hex, :ecto_sql, "3.8.3", "a7d22c624202546a39d615ed7a6b784580391e65723f2d24f65941b4dd73d471", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.8.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "348cb17fb9e6daf6f251a87049eafcb57805e2892e5e6a0f5dea0985d367329b"}, + "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, + "ex_aws": {:hex, :ex_aws, "2.4.2", "d2686c34b69287cc8dd7629e70131aec05fef3cd3eae13698c9422933f7bc9ee", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0a2c07bd1541b0bef315f67e050d3cb9f947ab1a281896a8c35e3ee4976889f6"}, + "ex_aws_lambda": {:hex, :ex_aws_lambda, "2.1.0", "f28bffae6dde34ba17ef815ef50a82a1e7f3af26d36fe31dbda3a7657e0de449", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "25608630b8b45fe22b0237696662f88be724bc89f77ee42708a5871511f531a0"}, + "ex_aws_s3": {:hex, :ex_aws_s3, "2.3.3", "61412e524616ea31d3f31675d8bc4c73f277e367dee0ae8245610446f9b778aa", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "0044f0b6f9ce925666021eafd630de64c2b3404d79c85245cc7c8a9a32d7f104"}, + "ex_aws_secretsmanager": {:hex, :ex_aws_secretsmanager, "2.0.0", "deff8c12335f0160882afeb9687e55a97fddcd7d9a82fc3a6fbb270797374773", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "8b2838af536c32263ff797012b29e87bad73ef34f43cfa60ebca8e84576f6d45"}, + "ex_aws_sts": {:hex, :ex_aws_sts, "2.3.0", "ce48c4cba7f1595a7d544458d0202ca313124026dba7b1a0021bbb1baa3d66d0", [:mix], [{:ex_aws, "~> 2.2", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "f14e4c7da3454514bf253b331e9422d25825485c211896ab3b81d2a4bdbf62f5"}, + "ex_json_schema": {:hex, :ex_json_schema, "0.7.4", "09eb5b0c8184e5702bc89625a9d0c05c7a0a845d382e9f6f406a0fc1c9a8cc3f", [:mix], [], "hexpm", "45c67fa840f0d719a2b5578126dc29bcdc1f92499c0f61bcb8a3bcb5935f9684"}, + "ex_machina": {:hex, :ex_machina, "2.7.0", "b792cc3127fd0680fecdb6299235b4727a4944a09ff0fa904cc639272cd92dc7", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "419aa7a39bde11894c87a615c4ecaa52d8f107bbdd81d810465186f783245bf8"}, + "ex_utils": {:hex, :ex_utils, "0.1.7", "2c133e0bcdc49a858cf8dacf893308ebc05bc5fba501dc3d2935e65365ec0bf3", [:mix], [], "hexpm", "66d4fe75285948f2d1e69c2a5ddd651c398c813574f8d36a9eef11dc20356ef6"}, + "exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], [], "hexpm", "1222419f706e01bfa1095aec9acf6421367dcfab798a6f67c54cf784733cd6b5"}, + "excoveralls": {:hex, :excoveralls, "0.16.1", "0bd42ed05c7d2f4d180331a20113ec537be509da31fed5c8f7047ce59ee5a7c5", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dae763468e2008cf7075a64cb1249c97cb4bc71e236c5c2b5e5cdf1cfa2bf138"}, + "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm", "32e95820a97cffea67830e91514a2ad53b888850442d6d395f53a1ac60c82e07"}, + "exvcr": {:hex, :exvcr, "0.13.3", "fcd5f54ea0ebd41db7fe16701f3c67871d1b51c3c104ab88f11135a173d47134", [:mix], [{:exactor, "~> 2.2", [hex: :exactor, repo: "hexpm", optional: false]}, {:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.1", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "db61057447388b7adc4443a55047d11d09acc75eeb5548507c775a8402e02689"}, + "faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"}, + "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "gen_stage": {:hex, :gen_stage, "1.1.2", "b1656cd4ba431ed02c5656fe10cb5423820847113a07218da68eae5d6a260c23", [:mix], [], "hexpm", "9e39af23140f704e2b07a3e29d8f05fd21c2aaf4088ff43cb82be4b9e3148d02"}, + "gettext": {:hex, :gettext, "0.19.1", "564953fd21f29358e68b91634799d9d26989f8d039d7512622efb3c3b1c97892", [:mix], [], "hexpm", "10c656c0912b8299adba9b061c06947511e3f109ab0d18b44a866a4498e77222"}, + "git_hooks": {:hex, :git_hooks, "0.7.3", "09489e94d88dfc767662e22aff2b6208bd7cf555a19dd0e1477cca4683ce0701", [:mix], [{:blankable, "~> 1.0.0", [hex: :blankable, repo: "hexpm", optional: false]}, {:recase, "~> 0.7.0", [hex: :recase, repo: "hexpm", optional: false]}], "hexpm", "d6ddedeb4d3a8602bc3f84e087a38f6150a86d9e790628ed8bc70e6d90681659"}, + "guardian": {:hex, :guardian, "2.2.4", "3dafdc19665411c96b2796d184064d691bc08813a132da5119e39302a252b755", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "6f83d4309c16ec2469da8606bb2a9815512cc2fac1595ad34b79940a224eb110"}, + "guardian_db": {:hex, :guardian_db, "2.1.0", "ec95a9d99cdd1e550555d09a7bb4a340d8887aad0697f594590c2fd74be02426", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:guardian, "~> 1.0 or ~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "f8e7d543ac92c395f3a7fd5acbe6829faeade57d688f7562e2f0fca8f94a0d70"}, + "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, + "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "inch_ex": {:hex, :inch_ex, "2.1.0-rc.1", "7642a8902c0d2ed5d9b5754b2fc88fedf630500d630fc03db7caca2e92dedb36", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4ceee988760f9382d1c1d0b93ea5875727f6071693e89a0a3c49c456ef1be75d"}, + "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, + "jose": {:hex, :jose, "1.11.2", "f4c018ccf4fdce22c71e44d471f15f723cb3efab5d909ab2ba202b5bf35557b3", [:mix, :rebar3], [], "hexpm", "98143fbc48d55f3a18daba82d34fe48959d44538e9697c08f34200fa5f0947d2"}, + "jsx": {:hex, :jsx, "3.1.0", "d12516baa0bb23a59bb35dccaf02a1bd08243fcbb9efe24f2d9d056ccff71268", [:rebar3], [], "hexpm", "0c5cc8fdc11b53cc25cf65ac6705ad39e54ecc56d1c22e4adb8f5a53fb9427f3"}, + "mail": {:hex, :mail, "0.2.2", "b1d31beaa2a7b23d7b84b2794f037ef4dfdaba9e66d877142bedbaf0625b9c16", [:mix], [], "hexpm", "1c9d31548a60c44ded1806369e07a7dd4d05737eb47fa3238bbf2436b3da8a32"}, + "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, + "memento": {:hex, :memento, "0.3.2", "38cfc8ff9bcb1adff7cbd0f3b78a762636b86dff764729d1c82d0464c539bdd0", [:mix], [], "hexpm", "25cf691a98a0cb70262f4a7543c04bab24648cb2041d937eb64154a8d6f8012b"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "mock": {:hex, :mock, "0.3.7", "75b3bbf1466d7e486ea2052a73c6e062c6256fb429d6797999ab02fa32f29e03", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "4da49a4609e41fd99b7836945c26f373623ea968cfb6282742bcb94440cf7e5c"}, + "oban": {:hex, :oban, "2.14.1", "99e28a814ca9faa759cd3f88d9adc56eb5dd0b8d4a5dabb8d2e989cb57c86f52", [:mix], [{:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6c368b5face9b1e96ba42a1d39710c5193f4b38b62c8aeb651e37897aa3feecd"}, + "openid_connect": {:hex, :openid_connect, "0.2.2", "c05055363330deab39ffd89e609db6b37752f255a93802006d83b45596189c0b", [:mix], [{:httpoison, "~> 1.2", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "735769b6d592124b58edd0582554ce638524c0214cd783d8903d33357d74cc13"}, + "parallel_stream": {:hex, :parallel_stream, "1.0.6", "b967be2b23f0f6787fab7ed681b4c45a215a81481fb62b01a5b750fa8f30f76c", [:mix], [], "hexpm", "639b2e8749e11b87b9eb42f2ad325d161c170b39b288ac8d04c4f31f8f0823eb"}, + "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, + "phoenix": {:hex, :phoenix, "1.6.10", "7a9e8348c5c62e7fd2f74a1884b88d98251f87186a430048bfbdbab3e3f46736", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "08cf70d42f61dd0ea381805bac3cddef57b7b92ade5acc6f6036aa25ecaca9a2"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, + "phoenix_html": {:hex, :phoenix_html, "3.3.0", "bf451c71ebdaac8d2f40d3b703435e819ccfbb9ff243140ca3bd10c155f134cc", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "272c5c1533499f0132309936c619186480bafcc2246588f99a69ce85095556ef"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, + "phoenix_swagger": {:hex, :phoenix_swagger, "0.8.3", "298d6204802409d3b0b4fc1013873839478707cf3a62532a9e10fec0e26d0e37", [:mix], [{:ex_json_schema, "~> 0.7.1", [hex: :ex_json_schema, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "3bc0fa9f5b679b8a61b90a52b2c67dd932320e9a84a6f91a4af872a0ab367337"}, + "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, + "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, + "postgrex": {:hex, :postgrex, "0.16.3", "fac79a81a9a234b11c44235a4494d8565303fa4b9147acf57e48978a074971db", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "aeaae1d2d1322da4e5fe90d241b0a564ce03a3add09d7270fb85362166194590"}, + "quantum": {:hex, :quantum, "3.5.0", "8d2c5ba68c55991e8975aca368e3ab844ba01f4b87c4185a7403280e2c99cf34", [:mix], [{:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.14 or ~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.2", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "cab737d1d9779f43cb1d701f46dd05ea58146fd96238d91c9e0da662c1982bb6"}, + "que": {:hex, :que, "0.10.1", "788ed0ec92ed69bdf9cfb29bf41a94ca6355b8d44959bd0669cf706e557ac891", [:mix], [{:ex_utils, "~> 0.1.6", [hex: :ex_utils, repo: "hexpm", optional: false]}, {:memento, "~> 0.3.0", [hex: :memento, repo: "hexpm", optional: false]}], "hexpm", "a737b365253e75dbd24b2d51acc1d851049e87baae08cd0c94e2bc5cd65088d5"}, + "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "recase": {:hex, :recase, "0.7.0", "3f2f719f0886c7a3b7fe469058ec539cb7bbe0023604ae3bce920e186305e5ae", [:mix], [], "hexpm", "36f5756a9f552f4a94b54a695870e32f4e72d5fad9c25e61bc4a3151c08a4e0c"}, + "sentry": {:hex, :sentry, "8.0.6", "c8de1bf0523bc120ec37d596c55260901029ecb0994e7075b0973328779ceef7", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.3", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "051a2d0472162f3137787c7c9d6e6e4ef239de9329c8c45b1f1bf1e9379e1883"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, + "sweet_xml": {:hex, :sweet_xml, "0.7.3", "debb256781c75ff6a8c5cbf7981146312b66f044a2898f453709a53e5031b45b", [:mix], [], "hexpm", "e110c867a1b3fe74bfc7dd9893aa851f0eed5518d0d7cad76d7baafd30e4f5ba"}, + "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, + "telemetry_registry": {:hex, :telemetry_registry, "0.3.0", "6768f151ea53fc0fbca70dbff5b20a8d663ee4e0c0b2ae589590e08658e76f1e", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "492e2adbc609f3e79ece7f29fec363a97a2c484ac78a83098535d6564781e917"}, + "timex": {:hex, :timex, "3.7.8", "0e6e8bf7c0aba95f1e13204889b2446e7a5297b1c8e408f15ab58b2c8dc85f81", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8f3b8edc5faab5205d69e5255a1d64a83b190bab7f16baa78aefcb897cf81435"}, + "tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "xml_builder": {:hex, :xml_builder, "2.2.0", "cc5f1eeefcfcde6e90a9b77fb6c490a20bc1b856a7010ce6396f6da9719cbbab", [:mix], [], "hexpm", "9d66d52fb917565d358166a4314078d39ef04d552904de96f8e73f68f64a62c9"}, +} diff --git a/priv/repo/migrations/20190510152804_drop_announcements_table.exs b/priv/repo/migrations/20190510152804_drop_announcements_table.exs index 8a7c2b08d..9b8ae9eb9 100644 --- a/priv/repo/migrations/20190510152804_drop_announcements_table.exs +++ b/priv/repo/migrations/20190510152804_drop_announcements_table.exs @@ -1,7 +1,7 @@ -defmodule Cadet.Repo.Migrations.DropAnnouncementsTable do - use Ecto.Migration - - def change do - drop_if_exists(table(:announcements)) - end -end +defmodule Cadet.Repo.Migrations.DropAnnouncementsTable do + use Ecto.Migration + + def change do + drop_if_exists(table(:announcements)) + end +end diff --git a/test/cadet_web/controllers/assessments_controller_test.exs b/test/cadet_web/controllers/assessments_controller_test.exs index 709fa960e..7f9da3827 100644 --- a/test/cadet_web/controllers/assessments_controller_test.exs +++ b/test/cadet_web/controllers/assessments_controller_test.exs @@ -1,1573 +1,1573 @@ -defmodule CadetWeb.AssessmentsControllerTest do - use CadetWeb.ConnCase - use Timex - - import Ecto.Query - import Mock - - alias Cadet.{Assessments, Repo} - alias Cadet.Accounts.{Role, CourseRegistration} - alias Cadet.Assessments.{Assessment, Submission, SubmissionStatus} - alias Cadet.Autograder.GradingJob - alias CadetWeb.AssessmentsController - - @local_name "test/fixtures/local_repo" - - setup do - File.rm_rf!(@local_name) - - on_exit(fn -> - File.rm_rf!(@local_name) - end) - - Cadet.Test.Seeds.assessments() - end - - test "swagger" do - AssessmentsController.swagger_definitions() - AssessmentsController.swagger_path_index(nil) - AssessmentsController.swagger_path_show(nil) - AssessmentsController.swagger_path_unlock(nil) - AssessmentsController.swagger_path_submit(nil) - end - - describe "GET /, unauthenticated" do - test "unauthorized", %{conn: conn, courses: %{course1: course1}} do - conn = get(conn, build_url(course1.id)) - assert response(conn, 401) =~ "Unauthorised" - end - end - - describe "GET /:assessment_id, unauthenticated" do - test "unauthorized", %{conn: conn, courses: %{course1: course1}} do - conn = get(conn, build_url(course1.id, 1)) - assert response(conn, 401) =~ "Unauthorised" - end - end - - # All roles should see almost the same overview - describe "GET /, all roles" do - test "renders assessments overview", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for {_role, course_reg} <- role_crs do - expected = - assessments - |> Map.values() - |> Enum.map(& &1.assessment) - |> Enum.sort(&open_at_asc_comparator/2) - |> Enum.map( - &%{ - "courseId" => &1.course_id, - "id" => &1.id, - "title" => &1.title, - "shortSummary" => &1.summary_short, - "story" => &1.story, - "number" => &1.number, - "reading" => &1.reading, - "openAt" => format_datetime(&1.open_at), - "closeAt" => format_datetime(&1.close_at), - "type" => &1.config.type, - "isManuallyGraded" => &1.config.is_manually_graded, - "coverImage" => &1.cover_picture, - "maxXp" => 4800, - "status" => get_assessment_status(course_reg, &1), - "private" => false, - "isPublished" => &1.is_published, - "gradedCount" => 0, - "questionCount" => 9 - } - ) - - resp = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id)) - |> json_response(200) - |> Enum.map(&Map.delete(&1, "xp")) - - assert expected == resp - end - end - - test "render password protected assessments properly", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessment_configs: configs, - assessments: assessments - } do - for {_role, course_reg} <- role_crs do - mission = assessments[hd(configs).type] - - {:ok, _} = - mission.assessment - |> Assessment.changeset(%{password: "mysupersecretpassword"}) - |> Repo.update() - - resp = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id)) - |> json_response(200) - |> Enum.find(&(&1["type"] == hd(configs).type)) - |> Map.get("private") - - assert resp == true - end - end - end - - describe "GET /, student only" do - test "does not render unpublished assessments", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: %{student: student}, - assessment_configs: configs, - assessments: assessments - } do - mission = assessments[hd(configs).type] - - {:ok, _} = - mission.assessment - |> Assessment.changeset(%{is_published: false}) - |> Repo.update() - - expected = - assessments - |> Map.delete(hd(configs).type) - |> Map.values() - |> Enum.map(fn a -> a.assessment end) - |> Enum.sort(&open_at_asc_comparator/2) - |> Enum.map( - &%{ - "courseId" => &1.course_id, - "id" => &1.id, - "title" => &1.title, - "shortSummary" => &1.summary_short, - "story" => &1.story, - "number" => &1.number, - "reading" => &1.reading, - "openAt" => format_datetime(&1.open_at), - "closeAt" => format_datetime(&1.close_at), - "type" => &1.config.type, - "isManuallyGraded" => &1.config.is_manually_graded, - "coverImage" => &1.cover_picture, - "maxXp" => 4800, - "status" => get_assessment_status(student, &1), - "private" => false, - "isPublished" => &1.is_published, - "gradedCount" => 0, - "questionCount" => 9 - } - ) - - resp = - conn - |> sign_in(student.user) - |> get(build_url(course1.id)) - |> json_response(200) - |> Enum.map(&Map.delete(&1, "xp")) - - assert expected == resp - end - - test "renders student submission status in overview", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: %{student: student}, - assessment_configs: configs, - assessments: assessments - } do - assessment = assessments[hd(configs).type].assessment - [submission | _] = assessments[hd(configs).type].submissions - - for status <- SubmissionStatus.__enum_map__() do - submission - |> Submission.changeset(%{status: status}) - |> Repo.update() - - resp = - conn - |> sign_in(student.user) - |> get(build_url(course1.id)) - |> json_response(200) - |> Enum.find(&(&1["id"] == assessment.id)) - |> Map.get("status") - - assert get_assessment_status(student, assessment) == resp - end - end - - test "renders xp for students", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: %{student: student}, - assessment_configs: configs, - assessments: assessments - } do - assessment = assessments[hd(configs).type].assessment - - resp = - conn - |> sign_in(student.user) - |> get(build_url(course1.id)) - |> json_response(200) - |> Enum.find(&(&1["id"] == assessment.id)) - |> Map.get("xp") - - assert resp == 800 * 3 + 500 * 3 + 100 * 3 - end - end - - describe "GET /, non-students" do - test "renders unpublished assessments", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessment_configs: configs, - assessments: assessments - } do - for role <- ~w(staff admin)a do - course_reg = Map.get(role_crs, role) - mission = assessments[hd(configs).type] - - {:ok, _} = - mission.assessment - |> Assessment.changeset(%{is_published: false}) - |> Repo.update() - - resp = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id)) - |> json_response(200) - |> Enum.map(&Map.delete(&1, "xp")) - - expected = - assessments - |> Map.values() - |> Enum.map(fn a -> a.assessment end) - |> Enum.sort(&open_at_asc_comparator/2) - |> Enum.map( - &%{ - "id" => &1.id, - "courseId" => &1.course_id, - "title" => &1.title, - "shortSummary" => &1.summary_short, - "story" => &1.story, - "number" => &1.number, - "reading" => &1.reading, - "openAt" => format_datetime(&1.open_at), - "closeAt" => format_datetime(&1.close_at), - "type" => &1.config.type, - "isManuallyGraded" => &1.config.is_manually_graded, - "coverImage" => &1.cover_picture, - "maxXp" => 4800, - "status" => get_assessment_status(course_reg, &1), - "private" => false, - "gradedCount" => 0, - "questionCount" => 9, - "isPublished" => - if &1.config.type == hd(configs).type do - false - else - &1.is_published - end - } - ) - - assert expected == resp - end - end - end - - describe "GET /assessment_id, all roles" do - test "it renders assessment details", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for role <- Role.__enum_map__() do - course_reg = Map.get(role_crs, role) - - for {type, %{assessment: assessment}} <- assessments do - expected_assessments = %{ - "courseId" => assessment.course_id, - "id" => assessment.id, - "title" => assessment.title, - "type" => type, - "story" => assessment.story, - "number" => assessment.number, - "reading" => assessment.reading, - "longSummary" => assessment.summary_long, - "missionPDF" => Cadet.Assessments.Upload.url({assessment.mission_pdf, assessment}) - } - - resp_assessments = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.delete("questions") - - assert expected_assessments == resp_assessments - end - end - end - - test "it renders assessment questions", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for role <- Role.__enum_map__() do - course_reg = Map.get(role_crs, role) - - for {_type, - %{ - assessment: assessment, - mcq_questions: mcq_questions, - programming_questions: programming_questions, - voting_questions: voting_questions - }} <- assessments do - # Programming questions should come first due to seeding order - expected_programming_questions = - Enum.map( - programming_questions, - &%{ - "id" => &1.id, - "type" => "#{&1.type}", - "blocking" => &1.blocking, - "content" => &1.question.content, - "solutionTemplate" => &1.question.template, - "prepend" => &1.question.prepend, - "postpend" => &1.question.postpend, - "testcases" => - Enum.map( - &1.question.public, - fn testcase -> - for {k, v} <- testcase, - into: %{"type" => "public"}, - do: {Atom.to_string(k), v} - end - ) ++ - Enum.map( - &1.question.opaque, - fn testcase -> - for {k, v} <- testcase, - into: %{"type" => "opaque"}, - do: {Atom.to_string(k), v} - end - ) - } - ) - - expected_mcq_questions = - Enum.map( - mcq_questions, - &%{ - "id" => &1.id, - "type" => "#{&1.type}", - "blocking" => &1.blocking, - "content" => &1.question.content, - "choices" => - Enum.map( - &1.question.choices, - fn choice -> - %{ - "id" => choice.choice_id, - "content" => choice.content, - "hint" => choice.hint - } - end - ) - } - ) - - expected_voting_questions = - Enum.map( - voting_questions, - &%{ - "id" => &1.id, - "type" => "#{&1.type}", - "blocking" => &1.blocking, - "content" => &1.question.content, - "solutionTemplate" => &1.question.template, - "prepend" => &1.question.prepend - } - ) - - contests_submissions = - Enum.map(0..2, fn _ -> Enum.map(0..2, fn _ -> insert(:submission) end) end) - - contests_answers = - Enum.map(contests_submissions, fn contest_submissions -> - Enum.map(contest_submissions, fn submission -> - insert(:answer, %{ - submission: submission, - answer: %{code: "return 2;"}, - question: build(:programming_question) - }) - end) - end) - - voting_questions - |> Enum.zip(contests_submissions) - |> Enum.map(fn {question, contest_submissions} -> - Enum.map(contest_submissions, fn submission -> - insert(:submission_vote, %{ - voter: course_reg, - submission: submission, - question: question - }) - end) - end) - - contests_entries = - Enum.map(contests_answers, fn contest_answers -> - Enum.map(contest_answers, fn answer -> - %{ - "submission_id" => answer.submission.id, - "answer" => %{"code" => answer.answer.code}, - "score" => nil - } - end) - end) - - expected_voting_questions = - expected_voting_questions - |> Enum.zip(contests_entries) - |> Enum.map(fn {question, contest_entries} -> - question = Map.put(question, "contestEntries", contest_entries) - Map.put(question, "contestLeaderboard", []) - end) - - expected_questions = - expected_programming_questions ++ expected_mcq_questions ++ expected_voting_questions - - resp_questions = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.map(&Map.delete(&1, "answer")) - |> Enum.map(&Map.delete(&1, "solution")) - |> Enum.map(&Map.delete(&1, "library")) - |> Enum.map(&Map.delete(&1, "xp")) - |> Enum.map(&Map.delete(&1, "maxXp")) - |> Enum.map(&Map.delete(&1, "grader")) - |> Enum.map(&Map.delete(&1, "gradedAt")) - |> Enum.map(&Map.delete(&1, "autogradingResults")) - |> Enum.map(&Map.delete(&1, "autogradingStatus")) - |> Enum.map(&Map.delete(&1, "comments")) - - assert expected_questions == resp_questions - end - end - end - - test "renders open leaderboard for all roles", %{ - conn: conn, - course_regs: course_regs, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - voting_assessment = assessments["practical"].assessment - - voting_assessment - |> Assessment.changeset(%{ - open_at: Timex.shift(Timex.now(), days: -30), - close_at: Timex.shift(Timex.now(), days: -20) - }) - |> Repo.update() - - voting_question = assessments["practical"].voting_questions |> List.first() - contest_assessment_number = voting_question.question.contest_number - - contest_assessment = Repo.get_by(Assessment, number: contest_assessment_number) - - # insert contest question - contest_question = - insert(:programming_question, %{ - display_order: 1, - assessment: contest_assessment, - max_xp: 1000 - }) - - # insert contest submissions and answers - contest_submissions = - for student <- Enum.take(course_regs.students, 5) do - insert(:submission, %{assessment: contest_assessment, student: student}) - end - - contest_answers = - for {submission, score} <- Enum.with_index(contest_submissions, 1) do - insert(:answer, %{ - xp: 1000, - question: contest_question, - submission: submission, - answer: build(:programming_answer), - relative_score: score / 1 - }) - end - - expected_leaderboard = - for answer <- contest_answers do - %{ - "answer" => %{"code" => answer.answer.code}, - "final_score" => answer.relative_score, - "student_name" => answer.submission.student.user.name, - "submission_id" => answer.submission.id - } - end - |> Enum.sort_by(& &1["final_score"], &>=/2) - - for role <- Role.__enum_map__() do - course_reg = Map.get(role_crs, role) - - resp_leaderboard = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, voting_question.assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.find(&(&1["id"] == voting_question.id)) - |> Map.get("contestLeaderboard") - - assert resp_leaderboard == expected_leaderboard - end - end - - test "renders close leaderboard for staff and admin", %{ - conn: conn, - course_regs: course_regs, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - voting_assessment = assessments["practical"].assessment - - voting_assessment - |> Assessment.changeset(%{ - close_at: Timex.shift(Timex.now(), days: 20) - }) - |> Repo.update() - - voting_question = assessments["practical"].voting_questions |> List.first() - contest_assessment_number = voting_question.question.contest_number - - contest_assessment = Repo.get_by(Assessment, number: contest_assessment_number) - - # insert contest question - contest_question = - insert(:programming_question, %{ - display_order: 1, - assessment: contest_assessment, - max_xp: 1000 - }) - - # insert contest submissions and answers - contest_submissions = - for student <- Enum.take(course_regs.students, 5) do - insert(:submission, %{assessment: contest_assessment, student: student}) - end - - contest_answers = - for {submission, score} <- Enum.with_index(contest_submissions, 1) do - insert(:answer, %{ - xp: 1000, - question: contest_question, - submission: submission, - answer: build(:programming_answer), - relative_score: score / 1 - }) - end - - expected_leaderboard = - for answer <- contest_answers do - %{ - "answer" => %{"code" => answer.answer.code}, - "final_score" => answer.relative_score, - "student_name" => answer.submission.student.user.name, - "submission_id" => answer.submission.id - } - end - |> Enum.sort_by(& &1["final_score"], &>=/2) - - for role <- [:admin, :staff] do - course_reg = Map.get(role_crs, role) - - resp_leaderboard = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, voting_question.assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.find(&(&1["id"] == voting_question.id)) - |> Map.get("contestLeaderboard") - - assert resp_leaderboard == expected_leaderboard - end - end - - test "does not render close leaderboard for students", %{ - conn: conn, - course_regs: course_regs, - courses: %{course1: course1}, - role_crs: %{student: course_reg}, - assessments: assessments - } do - voting_assessment = assessments["practical"].assessment - - voting_assessment - |> Assessment.changeset(%{ - close_at: Timex.shift(Timex.now(), days: 20) - }) - |> Repo.update() - - voting_question = assessments["practical"].voting_questions |> List.first() - contest_assessment_number = voting_question.question.contest_number - - contest_assessment = Repo.get_by(Assessment, number: contest_assessment_number) - - # insert contest question - contest_question = - insert(:programming_question, %{ - display_order: 1, - assessment: contest_assessment, - max_xp: 1000 - }) - - # insert contest submissions and answers - contest_submissions = - for student <- Enum.take(course_regs.students, 5) do - insert(:submission, %{assessment: contest_assessment, student: student}) - end - - _contest_answers = - for {submission, score} <- Enum.with_index(contest_submissions, 1) do - insert(:answer, %{ - xp: 1000, - question: contest_question, - submission: submission, - answer: build(:programming_answer), - relative_score: score / 1 - }) - end - - expected_leaderboard = [] - - resp_leaderboard = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, voting_question.assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.find(&(&1["id"] == voting_question.id)) - |> Map.get("contestLeaderboard") - - assert resp_leaderboard == expected_leaderboard - end - - test "it renders assessment question libraries", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for role <- Role.__enum_map__() do - course_reg = Map.get(role_crs, role) - - for {_type, - %{ - assessment: assessment, - mcq_questions: mcq_questions, - programming_questions: programming_questions, - voting_question: voting_questions - }} <- assessments do - # Programming questions should come first due to seeding order - - expected_libraries = - (programming_questions ++ mcq_questions ++ voting_questions) - |> Enum.map(&Map.get(&1, :library)) - |> Enum.map( - &%{ - "chapter" => &1.chapter, - "globals" => &1.globals, - "external" => %{ - "name" => "#{&1.external.name}", - "symbols" => &1.external.symbols - } - } - ) - - resp_libraries = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.map(&Map.get(&1, "library")) - - assert resp_libraries == expected_libraries - end - end - end - - test "it renders solutions for ungraded assessments (path)", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for role <- Role.__enum_map__() do - course_reg = Map.get(role_crs, role) - - %{ - assessment: assessment, - mcq_questions: mcq_questions, - programming_questions: programming_questions, - voting_questions: voting_questions - } = assessments["path"] - - # This is the case cuz the seed set "path" to build_soultion = true - - # Seeds set solution as 0 - expected_mcq_solutions = Enum.map(mcq_questions, fn _ -> %{"solution" => 0} end) - - expected_programming_solutions = - Enum.map(programming_questions, &%{"solution" => &1.question.solution}) - - # No solution in a voting question - expected_voting_solutions = Enum.map(voting_questions, fn _ -> %{"solution" => nil} end) - - expected_solutions = - Enum.sort( - expected_mcq_solutions ++ expected_programming_solutions ++ expected_voting_solutions - ) - - resp_solutions = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.map(&Map.take(&1, ["solution"])) - |> Enum.sort() - - assert expected_solutions == resp_solutions - end - end - - test "it renders xp, grade for students", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for role <- Role.__enum_map__() do - course_reg = Map.get(role_crs, role) - - for {_type, - %{ - assessment: assessment, - mcq_answers: [mcq_answers | _], - programming_answers: [programming_answers | _], - voting_answers: [voting_answers | _] - }} <- assessments do - expected = - if role == :student do - Enum.map( - programming_answers ++ mcq_answers ++ voting_answers, - &%{ - "xp" => &1.xp + &1.xp_adjustment - } - ) - else - fn -> %{"xp" => 0} end - |> Stream.repeatedly() - |> Enum.take( - length(programming_answers) + length(mcq_answers) + length(voting_answers) - ) - end - - resp = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.map(&Map.take(&1, ~w(xp))) - - assert expected == resp - end - end - end - - test "it does not render solutions for ungraded assessments (path)", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for role <- Role.__enum_map__() do - course_reg = Map.get(role_crs, role) - - for {_type, - %{ - assessment: assessment - }} <- Map.delete(assessments, "path") do - resp_solutions = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.map(&Map.get(&1, ["solution"])) - - assert Enum.uniq(resp_solutions) == [nil] - end - end - end - end - - describe "GET /assessment_id, student" do - test "it renders previously submitted answers", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: %{student: student}, - assessments: assessments - } do - for {_type, - %{ - assessment: assessment, - mcq_answers: [mcq_answers | _], - programming_answers: [programming_answers | _], - voting_answers: [voting_answers | _] - }} <- assessments do - # Programming questions should come first due to seeding order - expected_programming_answers = - Enum.map(programming_answers, &%{"answer" => &1.answer.code}) - - expected_mcq_answers = Enum.map(mcq_answers, &%{"answer" => &1.answer.choice_id}) - - # Answers are not rendered for voting questions - expected_voting_answers = Enum.map(voting_answers, fn _ -> %{"answer" => nil} end) - - expected_answers = - expected_programming_answers ++ expected_mcq_answers ++ expected_voting_answers - - resp_answers = - conn - |> sign_in(student.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.map(&Map.take(&1, ["answer"])) - - assert expected_answers == resp_answers - end - end - - test "it does not permit access to not yet open assessments", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: %{student: student}, - assessments: %{"mission" => mission} - } do - mission.assessment - |> Assessment.changeset(%{ - open_at: Timex.shift(Timex.now(), days: 5), - close_at: Timex.shift(Timex.now(), days: 10) - }) - |> Repo.update!() - - conn = - conn - |> sign_in(student.user) - |> get(build_url(course1.id, mission.assessment.id)) - - assert response(conn, 401) == "Assessment not open" - end - - test "it does not permit access to unpublished assessments", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: %{student: student}, - assessments: %{"mission" => mission} - } do - {:ok, _} = - mission.assessment - |> Assessment.changeset(%{is_published: false}) - |> Repo.update() - - conn = - conn - |> sign_in(student.user) - |> get(build_url(course1.id, mission.assessment.id)) - - assert response(conn, 400) == "Assessment not found" - end - end - - describe "GET /assessment_id, non-students" do - test "it renders empty answers", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for role <- ~w(staff admin)a do - course_reg = Map.get(role_crs, role) - - for {_type, %{assessment: assessment}} <- assessments do - resp_answers = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.map(&Map.get(&1, ["answer"])) - - assert Enum.uniq(resp_answers) == [nil] - end - end - end - - test "it permits access to not yet open assessments", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: %{"mission" => mission} - } do - for role <- ~w(staff admin)a do - course_reg = Map.get(role_crs, role) - - mission.assessment - |> Assessment.changeset(%{ - open_at: Timex.shift(Timex.now(), days: 5), - close_at: Timex.shift(Timex.now(), days: 10) - }) - |> Repo.update!() - - resp = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, mission.assessment.id)) - |> json_response(200) - - assert resp["id"] == mission.assessment.id - end - end - - test "it permits access to unpublished assessments", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: %{"mission" => mission} - } do - for role <- ~w(staff admin)a do - course_reg = Map.get(role_crs, role) - - {:ok, _} = - mission.assessment - |> Assessment.changeset(%{is_published: false}) - |> Repo.update() - - resp = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, mission.assessment.id)) - |> json_response(200) - - assert resp["id"] == mission.assessment.id - end - end - end - - describe "GET /assessment_id/submit unauthenticated" do - test "is not permitted", %{ - conn: conn, - courses: %{course1: course1}, - assessments: %{"mission" => %{assessment: assessment}} - } do - conn = post(conn, build_url_submit(course1.id, assessment.id)) - assert response(conn, 401) == "Unauthorised" - end - end - - describe "GET /assessment_id/submit students" do - for role <- ~w(student staff admin)a do - @tag role: role - test "is successful for attempted assessments for #{role}", %{ - conn: conn, - courses: %{course1: course1}, - assessments: %{"mission" => %{assessment: assessment}}, - role_crs: role_crs, - role: role - } do - with_mock GradingJob, - force_grade_individual_submission: fn _ -> nil end do - group = - if(role == :student, - do: insert(:group, %{course: course1, leader: role_crs.staff}), - else: nil - ) - - course_reg = insert(:course_registration, %{role: role, group: group, course: course1}) - - submission = - insert(:submission, %{student: course_reg, assessment: assessment, status: :attempted}) - - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - - assert response(conn, 200) == "OK" - - # Preloading is necessary because Mock does an exact match, including metadata - submission_db = Submission |> Repo.get(submission.id) |> Repo.preload(:assessment) - - assert submission_db.status == :submitted - - assert_called(GradingJob.force_grade_individual_submission(submission_db)) - end - end - end - - test "submission of answer within early hours(seeded 48) of opening grants full XP bonus", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs - } do - with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do - assessment_config = - insert( - :assessment_config, - early_submission_xp: 100, - hours_before_early_xp_decay: 48, - course: course1 - ) - - assessment = - insert( - :assessment, - open_at: Timex.shift(Timex.now(), hours: -40), - close_at: Timex.shift(Timex.now(), days: 7), - is_published: true, - config: assessment_config, - course: course1 - ) - - question = insert(:programming_question, assessment: assessment) - - group = insert(:group, leader: role_crs.staff) - - course_reg = - insert(:course_registration, %{role: :student, group: group, course: course1}) - - submission = - insert(:submission, assessment: assessment, student: course_reg, status: :attempted) - - insert( - :answer, - submission: submission, - question: question, - answer: %{code: "f => f(f);"} - ) - - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - |> response(200) - - submission_db = Repo.get(Submission, submission.id) - - assert submission_db.status == :submitted - assert submission_db.xp_bonus == 100 - end - end - - test "submission of answer after early hours before deadline get decaying XP bonus", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs - } do - with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do - for hours_after <- 48..148 do - assessment_config = - insert( - :assessment_config, - early_submission_xp: 100, - hours_before_early_xp_decay: 48, - course: course1 - ) - - assessment = - insert( - :assessment, - open_at: Timex.shift(Timex.now(), hours: -hours_after), - close_at: Timex.shift(Timex.now(), hours: 100), - is_published: true, - config: assessment_config, - course: course1 - ) - - question = insert(:programming_question, assessment: assessment) - - group = insert(:group, leader: role_crs.staff) - - course_reg = - insert(:course_registration, %{role: :student, group: group, course: course1}) - - submission = - insert(:submission, assessment: assessment, student: course_reg, status: :attempted) - - insert( - :answer, - submission: submission, - question: question, - answer: %{code: "f => f(f);"} - ) - - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - |> response(200) - - submission_db = Repo.get(Submission, submission.id) - - proportion = - Timex.diff(assessment.close_at, Timex.now(), :hours) / (100 + hours_after - 48) - - assert submission_db.status == :submitted - assert submission_db.xp_bonus == round(proportion * 100) - end - end - end - - test "submission of answer at the last hour yield 0 XP bonus", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs - } do - with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do - for hours_after <- 48..148 do - assessment_config = - insert( - :assessment_config, - early_submission_xp: 100, - hours_before_early_xp_decay: 48, - course: course1 - ) - - assessment = - insert( - :assessment, - open_at: Timex.shift(Timex.now(), hours: -hours_after), - close_at: Timex.shift(Timex.now(), hours: 1), - is_published: true, - config: assessment_config, - course: course1 - ) - - question = insert(:programming_question, assessment: assessment) - - group = insert(:group, leader: role_crs.staff) - - course_reg = - insert(:course_registration, %{role: :student, group: group, course: course1}) - - submission = - insert(:submission, assessment: assessment, student: course_reg, status: :attempted) - - insert( - :answer, - submission: submission, - question: question, - answer: %{code: "f => f(f);"} - ) - - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - |> response(200) - - submission_db = Repo.get(Submission, submission.id) - - assert submission_db.status == :submitted - assert submission_db.xp_bonus == 0 - end - end - end - - test "give 0 bonus for configs with 0 max", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs - } do - with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do - for hours_after <- 0..148 do - assessment_config = - insert( - :assessment_config, - early_submission_xp: 0, - hours_before_early_xp_decay: 48, - course: course1 - ) - - assessment = - insert( - :assessment, - open_at: Timex.shift(Timex.now(), hours: -hours_after), - close_at: Timex.shift(Timex.now(), days: 7), - is_published: true, - config: assessment_config, - course: course1 - ) - - question = insert(:programming_question, assessment: assessment) - - group = insert(:group, leader: role_crs.staff) - - course_reg = - insert(:course_registration, %{role: :student, group: group, course: course1}) - - submission = - insert(:submission, assessment: assessment, student: course_reg, status: :attempted) - - insert( - :answer, - submission: submission, - question: question, - answer: %{code: "f => f(f);"} - ) - - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - |> response(200) - - submission_db = Repo.get(Submission, submission.id) - - assert submission_db.status == :submitted - assert submission_db.xp_bonus == 0 - end - end - end - - # This also covers unpublished and assessments that are not open yet since they cannot be - # answered. - test "is not permitted for unattempted assessments", %{ - conn: conn, - courses: %{course1: course1}, - assessments: %{"mission" => %{assessment: assessment}} - } do - course_reg = insert(:course_registration, %{role: :student, course: course1}) - - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - - assert response(conn, 404) == "Submission not found" - end - - test "is not permitted for incomplete assessments", %{ - conn: conn, - courses: %{course1: course1}, - assessments: %{"mission" => %{assessment: assessment}} - } do - course_reg = insert(:course_registration, %{role: :student, course: course1}) - insert(:submission, %{student: course_reg, assessment: assessment, status: :attempting}) - - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - - assert response(conn, 400) == "Some questions have not been attempted" - end - - test "is not permitted for already submitted assessments", %{ - conn: conn, - courses: %{course1: course1}, - assessments: %{"mission" => %{assessment: assessment}} - } do - course_reg = insert(:course_registration, %{role: :student, course: course1}) - insert(:submission, %{student: course_reg, assessment: assessment, status: :submitted}) - - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - - assert response(conn, 403) == "Assessment has already been submitted" - end - - test "is not permitted for closed assessments", %{conn: conn, courses: %{course1: course1}} do - course_reg = insert(:course_registration, %{role: :student, course: course1}) - - # Only check for after-closing because submission shouldn't exist if unpublished or - # before opening and would fall under "Submission not found" - after_close_at_assessment = - insert(:assessment, %{ - open_at: Timex.shift(Timex.now(), days: -10), - close_at: Timex.shift(Timex.now(), days: -5), - course: course1 - }) - - insert(:submission, %{ - student: course_reg, - assessment: after_close_at_assessment, - status: :attempted - }) - - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, after_close_at_assessment.id)) - - assert response(conn, 403) == "Assessment not open" - end - - test "not found if not in same course", %{ - conn: conn, - courses: %{course2: course2}, - role_crs: %{student: student}, - assessments: %{"mission" => %{assessment: assessment}} - } do - # user is in both course, but assessment belongs to a course and no submission will be found - conn = - conn - |> sign_in(student.user) - |> post(build_url_submit(course2.id, assessment.id)) - - assert response(conn, 404) == "Submission not found" - end - - test "forbidden if not in course", %{ - conn: conn, - courses: %{course2: course2}, - course_regs: %{students: students}, - assessments: %{"mission" => %{assessment: assessment}} - } do - # user is not in the course - student2 = hd(tl(students)) - - conn = - conn - |> sign_in(student2.user) - |> post(build_url_submit(course2.id, assessment.id)) - - assert response(conn, 403) == "Forbidden" - end - end - - test "graded count is updated when assessment is graded", %{ - conn: conn, - courses: %{course1: course1}, - assessment_configs: [config | _], - role_crs: %{staff: avenger} - } do - assessment = - insert( - :assessment, - open_at: Timex.shift(Timex.now(), hours: -2), - close_at: Timex.shift(Timex.now(), days: 7), - is_published: true, - config: config, - course: course1 - ) - - [question_one, question_two] = insert_list(2, :programming_question, assessment: assessment) - - course_reg = insert(:course_registration, role: :student, course: course1) - - submission = - insert(:submission, assessment: assessment, student: course_reg, status: :submitted) - - Enum.each( - [question_one, question_two], - &insert(:answer, submission: submission, question: &1, answer: %{code: "f => f(f);"}) - ) - - get_graded_count = fn -> - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id)) - |> json_response(200) - |> Enum.find(&(&1["id"] == assessment.id)) - |> Map.get("gradedCount") - end - - grade_question = fn question -> - Assessments.update_grading_info( - %{submission_id: submission.id, question_id: question.id}, - %{"xp_adjustment" => 0}, - avenger - ) - end - - assert get_graded_count.() == 0 - - grade_question.(question_one) - - assert get_graded_count.() == 1 - - grade_question.(question_two) - - assert get_graded_count.() == 2 - end - - describe "Password protected assessments render properly" do - setup %{courses: %{course1: course1}, assessment_configs: configs} do - assessment = - insert(:assessment, %{config: Enum.at(configs, 4), course: course1, is_published: true}) - - assessment - |> Assessment.changeset(%{ - password: "mysupersecretpassword", - open_at: Timex.shift(Timex.now(), days: -2), - close_at: Timex.shift(Timex.now(), days: +1) - }) - |> Repo.update!() - - {:ok, protected_assessment: assessment} - end - - test "returns 403 when trying to access a password protected assessment without a password", - %{ - conn: conn, - courses: %{course1: course1}, - protected_assessment: protected_assessment, - role_crs: role_crs - } do - for {_role, course_reg} <- role_crs do - conn = - conn |> sign_in(course_reg.user) |> get(build_url(course1.id, protected_assessment.id)) - - assert response(conn, 403) == "Missing Password." - end - end - - test "returns 403 when password is wrong/invalid", %{ - conn: conn, - courses: %{course1: course1}, - protected_assessment: protected_assessment, - role_crs: role_crs - } do - for {_role, course_reg} <- role_crs do - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_unlock(course1.id, protected_assessment.id), %{:password => "wrong"}) - - assert response(conn, 403) == "Invalid Password." - end - end - - test "allow role_crs with preexisting submission to access private assessment without a password", - %{ - conn: conn, - courses: %{course1: course1}, - protected_assessment: protected_assessment, - role_crs: %{student: student} - } do - insert(:submission, %{assessment: protected_assessment, student: student}) - conn = conn |> sign_in(student.user) |> get(build_url(course1.id, protected_assessment.id)) - assert response(conn, 200) - end - - test "ignore password when assessment is not password protected", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - assessment = assessments["mission"].assessment - - for {_role, course_reg} <- role_crs do - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_unlock(course1.id, assessment.id), %{:password => "wrong"}) - |> json_response(200) - - assert conn["id"] == assessment.id - end - end - - test "render assessment when password is correct", %{ - conn: conn, - courses: %{course1: course1}, - protected_assessment: protected_assessment, - role_crs: role_crs - } do - for {_role, course_reg} <- role_crs do - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_unlock(course1.id, protected_assessment.id), %{ - :password => "mysupersecretpassword" - }) - |> json_response(200) - - assert conn["id"] == protected_assessment.id - end - end - - test "permit global access to private assessment after closed", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: %{student: student}, - assessments: %{"mission" => mission} - } do - mission.assessment - |> Assessment.changeset(%{ - open_at: Timex.shift(Timex.now(), days: -2), - close_at: Timex.shift(Timex.now(), days: -1) - }) - |> Repo.update!() - - conn = - conn - |> sign_in(student.user) - |> get(build_url(course1.id, mission.assessment.id)) - - assert response(conn, 200) - end - end - - defp build_url(course_id), do: "/v2/courses/#{course_id}/assessments/" - - defp build_url(course_id, assessment_id), - do: "/v2/courses/#{course_id}/assessments/#{assessment_id}" - - defp build_url_submit(course_id, assessment_id), - do: "/v2/courses/#{course_id}/assessments/#{assessment_id}/submit" - - defp build_url_unlock(course_id, assessment_id), - do: "/v2/courses/#{course_id}/assessments/#{assessment_id}/unlock" - - defp open_at_asc_comparator(x, y), do: Timex.before?(x.open_at, y.open_at) - - defp get_assessment_status(course_reg = %CourseRegistration{}, assessment = %Assessment{}) do - submission = - Submission - |> where(student_id: ^course_reg.id) - |> where(assessment_id: ^assessment.id) - |> Repo.one() - - (submission && submission.status |> Atom.to_string()) || "not_attempted" - end -end +defmodule CadetWeb.AssessmentsControllerTest do + use CadetWeb.ConnCase + use Timex + + import Ecto.Query + import Mock + + alias Cadet.{Assessments, Repo} + alias Cadet.Accounts.{Role, CourseRegistration} + alias Cadet.Assessments.{Assessment, Submission, SubmissionStatus} + alias Cadet.Autograder.GradingJob + alias CadetWeb.AssessmentsController + + @local_name "test/fixtures/local_repo" + + setup do + File.rm_rf!(@local_name) + + on_exit(fn -> + File.rm_rf!(@local_name) + end) + + Cadet.Test.Seeds.assessments() + end + + test "swagger" do + AssessmentsController.swagger_definitions() + AssessmentsController.swagger_path_index(nil) + AssessmentsController.swagger_path_show(nil) + AssessmentsController.swagger_path_unlock(nil) + AssessmentsController.swagger_path_submit(nil) + end + + describe "GET /, unauthenticated" do + test "unauthorized", %{conn: conn, courses: %{course1: course1}} do + conn = get(conn, build_url(course1.id)) + assert response(conn, 401) =~ "Unauthorised" + end + end + + describe "GET /:assessment_id, unauthenticated" do + test "unauthorized", %{conn: conn, courses: %{course1: course1}} do + conn = get(conn, build_url(course1.id, 1)) + assert response(conn, 401) =~ "Unauthorised" + end + end + + # All roles should see almost the same overview + describe "GET /, all roles" do + test "renders assessments overview", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for {_role, course_reg} <- role_crs do + expected = + assessments + |> Map.values() + |> Enum.map(& &1.assessment) + |> Enum.sort(&open_at_asc_comparator/2) + |> Enum.map( + &%{ + "courseId" => &1.course_id, + "id" => &1.id, + "title" => &1.title, + "shortSummary" => &1.summary_short, + "story" => &1.story, + "number" => &1.number, + "reading" => &1.reading, + "openAt" => format_datetime(&1.open_at), + "closeAt" => format_datetime(&1.close_at), + "type" => &1.config.type, + "isManuallyGraded" => &1.config.is_manually_graded, + "coverImage" => &1.cover_picture, + "maxXp" => 4800, + "status" => get_assessment_status(course_reg, &1), + "private" => false, + "isPublished" => &1.is_published, + "gradedCount" => 0, + "questionCount" => 9 + } + ) + + resp = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id)) + |> json_response(200) + |> Enum.map(&Map.delete(&1, "xp")) + + assert expected == resp + end + end + + test "render password protected assessments properly", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessment_configs: configs, + assessments: assessments + } do + for {_role, course_reg} <- role_crs do + mission = assessments[hd(configs).type] + + {:ok, _} = + mission.assessment + |> Assessment.changeset(%{password: "mysupersecretpassword"}) + |> Repo.update() + + resp = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id)) + |> json_response(200) + |> Enum.find(&(&1["type"] == hd(configs).type)) + |> Map.get("private") + + assert resp == true + end + end + end + + describe "GET /, student only" do + test "does not render unpublished assessments", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessment_configs: configs, + assessments: assessments + } do + mission = assessments[hd(configs).type] + + {:ok, _} = + mission.assessment + |> Assessment.changeset(%{is_published: false}) + |> Repo.update() + + expected = + assessments + |> Map.delete(hd(configs).type) + |> Map.values() + |> Enum.map(fn a -> a.assessment end) + |> Enum.sort(&open_at_asc_comparator/2) + |> Enum.map( + &%{ + "courseId" => &1.course_id, + "id" => &1.id, + "title" => &1.title, + "shortSummary" => &1.summary_short, + "story" => &1.story, + "number" => &1.number, + "reading" => &1.reading, + "openAt" => format_datetime(&1.open_at), + "closeAt" => format_datetime(&1.close_at), + "type" => &1.config.type, + "isManuallyGraded" => &1.config.is_manually_graded, + "coverImage" => &1.cover_picture, + "maxXp" => 4800, + "status" => get_assessment_status(student, &1), + "private" => false, + "isPublished" => &1.is_published, + "gradedCount" => 0, + "questionCount" => 9 + } + ) + + resp = + conn + |> sign_in(student.user) + |> get(build_url(course1.id)) + |> json_response(200) + |> Enum.map(&Map.delete(&1, "xp")) + + assert expected == resp + end + + test "renders student submission status in overview", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessment_configs: configs, + assessments: assessments + } do + assessment = assessments[hd(configs).type].assessment + [submission | _] = assessments[hd(configs).type].submissions + + for status <- SubmissionStatus.__enum_map__() do + submission + |> Submission.changeset(%{status: status}) + |> Repo.update() + + resp = + conn + |> sign_in(student.user) + |> get(build_url(course1.id)) + |> json_response(200) + |> Enum.find(&(&1["id"] == assessment.id)) + |> Map.get("status") + + assert get_assessment_status(student, assessment) == resp + end + end + + test "renders xp for students", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessment_configs: configs, + assessments: assessments + } do + assessment = assessments[hd(configs).type].assessment + + resp = + conn + |> sign_in(student.user) + |> get(build_url(course1.id)) + |> json_response(200) + |> Enum.find(&(&1["id"] == assessment.id)) + |> Map.get("xp") + + assert resp == 800 * 3 + 500 * 3 + 100 * 3 + end + end + + describe "GET /, non-students" do + test "renders unpublished assessments", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessment_configs: configs, + assessments: assessments + } do + for role <- ~w(staff admin)a do + course_reg = Map.get(role_crs, role) + mission = assessments[hd(configs).type] + + {:ok, _} = + mission.assessment + |> Assessment.changeset(%{is_published: false}) + |> Repo.update() + + resp = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id)) + |> json_response(200) + |> Enum.map(&Map.delete(&1, "xp")) + + expected = + assessments + |> Map.values() + |> Enum.map(fn a -> a.assessment end) + |> Enum.sort(&open_at_asc_comparator/2) + |> Enum.map( + &%{ + "id" => &1.id, + "courseId" => &1.course_id, + "title" => &1.title, + "shortSummary" => &1.summary_short, + "story" => &1.story, + "number" => &1.number, + "reading" => &1.reading, + "openAt" => format_datetime(&1.open_at), + "closeAt" => format_datetime(&1.close_at), + "type" => &1.config.type, + "isManuallyGraded" => &1.config.is_manually_graded, + "coverImage" => &1.cover_picture, + "maxXp" => 4800, + "status" => get_assessment_status(course_reg, &1), + "private" => false, + "gradedCount" => 0, + "questionCount" => 9, + "isPublished" => + if &1.config.type == hd(configs).type do + false + else + &1.is_published + end + } + ) + + assert expected == resp + end + end + end + + describe "GET /assessment_id, all roles" do + test "it renders assessment details", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for role <- Role.__enum_map__() do + course_reg = Map.get(role_crs, role) + + for {type, %{assessment: assessment}} <- assessments do + expected_assessments = %{ + "courseId" => assessment.course_id, + "id" => assessment.id, + "title" => assessment.title, + "type" => type, + "story" => assessment.story, + "number" => assessment.number, + "reading" => assessment.reading, + "longSummary" => assessment.summary_long, + "missionPDF" => Cadet.Assessments.Upload.url({assessment.mission_pdf, assessment}) + } + + resp_assessments = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.delete("questions") + + assert expected_assessments == resp_assessments + end + end + end + + test "it renders assessment questions", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for role <- Role.__enum_map__() do + course_reg = Map.get(role_crs, role) + + for {_type, + %{ + assessment: assessment, + mcq_questions: mcq_questions, + programming_questions: programming_questions, + voting_questions: voting_questions + }} <- assessments do + # Programming questions should come first due to seeding order + expected_programming_questions = + Enum.map( + programming_questions, + &%{ + "id" => &1.id, + "type" => "#{&1.type}", + "blocking" => &1.blocking, + "content" => &1.question.content, + "solutionTemplate" => &1.question.template, + "prepend" => &1.question.prepend, + "postpend" => &1.question.postpend, + "testcases" => + Enum.map( + &1.question.public, + fn testcase -> + for {k, v} <- testcase, + into: %{"type" => "public"}, + do: {Atom.to_string(k), v} + end + ) ++ + Enum.map( + &1.question.opaque, + fn testcase -> + for {k, v} <- testcase, + into: %{"type" => "opaque"}, + do: {Atom.to_string(k), v} + end + ) + } + ) + + expected_mcq_questions = + Enum.map( + mcq_questions, + &%{ + "id" => &1.id, + "type" => "#{&1.type}", + "blocking" => &1.blocking, + "content" => &1.question.content, + "choices" => + Enum.map( + &1.question.choices, + fn choice -> + %{ + "id" => choice.choice_id, + "content" => choice.content, + "hint" => choice.hint + } + end + ) + } + ) + + expected_voting_questions = + Enum.map( + voting_questions, + &%{ + "id" => &1.id, + "type" => "#{&1.type}", + "blocking" => &1.blocking, + "content" => &1.question.content, + "solutionTemplate" => &1.question.template, + "prepend" => &1.question.prepend + } + ) + + contests_submissions = + Enum.map(0..2, fn _ -> Enum.map(0..2, fn _ -> insert(:submission) end) end) + + contests_answers = + Enum.map(contests_submissions, fn contest_submissions -> + Enum.map(contest_submissions, fn submission -> + insert(:answer, %{ + submission: submission, + answer: %{code: "return 2;"}, + question: build(:programming_question) + }) + end) + end) + + voting_questions + |> Enum.zip(contests_submissions) + |> Enum.map(fn {question, contest_submissions} -> + Enum.map(contest_submissions, fn submission -> + insert(:submission_vote, %{ + voter: course_reg, + submission: submission, + question: question + }) + end) + end) + + contests_entries = + Enum.map(contests_answers, fn contest_answers -> + Enum.map(contest_answers, fn answer -> + %{ + "submission_id" => answer.submission.id, + "answer" => %{"code" => answer.answer.code}, + "score" => nil + } + end) + end) + + expected_voting_questions = + expected_voting_questions + |> Enum.zip(contests_entries) + |> Enum.map(fn {question, contest_entries} -> + question = Map.put(question, "contestEntries", contest_entries) + Map.put(question, "contestLeaderboard", []) + end) + + expected_questions = + expected_programming_questions ++ expected_mcq_questions ++ expected_voting_questions + + resp_questions = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.map(&Map.delete(&1, "answer")) + |> Enum.map(&Map.delete(&1, "solution")) + |> Enum.map(&Map.delete(&1, "library")) + |> Enum.map(&Map.delete(&1, "xp")) + |> Enum.map(&Map.delete(&1, "maxXp")) + |> Enum.map(&Map.delete(&1, "grader")) + |> Enum.map(&Map.delete(&1, "gradedAt")) + |> Enum.map(&Map.delete(&1, "autogradingResults")) + |> Enum.map(&Map.delete(&1, "autogradingStatus")) + |> Enum.map(&Map.delete(&1, "comments")) + + assert expected_questions == resp_questions + end + end + end + + test "renders open leaderboard for all roles", %{ + conn: conn, + course_regs: course_regs, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + voting_assessment = assessments["practical"].assessment + + voting_assessment + |> Assessment.changeset(%{ + open_at: Timex.shift(Timex.now(), days: -30), + close_at: Timex.shift(Timex.now(), days: -20) + }) + |> Repo.update() + + voting_question = assessments["practical"].voting_questions |> List.first() + contest_assessment_number = voting_question.question.contest_number + + contest_assessment = Repo.get_by(Assessment, number: contest_assessment_number) + + # insert contest question + contest_question = + insert(:programming_question, %{ + display_order: 1, + assessment: contest_assessment, + max_xp: 1000 + }) + + # insert contest submissions and answers + contest_submissions = + for student <- Enum.take(course_regs.students, 5) do + insert(:submission, %{assessment: contest_assessment, student: student}) + end + + contest_answers = + for {submission, score} <- Enum.with_index(contest_submissions, 1) do + insert(:answer, %{ + xp: 1000, + question: contest_question, + submission: submission, + answer: build(:programming_answer), + relative_score: score / 1 + }) + end + + expected_leaderboard = + for answer <- contest_answers do + %{ + "answer" => %{"code" => answer.answer.code}, + "final_score" => answer.relative_score, + "student_name" => answer.submission.student.user.name, + "submission_id" => answer.submission.id + } + end + |> Enum.sort_by(& &1["final_score"], &>=/2) + + for role <- Role.__enum_map__() do + course_reg = Map.get(role_crs, role) + + resp_leaderboard = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, voting_question.assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.find(&(&1["id"] == voting_question.id)) + |> Map.get("contestLeaderboard") + + assert resp_leaderboard == expected_leaderboard + end + end + + test "renders close leaderboard for staff and admin", %{ + conn: conn, + course_regs: course_regs, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + voting_assessment = assessments["practical"].assessment + + voting_assessment + |> Assessment.changeset(%{ + close_at: Timex.shift(Timex.now(), days: 20) + }) + |> Repo.update() + + voting_question = assessments["practical"].voting_questions |> List.first() + contest_assessment_number = voting_question.question.contest_number + + contest_assessment = Repo.get_by(Assessment, number: contest_assessment_number) + + # insert contest question + contest_question = + insert(:programming_question, %{ + display_order: 1, + assessment: contest_assessment, + max_xp: 1000 + }) + + # insert contest submissions and answers + contest_submissions = + for student <- Enum.take(course_regs.students, 5) do + insert(:submission, %{assessment: contest_assessment, student: student}) + end + + contest_answers = + for {submission, score} <- Enum.with_index(contest_submissions, 1) do + insert(:answer, %{ + xp: 1000, + question: contest_question, + submission: submission, + answer: build(:programming_answer), + relative_score: score / 1 + }) + end + + expected_leaderboard = + for answer <- contest_answers do + %{ + "answer" => %{"code" => answer.answer.code}, + "final_score" => answer.relative_score, + "student_name" => answer.submission.student.user.name, + "submission_id" => answer.submission.id + } + end + |> Enum.sort_by(& &1["final_score"], &>=/2) + + for role <- [:admin, :staff] do + course_reg = Map.get(role_crs, role) + + resp_leaderboard = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, voting_question.assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.find(&(&1["id"] == voting_question.id)) + |> Map.get("contestLeaderboard") + + assert resp_leaderboard == expected_leaderboard + end + end + + test "does not render close leaderboard for students", %{ + conn: conn, + course_regs: course_regs, + courses: %{course1: course1}, + role_crs: %{student: course_reg}, + assessments: assessments + } do + voting_assessment = assessments["practical"].assessment + + voting_assessment + |> Assessment.changeset(%{ + close_at: Timex.shift(Timex.now(), days: 20) + }) + |> Repo.update() + + voting_question = assessments["practical"].voting_questions |> List.first() + contest_assessment_number = voting_question.question.contest_number + + contest_assessment = Repo.get_by(Assessment, number: contest_assessment_number) + + # insert contest question + contest_question = + insert(:programming_question, %{ + display_order: 1, + assessment: contest_assessment, + max_xp: 1000 + }) + + # insert contest submissions and answers + contest_submissions = + for student <- Enum.take(course_regs.students, 5) do + insert(:submission, %{assessment: contest_assessment, student: student}) + end + + _contest_answers = + for {submission, score} <- Enum.with_index(contest_submissions, 1) do + insert(:answer, %{ + xp: 1000, + question: contest_question, + submission: submission, + answer: build(:programming_answer), + relative_score: score / 1 + }) + end + + expected_leaderboard = [] + + resp_leaderboard = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, voting_question.assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.find(&(&1["id"] == voting_question.id)) + |> Map.get("contestLeaderboard") + + assert resp_leaderboard == expected_leaderboard + end + + test "it renders assessment question libraries", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for role <- Role.__enum_map__() do + course_reg = Map.get(role_crs, role) + + for {_type, + %{ + assessment: assessment, + mcq_questions: mcq_questions, + programming_questions: programming_questions, + voting_question: voting_questions + }} <- assessments do + # Programming questions should come first due to seeding order + + expected_libraries = + (programming_questions ++ mcq_questions ++ voting_questions) + |> Enum.map(&Map.get(&1, :library)) + |> Enum.map( + &%{ + "chapter" => &1.chapter, + "globals" => &1.globals, + "external" => %{ + "name" => "#{&1.external.name}", + "symbols" => &1.external.symbols + } + } + ) + + resp_libraries = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.map(&Map.get(&1, "library")) + + assert resp_libraries == expected_libraries + end + end + end + + test "it renders solutions for ungraded assessments (path)", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for role <- Role.__enum_map__() do + course_reg = Map.get(role_crs, role) + + %{ + assessment: assessment, + mcq_questions: mcq_questions, + programming_questions: programming_questions, + voting_questions: voting_questions + } = assessments["path"] + + # This is the case cuz the seed set "path" to build_soultion = true + + # Seeds set solution as 0 + expected_mcq_solutions = Enum.map(mcq_questions, fn _ -> %{"solution" => 0} end) + + expected_programming_solutions = + Enum.map(programming_questions, &%{"solution" => &1.question.solution}) + + # No solution in a voting question + expected_voting_solutions = Enum.map(voting_questions, fn _ -> %{"solution" => nil} end) + + expected_solutions = + Enum.sort( + expected_mcq_solutions ++ expected_programming_solutions ++ expected_voting_solutions + ) + + resp_solutions = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.map(&Map.take(&1, ["solution"])) + |> Enum.sort() + + assert expected_solutions == resp_solutions + end + end + + test "it renders xp, grade for students", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for role <- Role.__enum_map__() do + course_reg = Map.get(role_crs, role) + + for {_type, + %{ + assessment: assessment, + mcq_answers: [mcq_answers | _], + programming_answers: [programming_answers | _], + voting_answers: [voting_answers | _] + }} <- assessments do + expected = + if role == :student do + Enum.map( + programming_answers ++ mcq_answers ++ voting_answers, + &%{ + "xp" => &1.xp + &1.xp_adjustment + } + ) + else + fn -> %{"xp" => 0} end + |> Stream.repeatedly() + |> Enum.take( + length(programming_answers) + length(mcq_answers) + length(voting_answers) + ) + end + + resp = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.map(&Map.take(&1, ~w(xp))) + + assert expected == resp + end + end + end + + test "it does not render solutions for ungraded assessments (path)", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for role <- Role.__enum_map__() do + course_reg = Map.get(role_crs, role) + + for {_type, + %{ + assessment: assessment + }} <- Map.delete(assessments, "path") do + resp_solutions = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.map(&Map.get(&1, ["solution"])) + + assert Enum.uniq(resp_solutions) == [nil] + end + end + end + end + + describe "GET /assessment_id, student" do + test "it renders previously submitted answers", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessments: assessments + } do + for {_type, + %{ + assessment: assessment, + mcq_answers: [mcq_answers | _], + programming_answers: [programming_answers | _], + voting_answers: [voting_answers | _] + }} <- assessments do + # Programming questions should come first due to seeding order + expected_programming_answers = + Enum.map(programming_answers, &%{"answer" => &1.answer.code}) + + expected_mcq_answers = Enum.map(mcq_answers, &%{"answer" => &1.answer.choice_id}) + + # Answers are not rendered for voting questions + expected_voting_answers = Enum.map(voting_answers, fn _ -> %{"answer" => nil} end) + + expected_answers = + expected_programming_answers ++ expected_mcq_answers ++ expected_voting_answers + + resp_answers = + conn + |> sign_in(student.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.map(&Map.take(&1, ["answer"])) + + assert expected_answers == resp_answers + end + end + + test "it does not permit access to not yet open assessments", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessments: %{"mission" => mission} + } do + mission.assessment + |> Assessment.changeset(%{ + open_at: Timex.shift(Timex.now(), days: 5), + close_at: Timex.shift(Timex.now(), days: 10) + }) + |> Repo.update!() + + conn = + conn + |> sign_in(student.user) + |> get(build_url(course1.id, mission.assessment.id)) + + assert response(conn, 401) == "Assessment not open" + end + + test "it does not permit access to unpublished assessments", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessments: %{"mission" => mission} + } do + {:ok, _} = + mission.assessment + |> Assessment.changeset(%{is_published: false}) + |> Repo.update() + + conn = + conn + |> sign_in(student.user) + |> get(build_url(course1.id, mission.assessment.id)) + + assert response(conn, 400) == "Assessment not found" + end + end + + describe "GET /assessment_id, non-students" do + test "it renders empty answers", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for role <- ~w(staff admin)a do + course_reg = Map.get(role_crs, role) + + for {_type, %{assessment: assessment}} <- assessments do + resp_answers = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.map(&Map.get(&1, ["answer"])) + + assert Enum.uniq(resp_answers) == [nil] + end + end + end + + test "it permits access to not yet open assessments", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: %{"mission" => mission} + } do + for role <- ~w(staff admin)a do + course_reg = Map.get(role_crs, role) + + mission.assessment + |> Assessment.changeset(%{ + open_at: Timex.shift(Timex.now(), days: 5), + close_at: Timex.shift(Timex.now(), days: 10) + }) + |> Repo.update!() + + resp = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, mission.assessment.id)) + |> json_response(200) + + assert resp["id"] == mission.assessment.id + end + end + + test "it permits access to unpublished assessments", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: %{"mission" => mission} + } do + for role <- ~w(staff admin)a do + course_reg = Map.get(role_crs, role) + + {:ok, _} = + mission.assessment + |> Assessment.changeset(%{is_published: false}) + |> Repo.update() + + resp = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, mission.assessment.id)) + |> json_response(200) + + assert resp["id"] == mission.assessment.id + end + end + end + + describe "GET /assessment_id/submit unauthenticated" do + test "is not permitted", %{ + conn: conn, + courses: %{course1: course1}, + assessments: %{"mission" => %{assessment: assessment}} + } do + conn = post(conn, build_url_submit(course1.id, assessment.id)) + assert response(conn, 401) == "Unauthorised" + end + end + + describe "GET /assessment_id/submit students" do + for role <- ~w(student staff admin)a do + @tag role: role + test "is successful for attempted assessments for #{role}", %{ + conn: conn, + courses: %{course1: course1}, + assessments: %{"mission" => %{assessment: assessment}}, + role_crs: role_crs, + role: role + } do + with_mock GradingJob, + force_grade_individual_submission: fn _ -> nil end do + group = + if(role == :student, + do: insert(:group, %{course: course1, leader: role_crs.staff}), + else: nil + ) + + course_reg = insert(:course_registration, %{role: role, group: group, course: course1}) + + submission = + insert(:submission, %{student: course_reg, assessment: assessment, status: :attempted}) + + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + + assert response(conn, 200) == "OK" + + # Preloading is necessary because Mock does an exact match, including metadata + submission_db = Submission |> Repo.get(submission.id) |> Repo.preload(:assessment) + + assert submission_db.status == :submitted + + assert_called(GradingJob.force_grade_individual_submission(submission_db)) + end + end + end + + test "submission of answer within early hours(seeded 48) of opening grants full XP bonus", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs + } do + with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do + assessment_config = + insert( + :assessment_config, + early_submission_xp: 100, + hours_before_early_xp_decay: 48, + course: course1 + ) + + assessment = + insert( + :assessment, + open_at: Timex.shift(Timex.now(), hours: -40), + close_at: Timex.shift(Timex.now(), days: 7), + is_published: true, + config: assessment_config, + course: course1 + ) + + question = insert(:programming_question, assessment: assessment) + + group = insert(:group, leader: role_crs.staff) + + course_reg = + insert(:course_registration, %{role: :student, group: group, course: course1}) + + submission = + insert(:submission, assessment: assessment, student: course_reg, status: :attempted) + + insert( + :answer, + submission: submission, + question: question, + answer: %{code: "f => f(f);"} + ) + + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + |> response(200) + + submission_db = Repo.get(Submission, submission.id) + + assert submission_db.status == :submitted + assert submission_db.xp_bonus == 100 + end + end + + test "submission of answer after early hours before deadline get decaying XP bonus", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs + } do + with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do + for hours_after <- 48..148 do + assessment_config = + insert( + :assessment_config, + early_submission_xp: 100, + hours_before_early_xp_decay: 48, + course: course1 + ) + + assessment = + insert( + :assessment, + open_at: Timex.shift(Timex.now(), hours: -hours_after), + close_at: Timex.shift(Timex.now(), hours: 100), + is_published: true, + config: assessment_config, + course: course1 + ) + + question = insert(:programming_question, assessment: assessment) + + group = insert(:group, leader: role_crs.staff) + + course_reg = + insert(:course_registration, %{role: :student, group: group, course: course1}) + + submission = + insert(:submission, assessment: assessment, student: course_reg, status: :attempted) + + insert( + :answer, + submission: submission, + question: question, + answer: %{code: "f => f(f);"} + ) + + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + |> response(200) + + submission_db = Repo.get(Submission, submission.id) + + proportion = + Timex.diff(assessment.close_at, Timex.now(), :hours) / (100 + hours_after - 48) + + assert submission_db.status == :submitted + assert submission_db.xp_bonus == round(proportion * 100) + end + end + end + + test "submission of answer at the last hour yield 0 XP bonus", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs + } do + with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do + for hours_after <- 48..148 do + assessment_config = + insert( + :assessment_config, + early_submission_xp: 100, + hours_before_early_xp_decay: 48, + course: course1 + ) + + assessment = + insert( + :assessment, + open_at: Timex.shift(Timex.now(), hours: -hours_after), + close_at: Timex.shift(Timex.now(), hours: 1), + is_published: true, + config: assessment_config, + course: course1 + ) + + question = insert(:programming_question, assessment: assessment) + + group = insert(:group, leader: role_crs.staff) + + course_reg = + insert(:course_registration, %{role: :student, group: group, course: course1}) + + submission = + insert(:submission, assessment: assessment, student: course_reg, status: :attempted) + + insert( + :answer, + submission: submission, + question: question, + answer: %{code: "f => f(f);"} + ) + + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + |> response(200) + + submission_db = Repo.get(Submission, submission.id) + + assert submission_db.status == :submitted + assert submission_db.xp_bonus == 0 + end + end + end + + test "give 0 bonus for configs with 0 max", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs + } do + with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do + for hours_after <- 0..148 do + assessment_config = + insert( + :assessment_config, + early_submission_xp: 0, + hours_before_early_xp_decay: 48, + course: course1 + ) + + assessment = + insert( + :assessment, + open_at: Timex.shift(Timex.now(), hours: -hours_after), + close_at: Timex.shift(Timex.now(), days: 7), + is_published: true, + config: assessment_config, + course: course1 + ) + + question = insert(:programming_question, assessment: assessment) + + group = insert(:group, leader: role_crs.staff) + + course_reg = + insert(:course_registration, %{role: :student, group: group, course: course1}) + + submission = + insert(:submission, assessment: assessment, student: course_reg, status: :attempted) + + insert( + :answer, + submission: submission, + question: question, + answer: %{code: "f => f(f);"} + ) + + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + |> response(200) + + submission_db = Repo.get(Submission, submission.id) + + assert submission_db.status == :submitted + assert submission_db.xp_bonus == 0 + end + end + end + + # This also covers unpublished and assessments that are not open yet since they cannot be + # answered. + test "is not permitted for unattempted assessments", %{ + conn: conn, + courses: %{course1: course1}, + assessments: %{"mission" => %{assessment: assessment}} + } do + course_reg = insert(:course_registration, %{role: :student, course: course1}) + + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + + assert response(conn, 404) == "Submission not found" + end + + test "is not permitted for incomplete assessments", %{ + conn: conn, + courses: %{course1: course1}, + assessments: %{"mission" => %{assessment: assessment}} + } do + course_reg = insert(:course_registration, %{role: :student, course: course1}) + insert(:submission, %{student: course_reg, assessment: assessment, status: :attempting}) + + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + + assert response(conn, 400) == "Some questions have not been attempted" + end + + test "is not permitted for already submitted assessments", %{ + conn: conn, + courses: %{course1: course1}, + assessments: %{"mission" => %{assessment: assessment}} + } do + course_reg = insert(:course_registration, %{role: :student, course: course1}) + insert(:submission, %{student: course_reg, assessment: assessment, status: :submitted}) + + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + + assert response(conn, 403) == "Assessment has already been submitted" + end + + test "is not permitted for closed assessments", %{conn: conn, courses: %{course1: course1}} do + course_reg = insert(:course_registration, %{role: :student, course: course1}) + + # Only check for after-closing because submission shouldn't exist if unpublished or + # before opening and would fall under "Submission not found" + after_close_at_assessment = + insert(:assessment, %{ + open_at: Timex.shift(Timex.now(), days: -10), + close_at: Timex.shift(Timex.now(), days: -5), + course: course1 + }) + + insert(:submission, %{ + student: course_reg, + assessment: after_close_at_assessment, + status: :attempted + }) + + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, after_close_at_assessment.id)) + + assert response(conn, 403) == "Assessment not open" + end + + test "not found if not in same course", %{ + conn: conn, + courses: %{course2: course2}, + role_crs: %{student: student}, + assessments: %{"mission" => %{assessment: assessment}} + } do + # user is in both course, but assessment belongs to a course and no submission will be found + conn = + conn + |> sign_in(student.user) + |> post(build_url_submit(course2.id, assessment.id)) + + assert response(conn, 404) == "Submission not found" + end + + test "forbidden if not in course", %{ + conn: conn, + courses: %{course2: course2}, + course_regs: %{students: students}, + assessments: %{"mission" => %{assessment: assessment}} + } do + # user is not in the course + student2 = hd(tl(students)) + + conn = + conn + |> sign_in(student2.user) + |> post(build_url_submit(course2.id, assessment.id)) + + assert response(conn, 403) == "Forbidden" + end + end + + test "graded count is updated when assessment is graded", %{ + conn: conn, + courses: %{course1: course1}, + assessment_configs: [config | _], + role_crs: %{staff: avenger} + } do + assessment = + insert( + :assessment, + open_at: Timex.shift(Timex.now(), hours: -2), + close_at: Timex.shift(Timex.now(), days: 7), + is_published: true, + config: config, + course: course1 + ) + + [question_one, question_two] = insert_list(2, :programming_question, assessment: assessment) + + course_reg = insert(:course_registration, role: :student, course: course1) + + submission = + insert(:submission, assessment: assessment, student: course_reg, status: :submitted) + + Enum.each( + [question_one, question_two], + &insert(:answer, submission: submission, question: &1, answer: %{code: "f => f(f);"}) + ) + + get_graded_count = fn -> + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id)) + |> json_response(200) + |> Enum.find(&(&1["id"] == assessment.id)) + |> Map.get("gradedCount") + end + + grade_question = fn question -> + Assessments.update_grading_info( + %{submission_id: submission.id, question_id: question.id}, + %{"xp_adjustment" => 0}, + avenger + ) + end + + assert get_graded_count.() == 0 + + grade_question.(question_one) + + assert get_graded_count.() == 1 + + grade_question.(question_two) + + assert get_graded_count.() == 2 + end + + describe "Password protected assessments render properly" do + setup %{courses: %{course1: course1}, assessment_configs: configs} do + assessment = + insert(:assessment, %{config: Enum.at(configs, 4), course: course1, is_published: true}) + + assessment + |> Assessment.changeset(%{ + password: "mysupersecretpassword", + open_at: Timex.shift(Timex.now(), days: -2), + close_at: Timex.shift(Timex.now(), days: +1) + }) + |> Repo.update!() + + {:ok, protected_assessment: assessment} + end + + test "returns 403 when trying to access a password protected assessment without a password", + %{ + conn: conn, + courses: %{course1: course1}, + protected_assessment: protected_assessment, + role_crs: role_crs + } do + for {_role, course_reg} <- role_crs do + conn = + conn |> sign_in(course_reg.user) |> get(build_url(course1.id, protected_assessment.id)) + + assert response(conn, 403) == "Missing Password." + end + end + + test "returns 403 when password is wrong/invalid", %{ + conn: conn, + courses: %{course1: course1}, + protected_assessment: protected_assessment, + role_crs: role_crs + } do + for {_role, course_reg} <- role_crs do + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_unlock(course1.id, protected_assessment.id), %{:password => "wrong"}) + + assert response(conn, 403) == "Invalid Password." + end + end + + test "allow role_crs with preexisting submission to access private assessment without a password", + %{ + conn: conn, + courses: %{course1: course1}, + protected_assessment: protected_assessment, + role_crs: %{student: student} + } do + insert(:submission, %{assessment: protected_assessment, student: student}) + conn = conn |> sign_in(student.user) |> get(build_url(course1.id, protected_assessment.id)) + assert response(conn, 200) + end + + test "ignore password when assessment is not password protected", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + assessment = assessments["mission"].assessment + + for {_role, course_reg} <- role_crs do + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_unlock(course1.id, assessment.id), %{:password => "wrong"}) + |> json_response(200) + + assert conn["id"] == assessment.id + end + end + + test "render assessment when password is correct", %{ + conn: conn, + courses: %{course1: course1}, + protected_assessment: protected_assessment, + role_crs: role_crs + } do + for {_role, course_reg} <- role_crs do + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_unlock(course1.id, protected_assessment.id), %{ + :password => "mysupersecretpassword" + }) + |> json_response(200) + + assert conn["id"] == protected_assessment.id + end + end + + test "permit global access to private assessment after closed", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessments: %{"mission" => mission} + } do + mission.assessment + |> Assessment.changeset(%{ + open_at: Timex.shift(Timex.now(), days: -2), + close_at: Timex.shift(Timex.now(), days: -1) + }) + |> Repo.update!() + + conn = + conn + |> sign_in(student.user) + |> get(build_url(course1.id, mission.assessment.id)) + + assert response(conn, 200) + end + end + + defp build_url(course_id), do: "/v2/courses/#{course_id}/assessments/" + + defp build_url(course_id, assessment_id), + do: "/v2/courses/#{course_id}/assessments/#{assessment_id}" + + defp build_url_submit(course_id, assessment_id), + do: "/v2/courses/#{course_id}/assessments/#{assessment_id}/submit" + + defp build_url_unlock(course_id, assessment_id), + do: "/v2/courses/#{course_id}/assessments/#{assessment_id}/unlock" + + defp open_at_asc_comparator(x, y), do: Timex.before?(x.open_at, y.open_at) + + defp get_assessment_status(course_reg = %CourseRegistration{}, assessment = %Assessment{}) do + submission = + Submission + |> where(student_id: ^course_reg.id) + |> where(assessment_id: ^assessment.id) + |> Repo.one() + + (submission && submission.status |> Atom.to_string()) || "not_attempted" + end +end From 539dcbaa730253359351b816d4ee351d26e62bd1 Mon Sep 17 00:00:00 2001 From: LuYiting0913 Date: Thu, 6 Jul 2023 16:24:58 +0800 Subject: [PATCH 003/128] Add max team size to assessment controller payload --- lib/cadet_web/admin_controllers/admin_assessments_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/cadet_web/admin_controllers/admin_assessments_controller.ex b/lib/cadet_web/admin_controllers/admin_assessments_controller.ex index 4a73c4ee8..72cdad12f 100644 --- a/lib/cadet_web/admin_controllers/admin_assessments_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_assessments_controller.ex @@ -98,7 +98,6 @@ defmodule CadetWeb.AdminAssessmentsController do Map.put(updated_assessment, :max_team_size, max_team_size) end - IO.inspect(updated_assessment) with {:ok, assessment} <- check_dates(open_at, close_at, updated_assessment), {:ok, _nil} <- Assessments.update_assessment(assessment_id, assessment) do text(conn, "OK") @@ -216,6 +215,7 @@ defmodule CadetWeb.AdminAssessmentsController do closeAt(:string, "Open date", required: false) openAt(:string, "Close date", required: false) isPublished(:boolean, "Whether the assessment is published", required: false) + maxTeamSize(:number, "Max team size of the assessment", required: false) end end } From 0644003a516bd10b0c337caf838f0bd79021f6f6 Mon Sep 17 00:00:00 2001 From: LuYiting0913 <97156342+LuYiting0913@users.noreply.github.com> Date: Mon, 10 Jul 2023 18:15:11 +0800 Subject: [PATCH 004/128] Update mix.exs --- mix.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 005ac2832..361ccc9eb 100644 --- a/mix.exs +++ b/mix.exs @@ -7,7 +7,7 @@ defmodule Cadet.Mixfile do version: "0.0.1", elixir: "~> 1.10", elixirc_paths: elixirc_paths(Mix.env()), - compilers: [:phoenix, :gettext] ++ Mix.compilers() ++ [:phoenix_swagger], + compilers: Mix.compilers() ++ [:phoenix_swagger], start_permanent: Mix.env() == :prod, test_coverage: [tool: ExCoveralls], preferred_cli_env: [ @@ -70,6 +70,7 @@ defmodule Cadet.Mixfile do {:jason, "~> 1.2"}, {:openid_connect, "~> 0.2"}, {:phoenix, "~> 1.5"}, + {:phoenix_view, "~> 2.0"}, {:phoenix_ecto, "~> 4.0"}, {:phoenix_swagger, "~> 0.8"}, {:plug_cowboy, "~> 2.0"}, From c8f943b2daa8ad34e8e6b714d25da84ccb04ba20 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Tue, 11 Jul 2023 17:16:03 +0800 Subject: [PATCH 005/128] Create migrations for team assessments --- .../20230711032615_create_teams_table.exs | 10 +++++++ ...230711033554_create_team_members_table.exs | 11 ++++++++ ...20230711033707_alter_submissions_table.exs | 27 +++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 priv/repo/migrations/20230711032615_create_teams_table.exs create mode 100644 priv/repo/migrations/20230711033554_create_team_members_table.exs create mode 100644 priv/repo/migrations/20230711033707_alter_submissions_table.exs diff --git a/priv/repo/migrations/20230711032615_create_teams_table.exs b/priv/repo/migrations/20230711032615_create_teams_table.exs new file mode 100644 index 000000000..ae60a918f --- /dev/null +++ b/priv/repo/migrations/20230711032615_create_teams_table.exs @@ -0,0 +1,10 @@ +defmodule Cadet.Repo.Migrations.CreateTeamsTable do + use Ecto.Migration + + def change do + create table(:teams) do + add(:assessment_id, references(:assessments), null: false) + timestamps() + end + end +end diff --git a/priv/repo/migrations/20230711033554_create_team_members_table.exs b/priv/repo/migrations/20230711033554_create_team_members_table.exs new file mode 100644 index 000000000..9942ea9f2 --- /dev/null +++ b/priv/repo/migrations/20230711033554_create_team_members_table.exs @@ -0,0 +1,11 @@ +defmodule Cadet.Repo.Migrations.CreateTeamMembersTable do + use Ecto.Migration + + def change do + create table(:team_members) do + add(:team_id, references(:teams), null: false) + add(:student_id, references(:users), null: false) + timestamps() + end + end +end diff --git a/priv/repo/migrations/20230711033707_alter_submissions_table.exs b/priv/repo/migrations/20230711033707_alter_submissions_table.exs new file mode 100644 index 000000000..86493fe61 --- /dev/null +++ b/priv/repo/migrations/20230711033707_alter_submissions_table.exs @@ -0,0 +1,27 @@ +defmodule Cadet.Repo.Migrations.AlterSubmissionsTable do + use Ecto.Migration + + def up do + # Drop the existing constraint + execute("ALTER TABLE submissions DROP CONSTRAINT IF EXISTS submissions_student_id_fkey;") + + alter table(:submissions) do + modify(:student_id, references(:users), null: true) + add(:team_id, references(:teams), null: true) + end + + execute("ALTER TABLE submissions ADD CONSTRAINT xor_constraint CHECK ( + (student_id IS NULL AND team_id IS NOT NULL) OR + (student_id IS NOT NULL AND team_id IS NULL) + );") + end + + def down do + execute("ALTER TABLE submissions DROP CONSTRAINT xor_constraint;") + + alter table(:submissions) do + modify(:student_id, references(:users), null: false) + drop(:team_id) + end + end +end From 83ccac9483a72d1ea206ed72520997a44cd4c81c Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Mon, 17 Jul 2023 16:08:28 +0800 Subject: [PATCH 006/128] Add Team Formation Models --- lib/cadet/accounts/team.ex | 114 ++++++++++++++++++++++++++++++ lib/cadet/accounts/team_member.ex | 25 +++++++ 2 files changed, 139 insertions(+) create mode 100644 lib/cadet/accounts/team.ex create mode 100644 lib/cadet/accounts/team_member.ex diff --git a/lib/cadet/accounts/team.ex b/lib/cadet/accounts/team.ex new file mode 100644 index 000000000..1c207c034 --- /dev/null +++ b/lib/cadet/accounts/team.ex @@ -0,0 +1,114 @@ +defmodule Cadet.Accounts.Team do + use Ecto.Schema + import Ecto.Changeset + + alias Cadet.Accounts.TeamMember + alias Cadet.Assessments.{Assessment, Submission} + + schema "teams" do + + belongs_to(:assessment, Assessment) + has_one(:submission, Submission, on_delete: :delete_all) + has_many(:team_members, TeamMember, on_delete: :delete_all) + + timestamps() + end + + @required_fields ~w(assessment_id)a + + def changeset(team, attrs) do + team + |> cast(attrs, @required_fields) + |> validate_required(@required_fields) + |> foreign_key_constraint(:assessment_id) + end + + def create_team(attrs) do + assessment_id = attrs["assessment_id"] + student_ids = attrs["student_ids"] + + %Team{} + |> cast(attrs, [:assessment_id]) + |> validate_required([:assessment_id]) + |> foreign_key_constraint(:assessment_id) + |> Repo.insert() + |> case do + {:ok, team} -> + create_team_members(team, student_ids) + {:ok, team} + + error -> + error + end + end + + defp create_team_members(team, student_ids) do + Enum.each(student_ids, fn student_id -> + %TeamMember{} + |> Ecto.Changeset.change(%{team_id: team.id, student_id: student_id}) + |> Repo.insert() + end) + end + + def update_team(%Team{} = team, attrs) do + assessment_id = attrs["assessment_id"] + student_ids = attrs["student_ids"] + + team + |> cast(attrs, [:assessment_id]) + |> validate_required([:assessment_id]) + |> foreign_key_constraint(:assessment_id) + |> Ecto.Changeset.change() + |> Repo.update() + |> case do + {:ok, updated_team} -> + update_team_members(updated_team, student_ids) + {:ok, updated_team} + + error -> + error + end + end + + defp update_team_members(team, student_ids) do + current_student_ids = team.team_members |> Enum.map(&(&1.student_id)) + + student_ids_to_add = List.difference(student_ids, current_student_ids) + student_ids_to_remove = List.difference(current_student_ids, student_ids) + + Enum.each(student_ids_to_add, fn student_id -> + %TeamMember{} + |> Ecto.Changeset.change(%{team_id: team.id, student_id: student_id}) + |> Repo.insert() + end) + + Enum.each(student_ids_to_remove, fn student_id -> + team.team_members + |> where([tm], tm.student_id == ^student_id) + |> Repo.delete_all() + end) + end + + def delete_team(%Team{} = team) do + team + |> Ecto.Changeset.delete_assoc(:submission) + |> Ecto.Changeset.delete_assoc(:team_members) + |> Repo.delete() + end + + def bulk_upload_teams(teams_params) do + teams = Jason.decode!(teams_params) + Enum.map(teams, fn team -> + case get_by_assessment_id(team["assessment_id"]) do + nil -> create_team(team) + existing_team -> update_team(existing_team, team) + end + end) + end + + def get_by_assessment_id(assessment_id) do + from(Team) + |> where(assessment_id: ^assessment_id) + |> Repo.one() + end +end diff --git a/lib/cadet/accounts/team_member.ex b/lib/cadet/accounts/team_member.ex new file mode 100644 index 000000000..3fa353aed --- /dev/null +++ b/lib/cadet/accounts/team_member.ex @@ -0,0 +1,25 @@ +defmodule Cadet.Accounts.TeamMember do + use Ecto.Schema + import Ecto.Changeset + + alias Cadet.Accounts.CourseRegistration + alias Cadet.Accounts.Team + + schema "team_members" do + + belongs_to(:student, CourseRegistration) + belongs_to(:team, Team) + + timestamps() + end + + @required_fields ~w(student_id team_id)a + + def changeset(team_member, attrs) do + team_member + |> cast(attrs, @required_fields) + |> validate_required(@required_fields) + |> foreign_key_constraint(:team_id) + |> foreign_key_constraint(:student_id) + end +end From 9fd7bef97deee1d5ea71cbe8cb98711a8497ce04 Mon Sep 17 00:00:00 2001 From: LuYiting0913 Date: Mon, 17 Jul 2023 17:40:18 +0800 Subject: [PATCH 007/128] Fix line endings --- .credo.exs | 280 +- lib/cadet/accounts/notification.ex | 72 +- lib/cadet/assessments/assessment.ex | 186 +- lib/cadet/assessments/assessments.ex | 3346 ++++++++--------- .../admin_assessments_controller.ex | 446 +-- .../admin_views/admin_assessments_view.ex | 128 +- .../controllers/assessments_controller.ex | 806 ++-- mix.exs | 264 +- mix.lock | 180 +- ...0190510152804_drop_announcements_table.exs | 14 +- .../assessments_controller_test.exs | 3146 ++++++++-------- 11 files changed, 4434 insertions(+), 4434 deletions(-) diff --git a/.credo.exs b/.credo.exs index 0b50b3681..cc2e7c08b 100644 --- a/.credo.exs +++ b/.credo.exs @@ -1,140 +1,140 @@ -# This file contains the configuration for Credo and you are probably reading -# this after creating it with `mix credo.gen.config`. -# -# If you find anything wrong or unclear in this file, please report an -# issue on GitHub: https://github.com/rrrene/credo/issues -# -%{ - # - # You can have as many configs as you like in the `configs:` field. - configs: [ - %{ - # - # Run any exec using `mix credo -C `. If no exec name is given - # "default" is used. - # - name: "default", - # - # These are the files included in the analysis: - files: %{ - # - # You can give explicit globs or simply directories. - # In the latter case `**/*.{ex,exs}` will be used. - included: ["lib/", "src/", "web/", "apps/", "test/"], - excluded: [~r"/_build/", ~r"/deps/"] - }, - # - # If you create your own checks, you must specify the source files for - # them here, so they can be loaded by Credo before running the analysis. - # - requires: [], - # - # If you want to enforce a style guide and need a more traditional linting - # experience, you can change `strict` to `true` below: - # - strict: true, - # - # If you want to use uncolored output by default, you can change `color` - # to `false` below: - # - color: true, - # - # You can customize the parameters of any check by adding a second element - # to the tuple. - # - # To disable a check put `false` as second element: - # - # {Credo.Check.Design.DuplicatedCode, false} - # - checks: [ - {Credo.Check.Consistency.ExceptionNames}, - {Credo.Check.Consistency.LineEndings}, - {Credo.Check.Consistency.ParameterPatternMatching}, - {Credo.Check.Consistency.SpaceAroundOperators}, - {Credo.Check.Consistency.SpaceInParentheses}, - {Credo.Check.Consistency.TabsOrSpaces}, - - # For some checks, like AliasUsage, you can only customize the priority - # Priority values are: `low, normal, high, higher` - # - {Credo.Check.Design.AliasUsage, - if_called_more_often_than: 2, excluded_namespaces: ["Faker"]}, - - # For others you can set parameters - - # If you don't want the `setup` and `test` macro calls in ExUnit tests - # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just - # set the `excluded_macros` parameter to `[:schema, :setup, :test]`. - # - {Credo.Check.Design.DuplicatedCode, excluded_macros: [], exit_status: 0}, - - # You can also customize the exit_status of each check. - # If you don't want TODO comments to cause `mix credo` to fail, just - # set this value to 0 (zero). - # - {Credo.Check.Design.TagTODO, exit_status: 0}, - {Credo.Check.Design.TagFIXME}, - {Credo.Check.Readability.AliasOrder, false}, - {Credo.Check.Readability.FunctionNames}, - {Credo.Check.Readability.LargeNumbers}, - {Credo.Check.Readability.MaxLineLength, max_length: 101}, - {Credo.Check.Readability.ModuleAttributeNames}, - {Credo.Check.Readability.ModuleDoc}, - {Credo.Check.Readability.ModuleNames}, - {Credo.Check.Readability.ParenthesesOnZeroArityDefs}, - {Credo.Check.Readability.ParenthesesInCondition}, - {Credo.Check.Readability.PredicateFunctionNames}, - {Credo.Check.Readability.PreferImplicitTry}, - {Credo.Check.Readability.RedundantBlankLines}, - {Credo.Check.Readability.StringSigils}, - {Credo.Check.Readability.TrailingBlankLine}, - {Credo.Check.Readability.TrailingWhiteSpace}, - {Credo.Check.Readability.VariableNames}, - {Credo.Check.Readability.Semicolons}, - {Credo.Check.Readability.SpaceAfterCommas}, - {Credo.Check.Refactor.DoubleBooleanNegation}, - {Credo.Check.Refactor.CondStatements}, - {Credo.Check.Refactor.CyclomaticComplexity}, - {Credo.Check.Refactor.FunctionArity}, - {Credo.Check.Refactor.LongQuoteBlocks}, - {Credo.Check.Refactor.MatchInCondition}, - {Credo.Check.Refactor.NegatedConditionsInUnless}, - {Credo.Check.Refactor.NegatedConditionsWithElse}, - {Credo.Check.Refactor.Nesting}, - {Credo.Check.Refactor.PipeChainStart}, - {Credo.Check.Refactor.UnlessWithElse}, - {Credo.Check.Warning.BoolOperationOnSameValues}, - {Credo.Check.Warning.IExPry}, - {Credo.Check.Warning.IoInspect}, - {Credo.Check.Warning.LazyLogging, false}, - {Credo.Check.Warning.OperationOnSameValues}, - {Credo.Check.Warning.OperationWithConstantResult}, - {Credo.Check.Warning.UnusedEnumOperation}, - {Credo.Check.Warning.UnusedFileOperation}, - {Credo.Check.Warning.UnusedKeywordOperation}, - {Credo.Check.Warning.UnusedListOperation}, - {Credo.Check.Warning.UnusedPathOperation}, - {Credo.Check.Warning.UnusedRegexOperation}, - {Credo.Check.Warning.UnusedStringOperation}, - {Credo.Check.Warning.UnusedTupleOperation}, - {Credo.Check.Warning.RaiseInsideRescue}, - - # Controversial and experimental checks (opt-in, just remove `, false`) - # - {Credo.Check.Refactor.ABCSize, false}, - {Credo.Check.Refactor.AppendSingleItem, false}, - {Credo.Check.Refactor.VariableRebinding, false}, - {Credo.Check.Warning.MapGetUnsafePass}, - {Credo.Check.Consistency.MultiAliasImportRequireUse}, - - # Deprecated checks (these will be deleted after a grace period) - # - {Credo.Check.Readability.Specs, false}, - {Credo.Check.Refactor.MapInto, false} - - # Custom checks can be created using `mix credo.gen.check`. - # - ] - } - ] -} +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any exec using `mix credo -C `. If no exec name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + included: ["lib/", "src/", "web/", "apps/", "test/"], + excluded: [~r"/_build/", ~r"/deps/"] + }, + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: [], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: true, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: [ + {Credo.Check.Consistency.ExceptionNames}, + {Credo.Check.Consistency.LineEndings}, + {Credo.Check.Consistency.ParameterPatternMatching}, + {Credo.Check.Consistency.SpaceAroundOperators}, + {Credo.Check.Consistency.SpaceInParentheses}, + {Credo.Check.Consistency.TabsOrSpaces}, + + # For some checks, like AliasUsage, you can only customize the priority + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, + if_called_more_often_than: 2, excluded_namespaces: ["Faker"]}, + + # For others you can set parameters + + # If you don't want the `setup` and `test` macro calls in ExUnit tests + # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just + # set the `excluded_macros` parameter to `[:schema, :setup, :test]`. + # + {Credo.Check.Design.DuplicatedCode, excluded_macros: [], exit_status: 0}, + + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, exit_status: 0}, + {Credo.Check.Design.TagFIXME}, + {Credo.Check.Readability.AliasOrder, false}, + {Credo.Check.Readability.FunctionNames}, + {Credo.Check.Readability.LargeNumbers}, + {Credo.Check.Readability.MaxLineLength, max_length: 101}, + {Credo.Check.Readability.ModuleAttributeNames}, + {Credo.Check.Readability.ModuleDoc}, + {Credo.Check.Readability.ModuleNames}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs}, + {Credo.Check.Readability.ParenthesesInCondition}, + {Credo.Check.Readability.PredicateFunctionNames}, + {Credo.Check.Readability.PreferImplicitTry}, + {Credo.Check.Readability.RedundantBlankLines}, + {Credo.Check.Readability.StringSigils}, + {Credo.Check.Readability.TrailingBlankLine}, + {Credo.Check.Readability.TrailingWhiteSpace}, + {Credo.Check.Readability.VariableNames}, + {Credo.Check.Readability.Semicolons}, + {Credo.Check.Readability.SpaceAfterCommas}, + {Credo.Check.Refactor.DoubleBooleanNegation}, + {Credo.Check.Refactor.CondStatements}, + {Credo.Check.Refactor.CyclomaticComplexity}, + {Credo.Check.Refactor.FunctionArity}, + {Credo.Check.Refactor.LongQuoteBlocks}, + {Credo.Check.Refactor.MatchInCondition}, + {Credo.Check.Refactor.NegatedConditionsInUnless}, + {Credo.Check.Refactor.NegatedConditionsWithElse}, + {Credo.Check.Refactor.Nesting}, + {Credo.Check.Refactor.PipeChainStart}, + {Credo.Check.Refactor.UnlessWithElse}, + {Credo.Check.Warning.BoolOperationOnSameValues}, + {Credo.Check.Warning.IExPry}, + {Credo.Check.Warning.IoInspect}, + {Credo.Check.Warning.LazyLogging, false}, + {Credo.Check.Warning.OperationOnSameValues}, + {Credo.Check.Warning.OperationWithConstantResult}, + {Credo.Check.Warning.UnusedEnumOperation}, + {Credo.Check.Warning.UnusedFileOperation}, + {Credo.Check.Warning.UnusedKeywordOperation}, + {Credo.Check.Warning.UnusedListOperation}, + {Credo.Check.Warning.UnusedPathOperation}, + {Credo.Check.Warning.UnusedRegexOperation}, + {Credo.Check.Warning.UnusedStringOperation}, + {Credo.Check.Warning.UnusedTupleOperation}, + {Credo.Check.Warning.RaiseInsideRescue}, + + # Controversial and experimental checks (opt-in, just remove `, false`) + # + {Credo.Check.Refactor.ABCSize, false}, + {Credo.Check.Refactor.AppendSingleItem, false}, + {Credo.Check.Refactor.VariableRebinding, false}, + {Credo.Check.Warning.MapGetUnsafePass}, + {Credo.Check.Consistency.MultiAliasImportRequireUse}, + + # Deprecated checks (these will be deleted after a grace period) + # + {Credo.Check.Readability.Specs, false}, + {Credo.Check.Refactor.MapInto, false} + + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } + ] +} diff --git a/lib/cadet/accounts/notification.ex b/lib/cadet/accounts/notification.ex index 0b07e46a7..e6e552d1b 100644 --- a/lib/cadet/accounts/notification.ex +++ b/lib/cadet/accounts/notification.ex @@ -1,36 +1,36 @@ -defmodule Cadet.Accounts.Notification do - @moduledoc """ - The Notification entity represents a notification. - It stores information pertaining to the type of notification and who in which course it belongs to. - Each notification can have an assessment id or submission id, with optional question id. - This will be used to pinpoint where the notification will be showed on the frontend. - """ - use Cadet, :model - - alias Cadet.Accounts.{NotificationType, Role, CourseRegistration} - alias Cadet.Assessments.{Assessment, Submission} - - schema "notifications" do - field(:type, NotificationType) - field(:read, :boolean, default: false) - field(:role, Role, virtual: true) - - belongs_to(:course_reg, CourseRegistration) - belongs_to(:assessment, Assessment) - belongs_to(:submission, Submission) - - timestamps() - end - - @required_fields ~w(type read course_reg_id assessment_id)a - @optional_fields ~w(submission_id)a - - def changeset(answer, params) do - answer - |> cast(params, @required_fields ++ @optional_fields) - |> validate_required(@required_fields) - |> foreign_key_constraint(:course_reg_id) - |> foreign_key_constraint(:assessment_id) - |> foreign_key_constraint(:submission_id) - end -end +defmodule Cadet.Accounts.Notification do + @moduledoc """ + The Notification entity represents a notification. + It stores information pertaining to the type of notification and who in which course it belongs to. + Each notification can have an assessment id or submission id, with optional question id. + This will be used to pinpoint where the notification will be showed on the frontend. + """ + use Cadet, :model + + alias Cadet.Accounts.{NotificationType, Role, CourseRegistration} + alias Cadet.Assessments.{Assessment, Submission} + + schema "notifications" do + field(:type, NotificationType) + field(:read, :boolean, default: false) + field(:role, Role, virtual: true) + + belongs_to(:course_reg, CourseRegistration) + belongs_to(:assessment, Assessment) + belongs_to(:submission, Submission) + + timestamps() + end + + @required_fields ~w(type read course_reg_id assessment_id)a + @optional_fields ~w(submission_id)a + + def changeset(answer, params) do + answer + |> cast(params, @required_fields ++ @optional_fields) + |> validate_required(@required_fields) + |> foreign_key_constraint(:course_reg_id) + |> foreign_key_constraint(:assessment_id) + |> foreign_key_constraint(:submission_id) + end +end diff --git a/lib/cadet/assessments/assessment.ex b/lib/cadet/assessments/assessment.ex index fe30c0a45..5e5c36233 100644 --- a/lib/cadet/assessments/assessment.ex +++ b/lib/cadet/assessments/assessment.ex @@ -1,93 +1,93 @@ -defmodule Cadet.Assessments.Assessment do - @moduledoc """ - The Assessment entity stores metadata of a students' assessment - (mission, sidequest, path, and contest) - """ - use Cadet, :model - use Arc.Ecto.Schema - - alias Cadet.Repo - alias Cadet.Assessments.{AssessmentAccess, Question, SubmissionStatus, Upload} - alias Cadet.Courses.{Course, AssessmentConfig} - - @type t :: %__MODULE__{} - - schema "assessments" do - field(:access, AssessmentAccess, virtual: true, default: :public) - field(:max_xp, :integer, virtual: true) - field(:xp, :integer, virtual: true, default: 0) - field(:user_status, SubmissionStatus, virtual: true) - field(:grading_status, :string, virtual: true) - field(:question_count, :integer, virtual: true) - field(:graded_count, :integer, virtual: true) - field(:title, :string) - field(:is_published, :boolean, default: false) - field(:summary_short, :string) - field(:summary_long, :string) - field(:open_at, :utc_datetime_usec) - field(:close_at, :utc_datetime_usec) - field(:cover_picture, :string) - field(:mission_pdf, Upload.Type) - field(:number, :string) - field(:story, :string) - field(:reading, :string) - field(:password, :string, default: nil) - field(:max_team_size, :integer, default: 1) - - belongs_to(:config, AssessmentConfig) - belongs_to(:course, Course) - - has_many(:questions, Question, on_delete: :delete_all) - timestamps() - end - - @required_fields ~w(title open_at close_at number course_id config_id max_team_size)a - @optional_fields ~w(reading summary_short summary_long - is_published story cover_picture access password)a - @optional_file_fields ~w(mission_pdf)a - - def changeset(assessment, params) do - params = - params - |> convert_date(:open_at) - |> convert_date(:close_at) - - assessment - |> cast_attachments(params, @optional_file_fields) - |> cast(params, @required_fields ++ @optional_fields) - |> validate_required(@required_fields) - |> add_belongs_to_id_from_model([:config, :course], params) - |> foreign_key_constraint(:config_id) - |> foreign_key_constraint(:course_id) - |> unique_constraint([:number, :course_id]) - |> validate_config_course - |> validate_open_close_date - end - - defp validate_config_course(changeset) do - config_id = get_field(changeset, :config_id) - course_id = get_field(changeset, :course_id) - - case Repo.get(AssessmentConfig, config_id) do - nil -> - add_error(changeset, :config, "does not exist") - - config -> - if config.course_id == course_id do - changeset - else - add_error(changeset, :config, "does not belong to the same course as this assessment") - end - end - end - - defp validate_open_close_date(changeset) do - validate_change(changeset, :open_at, fn :open_at, open_at -> - if Timex.before?(open_at, get_field(changeset, :close_at)) do - [] - else - [open_at: "Open date must be before close date"] - end - end) - end -end +defmodule Cadet.Assessments.Assessment do + @moduledoc """ + The Assessment entity stores metadata of a students' assessment + (mission, sidequest, path, and contest) + """ + use Cadet, :model + use Arc.Ecto.Schema + + alias Cadet.Repo + alias Cadet.Assessments.{AssessmentAccess, Question, SubmissionStatus, Upload} + alias Cadet.Courses.{Course, AssessmentConfig} + + @type t :: %__MODULE__{} + + schema "assessments" do + field(:access, AssessmentAccess, virtual: true, default: :public) + field(:max_xp, :integer, virtual: true) + field(:xp, :integer, virtual: true, default: 0) + field(:user_status, SubmissionStatus, virtual: true) + field(:grading_status, :string, virtual: true) + field(:question_count, :integer, virtual: true) + field(:graded_count, :integer, virtual: true) + field(:title, :string) + field(:is_published, :boolean, default: false) + field(:summary_short, :string) + field(:summary_long, :string) + field(:open_at, :utc_datetime_usec) + field(:close_at, :utc_datetime_usec) + field(:cover_picture, :string) + field(:mission_pdf, Upload.Type) + field(:number, :string) + field(:story, :string) + field(:reading, :string) + field(:password, :string, default: nil) + field(:max_team_size, :integer, default: 1) + + belongs_to(:config, AssessmentConfig) + belongs_to(:course, Course) + + has_many(:questions, Question, on_delete: :delete_all) + timestamps() + end + + @required_fields ~w(title open_at close_at number course_id config_id max_team_size)a + @optional_fields ~w(reading summary_short summary_long + is_published story cover_picture access password)a + @optional_file_fields ~w(mission_pdf)a + + def changeset(assessment, params) do + params = + params + |> convert_date(:open_at) + |> convert_date(:close_at) + + assessment + |> cast_attachments(params, @optional_file_fields) + |> cast(params, @required_fields ++ @optional_fields) + |> validate_required(@required_fields) + |> add_belongs_to_id_from_model([:config, :course], params) + |> foreign_key_constraint(:config_id) + |> foreign_key_constraint(:course_id) + |> unique_constraint([:number, :course_id]) + |> validate_config_course + |> validate_open_close_date + end + + defp validate_config_course(changeset) do + config_id = get_field(changeset, :config_id) + course_id = get_field(changeset, :course_id) + + case Repo.get(AssessmentConfig, config_id) do + nil -> + add_error(changeset, :config, "does not exist") + + config -> + if config.course_id == course_id do + changeset + else + add_error(changeset, :config, "does not belong to the same course as this assessment") + end + end + end + + defp validate_open_close_date(changeset) do + validate_change(changeset, :open_at, fn :open_at, open_at -> + if Timex.before?(open_at, get_field(changeset, :close_at)) do + [] + else + [open_at: "Open date must be before close date"] + end + end) + end +end diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index b0d685119..a16b66bb2 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -1,1673 +1,1673 @@ -defmodule Cadet.Assessments do - @moduledoc """ - Assessments context contains domain logic for assessments management such as - missions, sidequests, paths, etc. - """ - use Cadet, [:context, :display] - import Ecto.Query - - require Logger - - alias Cadet.Accounts.{ - Notification, - Notifications, - User, - CourseRegistration, - CourseRegistrations - } - - alias Cadet.Assessments.{Answer, Assessment, Query, Question, Submission, SubmissionVotes} - alias Cadet.Autograder.GradingJob - alias Cadet.Courses.{Group, AssessmentConfig} - alias Cadet.Jobs.Log - alias Cadet.ProgramAnalysis.Lexer - alias Ecto.Multi - alias Cadet.Incentives.Achievements - - require Decimal - - @open_all_assessment_roles ~w(staff admin)a - - # These roles can save and finalise answers for closed assessments and - # submitted answers - @bypass_closed_roles ~w(staff admin)a - - def delete_assessment(id) do - assessment = Repo.get(Assessment, id) - - Submission - |> where(assessment_id: ^id) - |> delete_submission_assocation(id) - - Question - |> where(assessment_id: ^id) - |> Repo.all() - |> Enum.each(fn q -> - delete_submission_votes_association(q) - end) - - Repo.delete(assessment) - end - - defp delete_submission_votes_association(question) do - SubmissionVotes - |> where(question_id: ^question.id) - |> Repo.delete_all() - end - - defp delete_submission_assocation(submissions, assessment_id) do - submissions - |> Repo.all() - |> Enum.each(fn submission -> - Answer - |> where(submission_id: ^submission.id) - |> Repo.delete_all() - end) - - Notification - |> where(assessment_id: ^assessment_id) - |> Repo.delete_all() - - Repo.delete_all(submissions) - end - - @spec user_max_xp(CourseRegistration.t()) :: integer() - def user_max_xp(%CourseRegistration{id: cr_id}) do - Submission - |> where(status: ^:submitted) - |> where(student_id: ^cr_id) - |> join( - :inner, - [s], - a in subquery(Query.all_assessments_with_max_xp()), - on: s.assessment_id == a.id - ) - |> select([_, a], sum(a.max_xp)) - |> Repo.one() - |> decimal_to_integer() - end - - def assessments_total_xp(%CourseRegistration{id: cr_id}) do - submission_xp = - Submission - |> where(student_id: ^cr_id) - |> join(:inner, [s], a in Answer, on: s.id == a.submission_id) - |> group_by([s], s.id) - |> select([s, a], %{ - # grouping by submission, so s.xp_bonus will be the same, but we need an - # aggregate function - total_xp: sum(a.xp) + sum(a.xp_adjustment) + max(s.xp_bonus) - }) - - total = - submission_xp - |> subquery - |> select([s], %{ - total_xp: sum(s.total_xp) - }) - |> Repo.one() - - # for {key, val} <- total, into: %{}, do: {key, decimal_to_integer(val)} - decimal_to_integer(total.total_xp) - end - - def user_total_xp(course_id, user_id, course_reg_id) do - user_course = CourseRegistrations.get_user_course(user_id, course_id) - - total_achievement_xp = Achievements.achievements_total_xp(course_id, course_reg_id) - total_assessment_xp = assessments_total_xp(user_course) - - total_achievement_xp + total_assessment_xp - end - - defp decimal_to_integer(decimal) do - if Decimal.is_decimal(decimal) do - Decimal.to_integer(decimal) - else - 0 - end - end - - def user_current_story(cr = %CourseRegistration{}) do - {:ok, %{result: story}} = - Multi.new() - |> Multi.run(:unattempted, fn _repo, _ -> - {:ok, get_user_story_by_type(cr, :unattempted)} - end) - |> Multi.run(:result, fn _repo, %{unattempted: unattempted_story} -> - if unattempted_story do - {:ok, %{play_story?: true, story: unattempted_story}} - else - {:ok, %{play_story?: false, story: get_user_story_by_type(cr, :attempted)}} - end - end) - |> Repo.transaction() - - story - end - - @spec get_user_story_by_type(CourseRegistration.t(), :unattempted | :attempted) :: - String.t() | nil - def get_user_story_by_type(%CourseRegistration{id: cr_id}, type) - when is_atom(type) do - filter_and_sort = fn query -> - case type do - :unattempted -> - query - |> where([_, s], is_nil(s.id)) - |> order_by([a], asc: a.open_at) - - :attempted -> - query |> order_by([a], desc: a.close_at) - end - end - - Assessment - |> where(is_published: true) - |> where([a], not is_nil(a.story)) - |> where([a], a.open_at <= from_now(0, "second") and a.close_at >= from_now(0, "second")) - |> join(:left, [a], s in Submission, on: s.assessment_id == a.id and s.student_id == ^cr_id) - |> filter_and_sort.() - |> order_by([a], a.config_id) - |> select([a], a.story) - |> first() - |> Repo.one() - end - - def assessment_with_questions_and_answers( - assessment = %Assessment{password: nil}, - cr = %CourseRegistration{}, - nil - ) do - assessment_with_questions_and_answers(assessment, cr) - end - - def assessment_with_questions_and_answers( - assessment = %Assessment{password: nil}, - cr = %CourseRegistration{}, - _ - ) do - assessment_with_questions_and_answers(assessment, cr) - end - - def assessment_with_questions_and_answers( - assessment = %Assessment{password: password}, - cr = %CourseRegistration{}, - given_password - ) do - cond do - Timex.compare(Timex.now(), assessment.close_at) >= 0 -> - assessment_with_questions_and_answers(assessment, cr) - - match?({:ok, _}, find_submission(cr, assessment)) -> - assessment_with_questions_and_answers(assessment, cr) - - given_password == nil -> - {:error, {:forbidden, "Missing Password."}} - - password == given_password -> - find_or_create_submission(cr, assessment) - assessment_with_questions_and_answers(assessment, cr) - - true -> - {:error, {:forbidden, "Invalid Password."}} - end - end - - def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}, password) - when is_ecto_id(id) do - role = cr.role - - assessment = - if role in @open_all_assessment_roles do - Assessment - |> where(id: ^id) - |> preload(:config) - |> Repo.one() - else - Assessment - |> where(id: ^id) - |> where(is_published: true) - |> preload(:config) - |> Repo.one() - end - - if assessment do - assessment_with_questions_and_answers(assessment, cr, password) - else - {:error, {:bad_request, "Assessment not found"}} - end - end - - def assessment_with_questions_and_answers( - assessment = %Assessment{id: id}, - course_reg = %CourseRegistration{role: role} - ) do - if Timex.compare(Timex.now(), assessment.open_at) >= 0 or role in @open_all_assessment_roles do - answer_query = - Answer - |> join(:inner, [a], s in assoc(a, :submission)) - |> where([_, s], s.student_id == ^course_reg.id) - - questions = - Question - |> where(assessment_id: ^id) - |> join(:left, [q], a in subquery(answer_query), on: q.id == a.question_id) - |> join(:left, [_, a], g in assoc(a, :grader)) - |> join(:left, [_, _, g], u in assoc(g, :user)) - |> select([q, a, g, u], {q, a, g, u}) - |> order_by(:display_order) - |> Repo.all() - |> Enum.map(fn - {q, nil, _, _} -> %{q | answer: %Answer{grader: nil}} - {q, a, nil, _} -> %{q | answer: %Answer{a | grader: nil}} - {q, a, g, u} -> %{q | answer: %Answer{a | grader: %CourseRegistration{g | user: u}}} - end) - |> load_contest_voting_entries(course_reg, assessment) - - assessment = assessment |> Map.put(:questions, questions) - {:ok, assessment} - else - {:error, {:unauthorized, "Assessment not open"}} - end - end - - def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}) do - assessment_with_questions_and_answers(id, cr, nil) - end - - @doc """ - Returns a list of assessments with all fields and an indicator showing whether it has been attempted - by the supplied user - """ - def all_assessments(cr = %CourseRegistration{}) do - submission_aggregates = - Submission - |> join(:left, [s], ans in Answer, on: ans.submission_id == s.id) - |> where([s], s.student_id == ^cr.id) - |> group_by([s], s.assessment_id) - |> select([s, ans], %{ - assessment_id: s.assessment_id, - # s.xp_bonus should be the same across the group, but we need an aggregate function here - xp: fragment("? + ? + ?", sum(ans.xp), sum(ans.xp_adjustment), max(s.xp_bonus)), - graded_count: ans.id |> count() |> filter(not is_nil(ans.grader_id)) - }) - - submission_status = - Submission - |> where([s], s.student_id == ^cr.id) - |> select([s], [:assessment_id, :status]) - - assessments = - cr.course_id - |> Query.all_assessments_with_aggregates() - |> subquery() - |> join( - :left, - [a], - sa in subquery(submission_aggregates), - on: a.id == sa.assessment_id - ) - |> join(:left, [a, _], s in subquery(submission_status), on: a.id == s.assessment_id) - |> select([a, sa, s], %{ - a - | xp: sa.xp, - graded_count: sa.graded_count, - user_status: s.status - }) - |> filter_published_assessments(cr) - |> order_by(:open_at) - |> preload(:config) - |> Repo.all() - - {:ok, assessments} - end - - def filter_published_assessments(assessments, cr) do - role = cr.role - - case role do - :student -> where(assessments, is_published: true) - _ -> assessments - end - end - - def create_assessment(params) do - %Assessment{} - |> Assessment.changeset(params) - |> Repo.insert() - end - - @doc """ - The main function that inserts or updates assessments from the XML Parser - """ - @spec insert_or_update_assessments_and_questions(map(), [map()], boolean()) :: - {:ok, any()} - | {:error, Ecto.Multi.name(), any(), %{optional(Ecto.Multi.name()) => any()}} - def insert_or_update_assessments_and_questions( - assessment_params, - questions_params, - force_update - ) do - assessment_multi = - Multi.insert_or_update( - Multi.new(), - :assessment, - insert_or_update_assessment_changeset(assessment_params, force_update) - ) - - if force_update and invalid_force_update(assessment_multi, questions_params) do - {:error, "Question count is different"} - else - questions_params - |> Enum.with_index(1) - |> Enum.reduce(assessment_multi, fn {question_params, index}, multi -> - Multi.run(multi, "question#{index}", fn _repo, %{assessment: %Assessment{id: id}} -> - question = - Question - |> where([q], q.display_order == ^index and q.assessment_id == ^id) - |> Repo.one() - - # the is_nil(question) check allows for force updating of brand new assessments - if !force_update or is_nil(question) do - {status, new_question} = - question_params - |> Map.put(:display_order, index) - |> build_question_changeset_for_assessment_id(id) - |> Repo.insert() - - if status == :ok and new_question.type == :voting do - insert_voting( - assessment_params.course_id, - question_params.question.contest_number, - new_question.id - ) - else - {status, new_question} - end - else - params = - question_params - |> Map.put_new(:max_xp, 0) - |> Map.put(:display_order, index) - - if question_params.type != Atom.to_string(question.type) do - {:error, - create_invalid_changeset_with_error( - :question, - "Question types should remain the same" - )} - else - question - |> Question.changeset(params) - |> Repo.update() - end - end - end) - end) - |> Repo.transaction() - end - end - - # Function that checks if the force update is invalid. The force update is only invalid - # if the new question count is different from the old question count. - defp invalid_force_update(assessment_multi, questions_params) do - assessment_id = - (assessment_multi.operations - |> List.first() - |> elem(1) - |> elem(1)).data.id - - if assessment_id do - open_date = Repo.get(Assessment, assessment_id).open_at - # check if assessment is already opened - if Timex.compare(open_date, Timex.now()) >= 0 do - false - else - existing_questions_count = - Question - |> where([q], q.assessment_id == ^assessment_id) - |> Repo.all() - |> Enum.count() - - new_questions_count = Enum.count(questions_params) - existing_questions_count != new_questions_count - end - else - false - end - end - - @spec insert_or_update_assessment_changeset(map(), boolean()) :: Ecto.Changeset.t() - defp insert_or_update_assessment_changeset( - params = %{number: number, course_id: course_id}, - force_update - ) do - Assessment - |> where(number: ^number) - |> where(course_id: ^course_id) - |> Repo.one() - |> case do - nil -> - Assessment.changeset(%Assessment{}, params) - - %{id: assessment_id} = assessment -> - answers_exist = - Answer - |> join(:inner, [a], q in assoc(a, :question)) - |> join(:inner, [a, q], asst in assoc(q, :assessment)) - |> where([a, q, asst], asst.id == ^assessment_id) - |> Repo.exists?() - - # Maintain the same open/close date when updating an assessment - params = - params - |> Map.delete(:open_at) - |> Map.delete(:close_at) - |> Map.delete(:is_published) - - cond do - not answers_exist -> - # Delete all realted submission_votes - SubmissionVotes - |> join(:inner, [sv, q], q in assoc(sv, :question)) - |> where([sv, q], q.assessment_id == ^assessment_id) - |> Repo.delete_all() - - # Delete all existing questions - Question - |> where(assessment_id: ^assessment_id) - |> Repo.delete_all() - - Assessment.changeset(assessment, params) - - force_update -> - Assessment.changeset(assessment, params) - - true -> - # if the assessment has submissions, don't edit - create_invalid_changeset_with_error(:assessment, "has submissions") - end - end - end - - @spec build_question_changeset_for_assessment_id(map(), number() | String.t()) :: - Ecto.Changeset.t() - defp build_question_changeset_for_assessment_id(params, assessment_id) - when is_ecto_id(assessment_id) do - params_with_assessment_id = Map.put_new(params, :assessment_id, assessment_id) - - Question.changeset(%Question{}, params_with_assessment_id) - end - - @doc """ - Generates and assigns contest entries for users with given usernames. - """ - def insert_voting( - course_id, - contest_number, - question_id - ) do - contest_assessment = Repo.get_by(Assessment, number: contest_number, course_id: course_id) - - if is_nil(contest_assessment) do - changeset = change(%Assessment{}, %{number: ""}) - - error_changeset = - Ecto.Changeset.add_error( - changeset, - :number, - "invalid contest number" - ) - - {:error, error_changeset} - else - # Returns contest submission ids with answers that contain "return" - contest_submission_ids = - Submission - |> join(:inner, [s], ans in assoc(s, :answers)) - |> join(:inner, [s, ans], cr in assoc(s, :student)) - |> where([s, ans, cr], cr.role == "student") - |> where([s, _], s.assessment_id == ^contest_assessment.id and s.status == "submitted") - |> where( - [_, ans, cr], - fragment( - "?->>'code' like ?", - ans.answer, - "%return%" - ) - ) - |> select([s, _ans], {s.student_id, s.id}) - |> Repo.all() - |> Enum.into(%{}) - - contest_submission_ids_length = Enum.count(contest_submission_ids) - - voter_ids = - CourseRegistration - |> where(role: "student", course_id: ^course_id) - |> select([cr], cr.id) - |> Repo.all() - - votes_per_user = min(contest_submission_ids_length, 10) - - votes_per_submission = - if Enum.empty?(contest_submission_ids) do - 0 - else - trunc(Float.ceil(votes_per_user * length(voter_ids) / contest_submission_ids_length)) - end - - submission_id_list = - contest_submission_ids - |> Enum.map(fn {_, s_id} -> s_id end) - |> Enum.shuffle() - |> List.duplicate(votes_per_submission) - |> List.flatten() - - {_submission_map, submission_votes_changesets} = - voter_ids - |> Enum.reduce({submission_id_list, []}, fn voter_id, acc -> - {submission_list, submission_votes} = acc - - user_contest_submission_id = Map.get(contest_submission_ids, voter_id) - - {votes, rest} = - submission_list - |> Enum.reduce_while({MapSet.new(), submission_list}, fn s_id, acc -> - {user_votes, submissions} = acc - - max_votes = - if votes_per_user == contest_submission_ids_length and - not is_nil(user_contest_submission_id) do - # no. of submssions is less than 10. Unable to find - votes_per_user - 1 - else - votes_per_user - end - - if MapSet.size(user_votes) < max_votes do - if s_id != user_contest_submission_id and not MapSet.member?(user_votes, s_id) do - new_user_votes = MapSet.put(user_votes, s_id) - new_submissions = List.delete(submissions, s_id) - {:cont, {new_user_votes, new_submissions}} - else - {:cont, {user_votes, submissions}} - end - else - {:halt, acc} - end - end) - - votes = MapSet.to_list(votes) - - new_submission_votes = - votes - |> Enum.map(fn s_id -> - %SubmissionVotes{voter_id: voter_id, submission_id: s_id, question_id: question_id} - end) - |> Enum.concat(submission_votes) - - {rest, new_submission_votes} - end) - - submission_votes_changesets - |> Enum.with_index() - |> Enum.reduce(Multi.new(), fn {changeset, index}, multi -> - Multi.insert(multi, Integer.to_string(index), changeset) - end) - |> Repo.transaction() - end - end - - def update_assessment(id, params) when is_ecto_id(id) do - IO.inspect(params) - simple_update( - Assessment, - id, - using: &Assessment.changeset/2, - params: params - ) - end - - def update_question(id, params) when is_ecto_id(id) do - simple_update( - Question, - id, - using: &Question.changeset/2, - params: params - ) - end - - def publish_assessment(id) when is_ecto_id(id) do - update_assessment(id, %{is_published: true}) - end - - def create_question_for_assessment(params, assessment_id) when is_ecto_id(assessment_id) do - assessment = - Assessment - |> where(id: ^assessment_id) - |> join(:left, [a], q in assoc(a, :questions)) - |> preload([_, q], questions: q) - |> Repo.one() - - if assessment do - params_with_assessment_id = Map.put_new(params, :assessment_id, assessment.id) - - %Question{} - |> Question.changeset(params_with_assessment_id) - |> put_display_order(assessment.questions) - |> Repo.insert() - else - {:error, "Assessment not found"} - end - end - - def get_question(id) when is_ecto_id(id) do - Question - |> where(id: ^id) - |> join(:inner, [q], assessment in assoc(q, :assessment)) - |> preload([_, a], assessment: a) - |> Repo.one() - end - - def delete_question(id) when is_ecto_id(id) do - question = Repo.get(Question, id) - Repo.delete(question) - end - - @doc """ - Public internal api to submit new answers for a question. Possible return values are: - `{:ok, nil}` -> success - `{:error, error}` -> failed. `error` is in the format of `{http_response_code, error message}` - - Note: In the event of `find_or_create_submission` failing due to a race condition, error will be: - `{:bad_request, "Missing or invalid parameter(s)"}` - - """ - def answer_question( - question = %Question{}, - cr = %CourseRegistration{id: cr_id}, - raw_answer, - force_submit - ) do - with {:ok, submission} <- find_or_create_submission(cr, question.assessment), - {:status, true} <- {:status, force_submit or submission.status != :submitted}, - {:ok, _answer} <- insert_or_update_answer(submission, question, raw_answer, cr_id) do - update_submission_status_router(submission, question) - - {:ok, nil} - else - {:status, _} -> - {:error, {:forbidden, "Assessment submission already finalised"}} - - {:error, :race_condition} -> - {:error, {:internal_server_error, "Please try again later."}} - - {:error, :invalid_vote} -> - {:error, {:bad_request, "Invalid vote! Vote is not saved."}} - - _ -> - {:error, {:bad_request, "Missing or invalid parameter(s)"}} - end - end - - def get_submission(assessment_id, %CourseRegistration{id: cr_id}) - when is_ecto_id(assessment_id) do - Submission - |> where(assessment_id: ^assessment_id) - |> where(student_id: ^cr_id) - |> join(:inner, [s], a in assoc(s, :assessment)) - |> preload([_, a], assessment: a) - |> Repo.one() - end - - def get_submission_by_id(submission_id) when is_ecto_id(submission_id) do - Submission - |> where(id: ^submission_id) - |> join(:inner, [s], a in assoc(s, :assessment)) - |> preload([_, a], assessment: a) - |> Repo.one() - end - - def finalise_submission(submission = %Submission{}) do - with {:status, :attempted} <- {:status, submission.status}, - {:ok, updated_submission} <- update_submission_status_and_xp_bonus(submission) do - # Couple with update_submission_status_and_xp_bonus to ensure notification is sent - Notifications.write_notification_when_student_submits(submission) - # Send email notification to avenger - %{notification_type: "assessment_submission", submission_id: updated_submission.id} - |> Cadet.Workers.NotificationWorker.new() - |> Oban.insert() - - # Begin autograding job - GradingJob.force_grade_individual_submission(updated_submission) - - {:ok, nil} - else - {:status, :attempting} -> - {:error, {:bad_request, "Some questions have not been attempted"}} - - {:status, :submitted} -> - {:error, {:forbidden, "Assessment has already been submitted"}} - - _ -> - {:error, {:internal_server_error, "Please try again later."}} - end - end - - def unsubmit_submission( - submission_id, - cr = %CourseRegistration{id: course_reg_id, role: role} - ) - when is_ecto_id(submission_id) do - submission = - Submission - |> join(:inner, [s], a in assoc(s, :assessment)) - |> preload([_, a], assessment: a) - |> Repo.get(submission_id) - - # allows staff to unsubmit own assessment - bypass = role in @bypass_closed_roles and submission.student_id == course_reg_id - - with {:submission_found?, true} <- {:submission_found?, is_map(submission)}, - {:is_open?, true} <- {:is_open?, bypass or is_open?(submission.assessment)}, - {:status, :submitted} <- {:status, submission.status}, - {:allowed_to_unsubmit?, true} <- - {:allowed_to_unsubmit?, - role == :admin or bypass or - Cadet.Accounts.Query.avenger_of?(cr, submission.student_id)} do - Multi.new() - |> Multi.run( - :rollback_submission, - fn _repo, _ -> - submission - |> Submission.changeset(%{ - status: :attempted, - xp_bonus: 0, - unsubmitted_by_id: course_reg_id, - unsubmitted_at: Timex.now() - }) - |> Repo.update() - end - ) - |> Multi.run(:rollback_answers, fn _repo, _ -> - Answer - |> join(:inner, [a], q in assoc(a, :question)) - |> join(:inner, [a, _], s in assoc(a, :submission)) - |> preload([_, q, s], question: q, submission: s) - |> where(submission_id: ^submission.id) - |> Repo.all() - |> Enum.reduce_while({:ok, nil}, fn answer, acc -> - case acc do - {:error, _} -> - {:halt, acc} - - {:ok, _} -> - {:cont, - answer - |> Answer.grading_changeset(%{ - xp: 0, - xp_adjustment: 0, - autograding_status: :none, - autograding_results: [] - }) - |> Repo.update()} - end - end) - end) - |> Repo.transaction() - - Cadet.Accounts.Notifications.handle_unsubmit_notifications( - submission.assessment.id, - Repo.get(CourseRegistration, submission.student_id) - ) - - {:ok, nil} - else - {:submission_found?, false} -> - {:error, {:not_found, "Submission not found"}} - - {:is_open?, false} -> - {:error, {:forbidden, "Assessment not open"}} - - {:status, :attempting} -> - {:error, {:bad_request, "Some questions have not been attempted"}} - - {:status, :attempted} -> - {:error, {:bad_request, "Assessment has not been submitted"}} - - {:allowed_to_unsubmit?, false} -> - {:error, {:forbidden, "Only Avenger of student or Admin is permitted to unsubmit"}} - - _ -> - {:error, {:internal_server_error, "Please try again later."}} - end - end - - @spec update_submission_status_and_xp_bonus(Submission.t()) :: - {:ok, Submission.t()} | {:error, Ecto.Changeset.t()} - defp update_submission_status_and_xp_bonus(submission = %Submission{}) do - assessment = submission.assessment - assessment_conifg = Repo.get_by(AssessmentConfig, id: assessment.config_id) - - max_bonus_xp = assessment_conifg.early_submission_xp - early_hours = assessment_conifg.hours_before_early_xp_decay - - xp_bonus = - if Timex.before?(Timex.now(), Timex.shift(assessment.open_at, hours: early_hours)) do - max_bonus_xp - else - # This logic interpolates from max bonus at early hour to 0 bonus at close time - decaying_hours = Timex.diff(assessment.close_at, assessment.open_at, :hours) - early_hours - remaining_hours = Enum.max([0, Timex.diff(assessment.close_at, Timex.now(), :hours)]) - proportion = if(decaying_hours > 0, do: remaining_hours / decaying_hours, else: 1) - bonus_xp = round(max_bonus_xp * proportion) - Enum.max([0, bonus_xp]) - end - - submission - |> Submission.changeset(%{status: :submitted, xp_bonus: xp_bonus}) - |> Repo.update() - end - - defp update_submission_status_router(submission = %Submission{}, question = %Question{}) do - case question.type do - :voting -> update_contest_voting_submission_status(submission, question) - :mcq -> update_submission_status(submission, question.assessment) - :programming -> update_submission_status(submission, question.assessment) - end - end - - defp update_submission_status(submission = %Submission{}, assessment = %Assessment{}) do - model_assoc_count = fn model, assoc, id -> - model - |> where(id: ^id) - |> join(:inner, [m], a in assoc(m, ^assoc)) - |> select([_, a], count(a.id)) - |> Repo.one() - end - - Multi.new() - |> Multi.run(:assessment, fn _repo, _ -> - {:ok, model_assoc_count.(Assessment, :questions, assessment.id)} - end) - |> Multi.run(:submission, fn _repo, _ -> - {:ok, model_assoc_count.(Submission, :answers, submission.id)} - end) - |> Multi.run(:update, fn _repo, %{submission: s_count, assessment: a_count} -> - if s_count == a_count do - submission |> Submission.changeset(%{status: :attempted}) |> Repo.update() - else - {:ok, nil} - end - end) - |> Repo.transaction() - end - - defp update_contest_voting_submission_status(submission = %Submission{}, question = %Question{}) do - has_nil_entries = - SubmissionVotes - |> where(question_id: ^question.id) - |> where(voter_id: ^submission.student_id) - |> where([sv], is_nil(sv.score)) - |> Repo.exists?() - - unless has_nil_entries do - submission |> Submission.changeset(%{status: :attempted}) |> Repo.update() - end - end - - defp load_contest_voting_entries( - questions, - %CourseRegistration{role: role, course_id: course_id, id: voter_id}, - assessment - ) do - Enum.map( - questions, - fn q -> - if q.type == :voting do - submission_votes = all_submission_votes_by_question_id_and_voter_id(q.id, voter_id) - # fetch top 10 contest voting entries with the contest question id - question_id = fetch_associated_contest_question_id(course_id, q) - - leaderboard_results = - if is_nil(question_id) do - [] - else - if leaderboard_open?(assessment, q) or role in @open_all_assessment_roles do - fetch_top_relative_score_answers(question_id, 10) - else - [] - end - end - - # populate entries to vote for and leaderboard data into the question - voting_question = - q.question - |> Map.put(:contest_entries, submission_votes) - |> Map.put( - :contest_leaderboard, - leaderboard_results - ) - - Map.put(q, :question, voting_question) - else - q - end - end - ) - end - - defp all_submission_votes_by_question_id_and_voter_id(question_id, voter_id) do - SubmissionVotes - |> where([v], v.voter_id == ^voter_id and v.question_id == ^question_id) - |> join(:inner, [v], s in assoc(v, :submission)) - |> join(:inner, [v, s], a in assoc(s, :answers)) - |> select([v, s, a], %{submission_id: v.submission_id, answer: a.answer, score: v.score}) - |> Repo.all() - end - - # Finds the contest_question_id associated with the given voting_question id - defp fetch_associated_contest_question_id(course_id, voting_question) do - contest_number = voting_question.question["contest_number"] - - if is_nil(contest_number) do - nil - else - Assessment - |> where(number: ^contest_number, course_id: ^course_id) - |> join(:inner, [a], q in assoc(a, :questions)) - |> order_by([a, q], q.display_order) - |> select([a, q], q.id) - |> Repo.one() - end - end - - defp leaderboard_open?(assessment, voting_question) do - Timex.before?( - Timex.shift(assessment.close_at, hours: voting_question.question["reveal_hours"]), - Timex.now() - ) - end - - @doc """ - Fetches top answers for the given question, based on the contest relative_score - - Used for contest leaderboard fetching - """ - def fetch_top_relative_score_answers(question_id, number_of_answers) do - Answer - |> where(question_id: ^question_id) - |> where( - [a], - fragment( - "?->>'code' like ?", - a.answer, - "%return%" - ) - ) - |> order_by(desc: :relative_score) - |> join(:left, [a], s in assoc(a, :submission)) - |> join(:left, [a, s], student in assoc(s, :student)) - |> join(:inner, [a, s, student], student_user in assoc(student, :user)) - |> where([a, s, student], student.role == "student") - |> select([a, s, student, student_user], %{ - submission_id: a.submission_id, - answer: a.answer, - relative_score: a.relative_score, - student_name: student_user.name - }) - |> limit(^number_of_answers) - |> Repo.all() - end - - @doc """ - Computes rolling leaderboard for contest votes that are still open. - """ - def update_rolling_contest_leaderboards do - # 115 = 2 hours - 5 minutes is default. - if Log.log_execution("update_rolling_contest_leaderboards", Timex.Duration.from_minutes(115)) do - Logger.info("Started update_rolling_contest_leaderboards") - - voting_questions_to_update = fetch_active_voting_questions() - - _ = - voting_questions_to_update - |> Enum.map(fn qn -> compute_relative_score(qn.id) end) - - Logger.info("Successfully update_rolling_contest_leaderboards") - end - end - - def fetch_active_voting_questions do - Question - |> join(:left, [q], a in assoc(q, :assessment)) - |> where([q, a], q.type == "voting") - |> where([q, a], a.is_published) - |> where([q, a], a.open_at <= ^Timex.now() and a.close_at >= ^Timex.now()) - |> Repo.all() - end - - @doc """ - Computes final leaderboard for contest votes that have closed. - """ - def update_final_contest_leaderboards do - # 1435 = 24 hours - 5 minutes - if Log.log_execution("update_final_contest_leaderboards", Timex.Duration.from_minutes(1435)) do - Logger.info("Started update_final_contest_leaderboards") - - voting_questions_to_update = fetch_voting_questions_due_yesterday() - - _ = - voting_questions_to_update - |> Enum.map(fn qn -> compute_relative_score(qn.id) end) - - Logger.info("Successfully update_final_contest_leaderboards") - end - end - - def fetch_voting_questions_due_yesterday do - Question - |> join(:left, [q], a in assoc(q, :assessment)) - |> where([q, a], q.type == "voting") - |> where([q, a], a.is_published) - |> where([q, a], a.open_at <= ^Timex.now()) - |> where( - [q, a], - a.close_at < ^Timex.now() and a.close_at >= ^Timex.shift(Timex.now(), days: -1) - ) - |> Repo.all() - end - - @doc """ - Computes the current relative_score of each voting submission answer - based on current submitted votes. - """ - def compute_relative_score(contest_voting_question_id) do - # query all records from submission votes tied to the question id -> - # map score to user id -> - # store as grade -> - # query grade for contest question id. - eligible_votes = - SubmissionVotes - |> where(question_id: ^contest_voting_question_id) - |> where([sv], not is_nil(sv.score)) - |> join(:inner, [sv], ans in Answer, on: sv.submission_id == ans.submission_id) - |> select( - [sv, ans], - %{ans_id: ans.id, score: sv.score, ans: ans.answer["code"]} - ) - |> Repo.all() - - entry_scores = map_eligible_votes_to_entry_score(eligible_votes) - - entry_scores - |> Enum.map(fn {ans_id, relative_score} -> - %Answer{id: ans_id} - |> Answer.contest_score_update_changeset(%{ - relative_score: relative_score - }) - end) - |> Enum.map(fn changeset -> - op_key = "answer_#{changeset.data.id}" - Multi.update(Multi.new(), op_key, changeset) - end) - |> Enum.reduce(Multi.new(), &Multi.append/2) - |> Repo.transaction() - end - - defp map_eligible_votes_to_entry_score(eligible_votes) do - # converts eligible votes to the {total cumulative score, number of votes, tokens} - entry_vote_data = - Enum.reduce(eligible_votes, %{}, fn %{ans_id: ans_id, score: score, ans: ans}, tracker -> - {prev_score, prev_count, _ans_tokens} = Map.get(tracker, ans_id, {0, 0, 0}) - - Map.put( - tracker, - ans_id, - # assume each voter is assigned 10 entries which will make it fair. - {prev_score + score, prev_count + 1, Lexer.count_tokens(ans)} - ) - end) - - # calculate the score based on formula {ans_id, score} - Enum.map( - entry_vote_data, - fn {ans_id, {sum_of_scores, number_of_voters, tokens}} -> - {ans_id, calculate_formula_score(sum_of_scores, number_of_voters, tokens)} - end - ) - end - - # Calculate the score based on formula - # score(v,t) = v - 2^(t/50) where v is the normalized_voting_score - # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 - defp calculate_formula_score(sum_of_scores, number_of_voters, tokens) do - normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 - normalized_voting_score - :math.pow(2, min(1023.5, tokens / 50)) - end - - @doc """ - Function returning submissions under a grader. This function returns only the - fields that are exposed in the /grading endpoint. The reason we select only - those fields is to reduce the memory usage especially when the number of - submissions is large i.e. > 25000 submissions. - - The input parameters are the user and group_only. group_only is used to check - whether only the groups under the grader should be returned. The parameter is - a boolean which is false by default. - - The return value is {:ok, submissions} if no errors, else it is {:error, - {:unauthorized, "Forbidden."}} - """ - @spec all_submissions_by_grader_for_index(CourseRegistration.t()) :: - {:ok, String.t()} - def all_submissions_by_grader_for_index( - grader = %CourseRegistration{course_id: course_id}, - group_only \\ false, - ungraded_only \\ false - ) do - show_all = not group_only - - group_where = - if show_all, - do: "", - else: - "where s.student_id in (select cr.id from course_registrations cr inner join groups g on cr.group_id = g.id where g.leader_id = $2) or s.student_id = $2" - - ungraded_where = - if ungraded_only, - do: "where s.\"gradedCount\" < assts.\"questionCount\"", - else: "" - - params = if show_all, do: [course_id], else: [course_id, grader.id] - - # We bypass Ecto here and use a raw query to generate JSON directly from - # PostgreSQL, because doing it in Elixir/Erlang is too inefficient. - - case Repo.query( - """ - select json_agg(q)::TEXT from - ( - select - s.id, - s.status, - s."unsubmittedAt", - s.xp, - s."xpAdjustment", - s."xpBonus", - s."gradedCount", - assts.jsn as assessment, - students.jsn as student, - unsubmitters.jsn as "unsubmittedBy" - from - (select - s.id, - s.student_id, - s.assessment_id, - s.status, - s.unsubmitted_at as "unsubmittedAt", - s.unsubmitted_by_id, - sum(ans.xp) as xp, - sum(ans.xp_adjustment) as "xpAdjustment", - s.xp_bonus as "xpBonus", - count(ans.id) filter (where ans.grader_id is not null) as "gradedCount" - from submissions s - left join - answers ans on s.id = ans.submission_id - #{group_where} - group by s.id) s - inner join - (select - a.id, a."questionCount", to_json(a) as jsn - from - (select - a.id, - a.title, - bool_or(ac.is_manually_graded) as "isManuallyGraded", - max(ac.type) as "type", - sum(q.max_xp) as "maxXp", - count(q.id) as "questionCount" - from assessments a - left join - questions q on a.id = q.assessment_id - inner join - assessment_configs ac on ac.id = a.config_id - where a.course_id = $1 - group by a.id) a) assts on assts.id = s.assessment_id - inner join - (select - cr.id, to_json(cr) as jsn - from - (select - cr.id, - u.name as "name", - g.name as "groupName", - g.leader_id as "groupLeaderId" - from course_registrations cr - left join - groups g on g.id = cr.group_id - inner join - users u on u.id = cr.user_id) cr) students on students.id = s.student_id - left join - (select - cr.id, to_json(cr) as jsn - from - (select - cr.id, - u.name - from course_registrations cr - inner join - users u on u.id = cr.user_id) cr) unsubmitters on s.unsubmitted_by_id = unsubmitters.id - #{ungraded_where} - ) q - """, - params - ) do - {:ok, %{rows: [[nil]]}} -> {:ok, "[]"} - {:ok, %{rows: [[json]]}} -> {:ok, json} - end - end - - @spec get_answers_in_submission(integer() | String.t()) :: - {:ok, [Answer.t()]} | {:error, {:bad_request | :unauthorized, String.t()}} - def get_answers_in_submission(id) when is_ecto_id(id) do - answer_query = - Answer - |> where(submission_id: ^id) - |> join(:inner, [a], q in assoc(a, :question)) - |> join(:inner, [_, q], ast in assoc(q, :assessment)) - |> join(:inner, [a, ..., ast], ac in assoc(ast, :config)) - |> join(:left, [a, ...], g in assoc(a, :grader)) - |> join(:left, [a, ..., g], gu in assoc(g, :user)) - |> join(:inner, [a, ...], s in assoc(a, :submission)) - |> join(:inner, [a, ..., s], st in assoc(s, :student)) - |> join(:inner, [a, ..., st], u in assoc(st, :user)) - |> preload([_, q, ast, ac, g, gu, s, st, u], - question: {q, assessment: {ast, config: ac}}, - grader: {g, user: gu}, - submission: {s, student: {st, user: u}} - ) - - answers = - answer_query - |> Repo.all() - |> Enum.sort_by(& &1.question.display_order) - |> Enum.map(fn ans -> - if ans.question.type == :voting do - empty_contest_entries = Map.put(ans.question.question, :contest_entries, []) - empty_contest_leaderboard = Map.put(empty_contest_entries, :contest_leaderboard, []) - question = Map.put(ans.question, :question, empty_contest_leaderboard) - Map.put(ans, :question, question) - else - ans - end - end) - - if answers == [] do - {:error, {:bad_request, "Submission is not found."}} - else - {:ok, answers} - end - end - - defp is_fully_graded?(%Answer{submission_id: submission_id}) do - submission = - Submission - |> Repo.get_by(id: submission_id) - - question_count = - Question - |> where(assessment_id: ^submission.assessment_id) - |> select([q], count(q.id)) - |> Repo.one() - - graded_count = - Answer - |> where([a], submission_id: ^submission_id) - |> where([a], not is_nil(a.grader_id)) - |> select([a], count(a.id)) - |> Repo.one() - - question_count == graded_count - end - - @spec update_grading_info( - %{submission_id: integer() | String.t(), question_id: integer() | String.t()}, - %{}, - CourseRegistration.t() - ) :: - {:ok, nil} - | {:error, {:unauthorized | :bad_request | :internal_server_error, String.t()}} - def update_grading_info( - %{submission_id: submission_id, question_id: question_id}, - attrs, - %CourseRegistration{id: grader_id} - ) - when is_ecto_id(submission_id) and is_ecto_id(question_id) do - attrs = Map.put(attrs, "grader_id", grader_id) - - answer_query = - Answer - |> where(submission_id: ^submission_id) - |> where(question_id: ^question_id) - - answer_query = - answer_query - |> join(:inner, [a], s in assoc(a, :submission)) - |> preload([_, s], submission: s) - - answer = Repo.one(answer_query) - - is_own_submission = grader_id == answer.submission.student_id - - with {:answer_found?, true} <- {:answer_found?, is_map(answer)}, - {:status, true} <- - {:status, answer.submission.status == :submitted or is_own_submission}, - {:valid, changeset = %Ecto.Changeset{valid?: true}} <- - {:valid, Answer.grading_changeset(answer, attrs)}, - {:ok, _} <- Repo.update(changeset) do - if is_fully_graded?(answer) and not is_own_submission do - # Every answer in this submission has been graded manually - Notifications.write_notification_when_graded(submission_id, :graded) - else - {:ok, nil} - end - else - {:answer_found?, false} -> - {:error, {:bad_request, "Answer not found or user not permitted to grade."}} - - {:valid, changeset} -> - {:error, {:bad_request, full_error_messages(changeset)}} - - {:status, _} -> - {:error, {:method_not_allowed, "Submission is not submitted yet."}} - - {:error, _} -> - {:error, {:internal_server_error, "Please try again later."}} - end - end - - def update_grading_info( - _, - _, - _ - ) do - {:error, {:unauthorized, "User is not permitted to grade."}} - end - - @spec force_regrade_submission(integer() | String.t(), CourseRegistration.t()) :: - {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}} - def force_regrade_submission( - submission_id, - _requesting_user = %CourseRegistration{id: grader_id} - ) - when is_ecto_id(submission_id) do - with {:get, sub} when not is_nil(sub) <- {:get, Repo.get(Submission, submission_id)}, - {:status, true} <- {:status, sub.student_id == grader_id or sub.status == :submitted} do - GradingJob.force_grade_individual_submission(sub, true) - {:ok, nil} - else - {:get, nil} -> - {:error, {:not_found, "Submission not found"}} - - {:status, false} -> - {:error, {:bad_request, "Submission not submitted yet"}} - end - end - - def force_regrade_submission(_, _) do - {:error, {:forbidden, "User is not permitted to grade."}} - end - - @spec force_regrade_answer( - integer() | String.t(), - integer() | String.t(), - CourseRegistration.t() - ) :: - {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}} - def force_regrade_answer( - submission_id, - question_id, - _requesting_user = %CourseRegistration{id: grader_id} - ) - when is_ecto_id(submission_id) and is_ecto_id(question_id) do - answer = - Answer - |> where(submission_id: ^submission_id, question_id: ^question_id) - |> preload([:question, :submission]) - |> Repo.one() - - with {:get, answer} when not is_nil(answer) <- {:get, answer}, - {:status, true} <- - {:status, - answer.submission.student_id == grader_id or answer.submission.status == :submitted} do - GradingJob.grade_answer(answer, answer.question, true) - {:ok, nil} - else - {:get, nil} -> - {:error, {:not_found, "Answer not found"}} - - {:status, false} -> - {:error, {:bad_request, "Submission not submitted yet"}} - end - end - - def force_regrade_answer(_, _, _) do - {:error, {:forbidden, "User is not permitted to grade."}} - end - - defp find_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do - submission = - Submission - |> where(student_id: ^cr.id) - |> where(assessment_id: ^assessment.id) - |> Repo.one() - - if submission do - {:ok, submission} - else - {:error, nil} - end - end - - # Checks if an assessment is open and published. - @spec is_open?(Assessment.t()) :: boolean() - def is_open?(%Assessment{open_at: open_at, close_at: close_at, is_published: is_published}) do - Timex.between?(Timex.now(), open_at, close_at, inclusive: :start) and is_published - end - - @spec get_group_grading_summary(integer()) :: - {:ok, [String.t(), ...], []} - def get_group_grading_summary(course_id) do - subs = - Answer - |> join(:left, [ans], s in Submission, on: s.id == ans.submission_id) - |> join(:left, [ans, s], st in CourseRegistration, on: s.student_id == st.id) - |> join(:left, [ans, s, st], a in Assessment, on: a.id == s.assessment_id) - |> join(:inner, [ans, s, st, a], ac in AssessmentConfig, on: ac.id == a.config_id) - |> where( - [ans, s, st, a, ac], - not is_nil(st.group_id) and s.status == ^:submitted and - ac.show_grading_summary and a.course_id == ^course_id - ) - |> group_by([ans, s, st, a, ac], s.id) - |> select([ans, s, st, a, ac], %{ - group_id: max(st.group_id), - config_id: max(ac.id), - config_type: max(ac.type), - num_submitted: count(), - num_ungraded: filter(count(), is_nil(ans.grader_id)) - }) - - raw_data = - subs - |> subquery() - |> join(:left, [t], g in Group, on: t.group_id == g.id) - |> join(:left, [t, g], l in CourseRegistration, on: l.id == g.leader_id) - |> join(:left, [t, g, l], lu in User, on: lu.id == l.user_id) - |> group_by([t, g, l, lu], [t.group_id, t.config_id, t.config_type, g.name, lu.name]) - |> select([t, g, l, lu], %{ - group_name: g.name, - leader_name: lu.name, - config_id: t.config_id, - config_type: t.config_type, - ungraded: filter(count(), t.num_ungraded > 0), - submitted: count() - }) - |> Repo.all() - - showing_configs = - AssessmentConfig - |> where([ac], ac.course_id == ^course_id and ac.show_grading_summary) - |> order_by(:order) - |> group_by([ac], ac.id) - |> select([ac], %{ - id: ac.id, - type: ac.type - }) - |> Repo.all() - - data_by_groups = - raw_data - |> Enum.reduce(%{}, fn raw, acc -> - if Map.has_key?(acc, raw.group_name) do - acc - |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded) - |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted) - else - acc - |> put_in([raw.group_name], %{}) - |> put_in([raw.group_name, "groupName"], raw.group_name) - |> put_in([raw.group_name, "leaderName"], raw.leader_name) - |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded) - |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted) - end - end) - - headings = - showing_configs - |> Enum.reduce([], fn config, acc -> - acc ++ ["submitted" <> config.type, "ungraded" <> config.type] - end) - - default_row_data = - headings - |> Enum.reduce(%{}, fn heading, acc -> - put_in(acc, [heading], 0) - end) - - rows = data_by_groups |> Enum.map(fn {_k, row} -> Map.merge(default_row_data, row) end) - cols = ["groupName", "leaderName"] ++ headings - - {:ok, cols, rows} - end - - defp create_empty_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do - %Submission{} - |> Submission.changeset(%{student: cr, assessment: assessment}) - |> Repo.insert() - |> case do - {:ok, submission} -> {:ok, submission} - {:error, _} -> {:error, :race_condition} - end - end - - defp find_or_create_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do - case find_submission(cr, assessment) do - {:ok, submission} -> {:ok, submission} - {:error, _} -> create_empty_submission(cr, assessment) - end - end - - defp insert_or_update_answer( - submission = %Submission{}, - question = %Question{}, - raw_answer, - course_reg_id - ) do - answer_content = build_answer_content(raw_answer, question.type) - - if question.type == :voting do - insert_or_update_voting_answer(submission.id, course_reg_id, question.id, answer_content) - else - answer_changeset = - %Answer{} - |> Answer.changeset(%{ - answer: answer_content, - question_id: question.id, - submission_id: submission.id, - type: question.type - }) - - Repo.insert( - answer_changeset, - on_conflict: [set: [answer: get_change(answer_changeset, :answer)]], - conflict_target: [:submission_id, :question_id] - ) - end - end - - def insert_or_update_voting_answer(submission_id, course_reg_id, question_id, answer_content) do - set_score_to_nil = - SubmissionVotes - |> where(voter_id: ^course_reg_id, question_id: ^question_id) - - voting_multi = - Multi.new() - |> Multi.update_all(:set_score_to_nil, set_score_to_nil, set: [score: nil]) - - answer_content - |> Enum.with_index(1) - |> Enum.reduce(voting_multi, fn {entry, index}, multi -> - multi - |> Multi.run("update#{index}", fn _repo, _ -> - SubmissionVotes - |> Repo.get_by( - voter_id: course_reg_id, - submission_id: entry.submission_id - ) - |> SubmissionVotes.changeset(%{score: entry.score}) - |> Repo.insert_or_update() - end) - end) - |> Multi.run("insert into answer table", fn _repo, _ -> - Answer - |> Repo.get_by(submission_id: submission_id, question_id: question_id) - |> case do - nil -> - Repo.insert(%Answer{ - answer: %{completed: true}, - submission_id: submission_id, - question_id: question_id, - type: :voting - }) - - _ -> - {:ok, nil} - end - end) - |> Repo.transaction() - |> case do - {:ok, _result} -> {:ok, nil} - {:error, _name, _changeset, _error} -> {:error, :invalid_vote} - end - end - - defp build_answer_content(raw_answer, question_type) do - case question_type do - :mcq -> - %{choice_id: raw_answer} - - :programming -> - %{code: raw_answer} - - :voting -> - raw_answer - |> Enum.map(fn ans -> - for {key, value} <- ans, into: %{}, do: {String.to_existing_atom(key), value} - end) - end - end -end +defmodule Cadet.Assessments do + @moduledoc """ + Assessments context contains domain logic for assessments management such as + missions, sidequests, paths, etc. + """ + use Cadet, [:context, :display] + import Ecto.Query + + require Logger + + alias Cadet.Accounts.{ + Notification, + Notifications, + User, + CourseRegistration, + CourseRegistrations + } + + alias Cadet.Assessments.{Answer, Assessment, Query, Question, Submission, SubmissionVotes} + alias Cadet.Autograder.GradingJob + alias Cadet.Courses.{Group, AssessmentConfig} + alias Cadet.Jobs.Log + alias Cadet.ProgramAnalysis.Lexer + alias Ecto.Multi + alias Cadet.Incentives.Achievements + + require Decimal + + @open_all_assessment_roles ~w(staff admin)a + + # These roles can save and finalise answers for closed assessments and + # submitted answers + @bypass_closed_roles ~w(staff admin)a + + def delete_assessment(id) do + assessment = Repo.get(Assessment, id) + + Submission + |> where(assessment_id: ^id) + |> delete_submission_assocation(id) + + Question + |> where(assessment_id: ^id) + |> Repo.all() + |> Enum.each(fn q -> + delete_submission_votes_association(q) + end) + + Repo.delete(assessment) + end + + defp delete_submission_votes_association(question) do + SubmissionVotes + |> where(question_id: ^question.id) + |> Repo.delete_all() + end + + defp delete_submission_assocation(submissions, assessment_id) do + submissions + |> Repo.all() + |> Enum.each(fn submission -> + Answer + |> where(submission_id: ^submission.id) + |> Repo.delete_all() + end) + + Notification + |> where(assessment_id: ^assessment_id) + |> Repo.delete_all() + + Repo.delete_all(submissions) + end + + @spec user_max_xp(CourseRegistration.t()) :: integer() + def user_max_xp(%CourseRegistration{id: cr_id}) do + Submission + |> where(status: ^:submitted) + |> where(student_id: ^cr_id) + |> join( + :inner, + [s], + a in subquery(Query.all_assessments_with_max_xp()), + on: s.assessment_id == a.id + ) + |> select([_, a], sum(a.max_xp)) + |> Repo.one() + |> decimal_to_integer() + end + + def assessments_total_xp(%CourseRegistration{id: cr_id}) do + submission_xp = + Submission + |> where(student_id: ^cr_id) + |> join(:inner, [s], a in Answer, on: s.id == a.submission_id) + |> group_by([s], s.id) + |> select([s, a], %{ + # grouping by submission, so s.xp_bonus will be the same, but we need an + # aggregate function + total_xp: sum(a.xp) + sum(a.xp_adjustment) + max(s.xp_bonus) + }) + + total = + submission_xp + |> subquery + |> select([s], %{ + total_xp: sum(s.total_xp) + }) + |> Repo.one() + + # for {key, val} <- total, into: %{}, do: {key, decimal_to_integer(val)} + decimal_to_integer(total.total_xp) + end + + def user_total_xp(course_id, user_id, course_reg_id) do + user_course = CourseRegistrations.get_user_course(user_id, course_id) + + total_achievement_xp = Achievements.achievements_total_xp(course_id, course_reg_id) + total_assessment_xp = assessments_total_xp(user_course) + + total_achievement_xp + total_assessment_xp + end + + defp decimal_to_integer(decimal) do + if Decimal.is_decimal(decimal) do + Decimal.to_integer(decimal) + else + 0 + end + end + + def user_current_story(cr = %CourseRegistration{}) do + {:ok, %{result: story}} = + Multi.new() + |> Multi.run(:unattempted, fn _repo, _ -> + {:ok, get_user_story_by_type(cr, :unattempted)} + end) + |> Multi.run(:result, fn _repo, %{unattempted: unattempted_story} -> + if unattempted_story do + {:ok, %{play_story?: true, story: unattempted_story}} + else + {:ok, %{play_story?: false, story: get_user_story_by_type(cr, :attempted)}} + end + end) + |> Repo.transaction() + + story + end + + @spec get_user_story_by_type(CourseRegistration.t(), :unattempted | :attempted) :: + String.t() | nil + def get_user_story_by_type(%CourseRegistration{id: cr_id}, type) + when is_atom(type) do + filter_and_sort = fn query -> + case type do + :unattempted -> + query + |> where([_, s], is_nil(s.id)) + |> order_by([a], asc: a.open_at) + + :attempted -> + query |> order_by([a], desc: a.close_at) + end + end + + Assessment + |> where(is_published: true) + |> where([a], not is_nil(a.story)) + |> where([a], a.open_at <= from_now(0, "second") and a.close_at >= from_now(0, "second")) + |> join(:left, [a], s in Submission, on: s.assessment_id == a.id and s.student_id == ^cr_id) + |> filter_and_sort.() + |> order_by([a], a.config_id) + |> select([a], a.story) + |> first() + |> Repo.one() + end + + def assessment_with_questions_and_answers( + assessment = %Assessment{password: nil}, + cr = %CourseRegistration{}, + nil + ) do + assessment_with_questions_and_answers(assessment, cr) + end + + def assessment_with_questions_and_answers( + assessment = %Assessment{password: nil}, + cr = %CourseRegistration{}, + _ + ) do + assessment_with_questions_and_answers(assessment, cr) + end + + def assessment_with_questions_and_answers( + assessment = %Assessment{password: password}, + cr = %CourseRegistration{}, + given_password + ) do + cond do + Timex.compare(Timex.now(), assessment.close_at) >= 0 -> + assessment_with_questions_and_answers(assessment, cr) + + match?({:ok, _}, find_submission(cr, assessment)) -> + assessment_with_questions_and_answers(assessment, cr) + + given_password == nil -> + {:error, {:forbidden, "Missing Password."}} + + password == given_password -> + find_or_create_submission(cr, assessment) + assessment_with_questions_and_answers(assessment, cr) + + true -> + {:error, {:forbidden, "Invalid Password."}} + end + end + + def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}, password) + when is_ecto_id(id) do + role = cr.role + + assessment = + if role in @open_all_assessment_roles do + Assessment + |> where(id: ^id) + |> preload(:config) + |> Repo.one() + else + Assessment + |> where(id: ^id) + |> where(is_published: true) + |> preload(:config) + |> Repo.one() + end + + if assessment do + assessment_with_questions_and_answers(assessment, cr, password) + else + {:error, {:bad_request, "Assessment not found"}} + end + end + + def assessment_with_questions_and_answers( + assessment = %Assessment{id: id}, + course_reg = %CourseRegistration{role: role} + ) do + if Timex.compare(Timex.now(), assessment.open_at) >= 0 or role in @open_all_assessment_roles do + answer_query = + Answer + |> join(:inner, [a], s in assoc(a, :submission)) + |> where([_, s], s.student_id == ^course_reg.id) + + questions = + Question + |> where(assessment_id: ^id) + |> join(:left, [q], a in subquery(answer_query), on: q.id == a.question_id) + |> join(:left, [_, a], g in assoc(a, :grader)) + |> join(:left, [_, _, g], u in assoc(g, :user)) + |> select([q, a, g, u], {q, a, g, u}) + |> order_by(:display_order) + |> Repo.all() + |> Enum.map(fn + {q, nil, _, _} -> %{q | answer: %Answer{grader: nil}} + {q, a, nil, _} -> %{q | answer: %Answer{a | grader: nil}} + {q, a, g, u} -> %{q | answer: %Answer{a | grader: %CourseRegistration{g | user: u}}} + end) + |> load_contest_voting_entries(course_reg, assessment) + + assessment = assessment |> Map.put(:questions, questions) + {:ok, assessment} + else + {:error, {:unauthorized, "Assessment not open"}} + end + end + + def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}) do + assessment_with_questions_and_answers(id, cr, nil) + end + + @doc """ + Returns a list of assessments with all fields and an indicator showing whether it has been attempted + by the supplied user + """ + def all_assessments(cr = %CourseRegistration{}) do + submission_aggregates = + Submission + |> join(:left, [s], ans in Answer, on: ans.submission_id == s.id) + |> where([s], s.student_id == ^cr.id) + |> group_by([s], s.assessment_id) + |> select([s, ans], %{ + assessment_id: s.assessment_id, + # s.xp_bonus should be the same across the group, but we need an aggregate function here + xp: fragment("? + ? + ?", sum(ans.xp), sum(ans.xp_adjustment), max(s.xp_bonus)), + graded_count: ans.id |> count() |> filter(not is_nil(ans.grader_id)) + }) + + submission_status = + Submission + |> where([s], s.student_id == ^cr.id) + |> select([s], [:assessment_id, :status]) + + assessments = + cr.course_id + |> Query.all_assessments_with_aggregates() + |> subquery() + |> join( + :left, + [a], + sa in subquery(submission_aggregates), + on: a.id == sa.assessment_id + ) + |> join(:left, [a, _], s in subquery(submission_status), on: a.id == s.assessment_id) + |> select([a, sa, s], %{ + a + | xp: sa.xp, + graded_count: sa.graded_count, + user_status: s.status + }) + |> filter_published_assessments(cr) + |> order_by(:open_at) + |> preload(:config) + |> Repo.all() + + {:ok, assessments} + end + + def filter_published_assessments(assessments, cr) do + role = cr.role + + case role do + :student -> where(assessments, is_published: true) + _ -> assessments + end + end + + def create_assessment(params) do + %Assessment{} + |> Assessment.changeset(params) + |> Repo.insert() + end + + @doc """ + The main function that inserts or updates assessments from the XML Parser + """ + @spec insert_or_update_assessments_and_questions(map(), [map()], boolean()) :: + {:ok, any()} + | {:error, Ecto.Multi.name(), any(), %{optional(Ecto.Multi.name()) => any()}} + def insert_or_update_assessments_and_questions( + assessment_params, + questions_params, + force_update + ) do + assessment_multi = + Multi.insert_or_update( + Multi.new(), + :assessment, + insert_or_update_assessment_changeset(assessment_params, force_update) + ) + + if force_update and invalid_force_update(assessment_multi, questions_params) do + {:error, "Question count is different"} + else + questions_params + |> Enum.with_index(1) + |> Enum.reduce(assessment_multi, fn {question_params, index}, multi -> + Multi.run(multi, "question#{index}", fn _repo, %{assessment: %Assessment{id: id}} -> + question = + Question + |> where([q], q.display_order == ^index and q.assessment_id == ^id) + |> Repo.one() + + # the is_nil(question) check allows for force updating of brand new assessments + if !force_update or is_nil(question) do + {status, new_question} = + question_params + |> Map.put(:display_order, index) + |> build_question_changeset_for_assessment_id(id) + |> Repo.insert() + + if status == :ok and new_question.type == :voting do + insert_voting( + assessment_params.course_id, + question_params.question.contest_number, + new_question.id + ) + else + {status, new_question} + end + else + params = + question_params + |> Map.put_new(:max_xp, 0) + |> Map.put(:display_order, index) + + if question_params.type != Atom.to_string(question.type) do + {:error, + create_invalid_changeset_with_error( + :question, + "Question types should remain the same" + )} + else + question + |> Question.changeset(params) + |> Repo.update() + end + end + end) + end) + |> Repo.transaction() + end + end + + # Function that checks if the force update is invalid. The force update is only invalid + # if the new question count is different from the old question count. + defp invalid_force_update(assessment_multi, questions_params) do + assessment_id = + (assessment_multi.operations + |> List.first() + |> elem(1) + |> elem(1)).data.id + + if assessment_id do + open_date = Repo.get(Assessment, assessment_id).open_at + # check if assessment is already opened + if Timex.compare(open_date, Timex.now()) >= 0 do + false + else + existing_questions_count = + Question + |> where([q], q.assessment_id == ^assessment_id) + |> Repo.all() + |> Enum.count() + + new_questions_count = Enum.count(questions_params) + existing_questions_count != new_questions_count + end + else + false + end + end + + @spec insert_or_update_assessment_changeset(map(), boolean()) :: Ecto.Changeset.t() + defp insert_or_update_assessment_changeset( + params = %{number: number, course_id: course_id}, + force_update + ) do + Assessment + |> where(number: ^number) + |> where(course_id: ^course_id) + |> Repo.one() + |> case do + nil -> + Assessment.changeset(%Assessment{}, params) + + %{id: assessment_id} = assessment -> + answers_exist = + Answer + |> join(:inner, [a], q in assoc(a, :question)) + |> join(:inner, [a, q], asst in assoc(q, :assessment)) + |> where([a, q, asst], asst.id == ^assessment_id) + |> Repo.exists?() + + # Maintain the same open/close date when updating an assessment + params = + params + |> Map.delete(:open_at) + |> Map.delete(:close_at) + |> Map.delete(:is_published) + + cond do + not answers_exist -> + # Delete all realted submission_votes + SubmissionVotes + |> join(:inner, [sv, q], q in assoc(sv, :question)) + |> where([sv, q], q.assessment_id == ^assessment_id) + |> Repo.delete_all() + + # Delete all existing questions + Question + |> where(assessment_id: ^assessment_id) + |> Repo.delete_all() + + Assessment.changeset(assessment, params) + + force_update -> + Assessment.changeset(assessment, params) + + true -> + # if the assessment has submissions, don't edit + create_invalid_changeset_with_error(:assessment, "has submissions") + end + end + end + + @spec build_question_changeset_for_assessment_id(map(), number() | String.t()) :: + Ecto.Changeset.t() + defp build_question_changeset_for_assessment_id(params, assessment_id) + when is_ecto_id(assessment_id) do + params_with_assessment_id = Map.put_new(params, :assessment_id, assessment_id) + + Question.changeset(%Question{}, params_with_assessment_id) + end + + @doc """ + Generates and assigns contest entries for users with given usernames. + """ + def insert_voting( + course_id, + contest_number, + question_id + ) do + contest_assessment = Repo.get_by(Assessment, number: contest_number, course_id: course_id) + + if is_nil(contest_assessment) do + changeset = change(%Assessment{}, %{number: ""}) + + error_changeset = + Ecto.Changeset.add_error( + changeset, + :number, + "invalid contest number" + ) + + {:error, error_changeset} + else + # Returns contest submission ids with answers that contain "return" + contest_submission_ids = + Submission + |> join(:inner, [s], ans in assoc(s, :answers)) + |> join(:inner, [s, ans], cr in assoc(s, :student)) + |> where([s, ans, cr], cr.role == "student") + |> where([s, _], s.assessment_id == ^contest_assessment.id and s.status == "submitted") + |> where( + [_, ans, cr], + fragment( + "?->>'code' like ?", + ans.answer, + "%return%" + ) + ) + |> select([s, _ans], {s.student_id, s.id}) + |> Repo.all() + |> Enum.into(%{}) + + contest_submission_ids_length = Enum.count(contest_submission_ids) + + voter_ids = + CourseRegistration + |> where(role: "student", course_id: ^course_id) + |> select([cr], cr.id) + |> Repo.all() + + votes_per_user = min(contest_submission_ids_length, 10) + + votes_per_submission = + if Enum.empty?(contest_submission_ids) do + 0 + else + trunc(Float.ceil(votes_per_user * length(voter_ids) / contest_submission_ids_length)) + end + + submission_id_list = + contest_submission_ids + |> Enum.map(fn {_, s_id} -> s_id end) + |> Enum.shuffle() + |> List.duplicate(votes_per_submission) + |> List.flatten() + + {_submission_map, submission_votes_changesets} = + voter_ids + |> Enum.reduce({submission_id_list, []}, fn voter_id, acc -> + {submission_list, submission_votes} = acc + + user_contest_submission_id = Map.get(contest_submission_ids, voter_id) + + {votes, rest} = + submission_list + |> Enum.reduce_while({MapSet.new(), submission_list}, fn s_id, acc -> + {user_votes, submissions} = acc + + max_votes = + if votes_per_user == contest_submission_ids_length and + not is_nil(user_contest_submission_id) do + # no. of submssions is less than 10. Unable to find + votes_per_user - 1 + else + votes_per_user + end + + if MapSet.size(user_votes) < max_votes do + if s_id != user_contest_submission_id and not MapSet.member?(user_votes, s_id) do + new_user_votes = MapSet.put(user_votes, s_id) + new_submissions = List.delete(submissions, s_id) + {:cont, {new_user_votes, new_submissions}} + else + {:cont, {user_votes, submissions}} + end + else + {:halt, acc} + end + end) + + votes = MapSet.to_list(votes) + + new_submission_votes = + votes + |> Enum.map(fn s_id -> + %SubmissionVotes{voter_id: voter_id, submission_id: s_id, question_id: question_id} + end) + |> Enum.concat(submission_votes) + + {rest, new_submission_votes} + end) + + submission_votes_changesets + |> Enum.with_index() + |> Enum.reduce(Multi.new(), fn {changeset, index}, multi -> + Multi.insert(multi, Integer.to_string(index), changeset) + end) + |> Repo.transaction() + end + end + + def update_assessment(id, params) when is_ecto_id(id) do + IO.inspect(params) + simple_update( + Assessment, + id, + using: &Assessment.changeset/2, + params: params + ) + end + + def update_question(id, params) when is_ecto_id(id) do + simple_update( + Question, + id, + using: &Question.changeset/2, + params: params + ) + end + + def publish_assessment(id) when is_ecto_id(id) do + update_assessment(id, %{is_published: true}) + end + + def create_question_for_assessment(params, assessment_id) when is_ecto_id(assessment_id) do + assessment = + Assessment + |> where(id: ^assessment_id) + |> join(:left, [a], q in assoc(a, :questions)) + |> preload([_, q], questions: q) + |> Repo.one() + + if assessment do + params_with_assessment_id = Map.put_new(params, :assessment_id, assessment.id) + + %Question{} + |> Question.changeset(params_with_assessment_id) + |> put_display_order(assessment.questions) + |> Repo.insert() + else + {:error, "Assessment not found"} + end + end + + def get_question(id) when is_ecto_id(id) do + Question + |> where(id: ^id) + |> join(:inner, [q], assessment in assoc(q, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.one() + end + + def delete_question(id) when is_ecto_id(id) do + question = Repo.get(Question, id) + Repo.delete(question) + end + + @doc """ + Public internal api to submit new answers for a question. Possible return values are: + `{:ok, nil}` -> success + `{:error, error}` -> failed. `error` is in the format of `{http_response_code, error message}` + + Note: In the event of `find_or_create_submission` failing due to a race condition, error will be: + `{:bad_request, "Missing or invalid parameter(s)"}` + + """ + def answer_question( + question = %Question{}, + cr = %CourseRegistration{id: cr_id}, + raw_answer, + force_submit + ) do + with {:ok, submission} <- find_or_create_submission(cr, question.assessment), + {:status, true} <- {:status, force_submit or submission.status != :submitted}, + {:ok, _answer} <- insert_or_update_answer(submission, question, raw_answer, cr_id) do + update_submission_status_router(submission, question) + + {:ok, nil} + else + {:status, _} -> + {:error, {:forbidden, "Assessment submission already finalised"}} + + {:error, :race_condition} -> + {:error, {:internal_server_error, "Please try again later."}} + + {:error, :invalid_vote} -> + {:error, {:bad_request, "Invalid vote! Vote is not saved."}} + + _ -> + {:error, {:bad_request, "Missing or invalid parameter(s)"}} + end + end + + def get_submission(assessment_id, %CourseRegistration{id: cr_id}) + when is_ecto_id(assessment_id) do + Submission + |> where(assessment_id: ^assessment_id) + |> where(student_id: ^cr_id) + |> join(:inner, [s], a in assoc(s, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.one() + end + + def get_submission_by_id(submission_id) when is_ecto_id(submission_id) do + Submission + |> where(id: ^submission_id) + |> join(:inner, [s], a in assoc(s, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.one() + end + + def finalise_submission(submission = %Submission{}) do + with {:status, :attempted} <- {:status, submission.status}, + {:ok, updated_submission} <- update_submission_status_and_xp_bonus(submission) do + # Couple with update_submission_status_and_xp_bonus to ensure notification is sent + Notifications.write_notification_when_student_submits(submission) + # Send email notification to avenger + %{notification_type: "assessment_submission", submission_id: updated_submission.id} + |> Cadet.Workers.NotificationWorker.new() + |> Oban.insert() + + # Begin autograding job + GradingJob.force_grade_individual_submission(updated_submission) + + {:ok, nil} + else + {:status, :attempting} -> + {:error, {:bad_request, "Some questions have not been attempted"}} + + {:status, :submitted} -> + {:error, {:forbidden, "Assessment has already been submitted"}} + + _ -> + {:error, {:internal_server_error, "Please try again later."}} + end + end + + def unsubmit_submission( + submission_id, + cr = %CourseRegistration{id: course_reg_id, role: role} + ) + when is_ecto_id(submission_id) do + submission = + Submission + |> join(:inner, [s], a in assoc(s, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.get(submission_id) + + # allows staff to unsubmit own assessment + bypass = role in @bypass_closed_roles and submission.student_id == course_reg_id + + with {:submission_found?, true} <- {:submission_found?, is_map(submission)}, + {:is_open?, true} <- {:is_open?, bypass or is_open?(submission.assessment)}, + {:status, :submitted} <- {:status, submission.status}, + {:allowed_to_unsubmit?, true} <- + {:allowed_to_unsubmit?, + role == :admin or bypass or + Cadet.Accounts.Query.avenger_of?(cr, submission.student_id)} do + Multi.new() + |> Multi.run( + :rollback_submission, + fn _repo, _ -> + submission + |> Submission.changeset(%{ + status: :attempted, + xp_bonus: 0, + unsubmitted_by_id: course_reg_id, + unsubmitted_at: Timex.now() + }) + |> Repo.update() + end + ) + |> Multi.run(:rollback_answers, fn _repo, _ -> + Answer + |> join(:inner, [a], q in assoc(a, :question)) + |> join(:inner, [a, _], s in assoc(a, :submission)) + |> preload([_, q, s], question: q, submission: s) + |> where(submission_id: ^submission.id) + |> Repo.all() + |> Enum.reduce_while({:ok, nil}, fn answer, acc -> + case acc do + {:error, _} -> + {:halt, acc} + + {:ok, _} -> + {:cont, + answer + |> Answer.grading_changeset(%{ + xp: 0, + xp_adjustment: 0, + autograding_status: :none, + autograding_results: [] + }) + |> Repo.update()} + end + end) + end) + |> Repo.transaction() + + Cadet.Accounts.Notifications.handle_unsubmit_notifications( + submission.assessment.id, + Repo.get(CourseRegistration, submission.student_id) + ) + + {:ok, nil} + else + {:submission_found?, false} -> + {:error, {:not_found, "Submission not found"}} + + {:is_open?, false} -> + {:error, {:forbidden, "Assessment not open"}} + + {:status, :attempting} -> + {:error, {:bad_request, "Some questions have not been attempted"}} + + {:status, :attempted} -> + {:error, {:bad_request, "Assessment has not been submitted"}} + + {:allowed_to_unsubmit?, false} -> + {:error, {:forbidden, "Only Avenger of student or Admin is permitted to unsubmit"}} + + _ -> + {:error, {:internal_server_error, "Please try again later."}} + end + end + + @spec update_submission_status_and_xp_bonus(Submission.t()) :: + {:ok, Submission.t()} | {:error, Ecto.Changeset.t()} + defp update_submission_status_and_xp_bonus(submission = %Submission{}) do + assessment = submission.assessment + assessment_conifg = Repo.get_by(AssessmentConfig, id: assessment.config_id) + + max_bonus_xp = assessment_conifg.early_submission_xp + early_hours = assessment_conifg.hours_before_early_xp_decay + + xp_bonus = + if Timex.before?(Timex.now(), Timex.shift(assessment.open_at, hours: early_hours)) do + max_bonus_xp + else + # This logic interpolates from max bonus at early hour to 0 bonus at close time + decaying_hours = Timex.diff(assessment.close_at, assessment.open_at, :hours) - early_hours + remaining_hours = Enum.max([0, Timex.diff(assessment.close_at, Timex.now(), :hours)]) + proportion = if(decaying_hours > 0, do: remaining_hours / decaying_hours, else: 1) + bonus_xp = round(max_bonus_xp * proportion) + Enum.max([0, bonus_xp]) + end + + submission + |> Submission.changeset(%{status: :submitted, xp_bonus: xp_bonus}) + |> Repo.update() + end + + defp update_submission_status_router(submission = %Submission{}, question = %Question{}) do + case question.type do + :voting -> update_contest_voting_submission_status(submission, question) + :mcq -> update_submission_status(submission, question.assessment) + :programming -> update_submission_status(submission, question.assessment) + end + end + + defp update_submission_status(submission = %Submission{}, assessment = %Assessment{}) do + model_assoc_count = fn model, assoc, id -> + model + |> where(id: ^id) + |> join(:inner, [m], a in assoc(m, ^assoc)) + |> select([_, a], count(a.id)) + |> Repo.one() + end + + Multi.new() + |> Multi.run(:assessment, fn _repo, _ -> + {:ok, model_assoc_count.(Assessment, :questions, assessment.id)} + end) + |> Multi.run(:submission, fn _repo, _ -> + {:ok, model_assoc_count.(Submission, :answers, submission.id)} + end) + |> Multi.run(:update, fn _repo, %{submission: s_count, assessment: a_count} -> + if s_count == a_count do + submission |> Submission.changeset(%{status: :attempted}) |> Repo.update() + else + {:ok, nil} + end + end) + |> Repo.transaction() + end + + defp update_contest_voting_submission_status(submission = %Submission{}, question = %Question{}) do + has_nil_entries = + SubmissionVotes + |> where(question_id: ^question.id) + |> where(voter_id: ^submission.student_id) + |> where([sv], is_nil(sv.score)) + |> Repo.exists?() + + unless has_nil_entries do + submission |> Submission.changeset(%{status: :attempted}) |> Repo.update() + end + end + + defp load_contest_voting_entries( + questions, + %CourseRegistration{role: role, course_id: course_id, id: voter_id}, + assessment + ) do + Enum.map( + questions, + fn q -> + if q.type == :voting do + submission_votes = all_submission_votes_by_question_id_and_voter_id(q.id, voter_id) + # fetch top 10 contest voting entries with the contest question id + question_id = fetch_associated_contest_question_id(course_id, q) + + leaderboard_results = + if is_nil(question_id) do + [] + else + if leaderboard_open?(assessment, q) or role in @open_all_assessment_roles do + fetch_top_relative_score_answers(question_id, 10) + else + [] + end + end + + # populate entries to vote for and leaderboard data into the question + voting_question = + q.question + |> Map.put(:contest_entries, submission_votes) + |> Map.put( + :contest_leaderboard, + leaderboard_results + ) + + Map.put(q, :question, voting_question) + else + q + end + end + ) + end + + defp all_submission_votes_by_question_id_and_voter_id(question_id, voter_id) do + SubmissionVotes + |> where([v], v.voter_id == ^voter_id and v.question_id == ^question_id) + |> join(:inner, [v], s in assoc(v, :submission)) + |> join(:inner, [v, s], a in assoc(s, :answers)) + |> select([v, s, a], %{submission_id: v.submission_id, answer: a.answer, score: v.score}) + |> Repo.all() + end + + # Finds the contest_question_id associated with the given voting_question id + defp fetch_associated_contest_question_id(course_id, voting_question) do + contest_number = voting_question.question["contest_number"] + + if is_nil(contest_number) do + nil + else + Assessment + |> where(number: ^contest_number, course_id: ^course_id) + |> join(:inner, [a], q in assoc(a, :questions)) + |> order_by([a, q], q.display_order) + |> select([a, q], q.id) + |> Repo.one() + end + end + + defp leaderboard_open?(assessment, voting_question) do + Timex.before?( + Timex.shift(assessment.close_at, hours: voting_question.question["reveal_hours"]), + Timex.now() + ) + end + + @doc """ + Fetches top answers for the given question, based on the contest relative_score + + Used for contest leaderboard fetching + """ + def fetch_top_relative_score_answers(question_id, number_of_answers) do + Answer + |> where(question_id: ^question_id) + |> where( + [a], + fragment( + "?->>'code' like ?", + a.answer, + "%return%" + ) + ) + |> order_by(desc: :relative_score) + |> join(:left, [a], s in assoc(a, :submission)) + |> join(:left, [a, s], student in assoc(s, :student)) + |> join(:inner, [a, s, student], student_user in assoc(student, :user)) + |> where([a, s, student], student.role == "student") + |> select([a, s, student, student_user], %{ + submission_id: a.submission_id, + answer: a.answer, + relative_score: a.relative_score, + student_name: student_user.name + }) + |> limit(^number_of_answers) + |> Repo.all() + end + + @doc """ + Computes rolling leaderboard for contest votes that are still open. + """ + def update_rolling_contest_leaderboards do + # 115 = 2 hours - 5 minutes is default. + if Log.log_execution("update_rolling_contest_leaderboards", Timex.Duration.from_minutes(115)) do + Logger.info("Started update_rolling_contest_leaderboards") + + voting_questions_to_update = fetch_active_voting_questions() + + _ = + voting_questions_to_update + |> Enum.map(fn qn -> compute_relative_score(qn.id) end) + + Logger.info("Successfully update_rolling_contest_leaderboards") + end + end + + def fetch_active_voting_questions do + Question + |> join(:left, [q], a in assoc(q, :assessment)) + |> where([q, a], q.type == "voting") + |> where([q, a], a.is_published) + |> where([q, a], a.open_at <= ^Timex.now() and a.close_at >= ^Timex.now()) + |> Repo.all() + end + + @doc """ + Computes final leaderboard for contest votes that have closed. + """ + def update_final_contest_leaderboards do + # 1435 = 24 hours - 5 minutes + if Log.log_execution("update_final_contest_leaderboards", Timex.Duration.from_minutes(1435)) do + Logger.info("Started update_final_contest_leaderboards") + + voting_questions_to_update = fetch_voting_questions_due_yesterday() + + _ = + voting_questions_to_update + |> Enum.map(fn qn -> compute_relative_score(qn.id) end) + + Logger.info("Successfully update_final_contest_leaderboards") + end + end + + def fetch_voting_questions_due_yesterday do + Question + |> join(:left, [q], a in assoc(q, :assessment)) + |> where([q, a], q.type == "voting") + |> where([q, a], a.is_published) + |> where([q, a], a.open_at <= ^Timex.now()) + |> where( + [q, a], + a.close_at < ^Timex.now() and a.close_at >= ^Timex.shift(Timex.now(), days: -1) + ) + |> Repo.all() + end + + @doc """ + Computes the current relative_score of each voting submission answer + based on current submitted votes. + """ + def compute_relative_score(contest_voting_question_id) do + # query all records from submission votes tied to the question id -> + # map score to user id -> + # store as grade -> + # query grade for contest question id. + eligible_votes = + SubmissionVotes + |> where(question_id: ^contest_voting_question_id) + |> where([sv], not is_nil(sv.score)) + |> join(:inner, [sv], ans in Answer, on: sv.submission_id == ans.submission_id) + |> select( + [sv, ans], + %{ans_id: ans.id, score: sv.score, ans: ans.answer["code"]} + ) + |> Repo.all() + + entry_scores = map_eligible_votes_to_entry_score(eligible_votes) + + entry_scores + |> Enum.map(fn {ans_id, relative_score} -> + %Answer{id: ans_id} + |> Answer.contest_score_update_changeset(%{ + relative_score: relative_score + }) + end) + |> Enum.map(fn changeset -> + op_key = "answer_#{changeset.data.id}" + Multi.update(Multi.new(), op_key, changeset) + end) + |> Enum.reduce(Multi.new(), &Multi.append/2) + |> Repo.transaction() + end + + defp map_eligible_votes_to_entry_score(eligible_votes) do + # converts eligible votes to the {total cumulative score, number of votes, tokens} + entry_vote_data = + Enum.reduce(eligible_votes, %{}, fn %{ans_id: ans_id, score: score, ans: ans}, tracker -> + {prev_score, prev_count, _ans_tokens} = Map.get(tracker, ans_id, {0, 0, 0}) + + Map.put( + tracker, + ans_id, + # assume each voter is assigned 10 entries which will make it fair. + {prev_score + score, prev_count + 1, Lexer.count_tokens(ans)} + ) + end) + + # calculate the score based on formula {ans_id, score} + Enum.map( + entry_vote_data, + fn {ans_id, {sum_of_scores, number_of_voters, tokens}} -> + {ans_id, calculate_formula_score(sum_of_scores, number_of_voters, tokens)} + end + ) + end + + # Calculate the score based on formula + # score(v,t) = v - 2^(t/50) where v is the normalized_voting_score + # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 + defp calculate_formula_score(sum_of_scores, number_of_voters, tokens) do + normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 + normalized_voting_score - :math.pow(2, min(1023.5, tokens / 50)) + end + + @doc """ + Function returning submissions under a grader. This function returns only the + fields that are exposed in the /grading endpoint. The reason we select only + those fields is to reduce the memory usage especially when the number of + submissions is large i.e. > 25000 submissions. + + The input parameters are the user and group_only. group_only is used to check + whether only the groups under the grader should be returned. The parameter is + a boolean which is false by default. + + The return value is {:ok, submissions} if no errors, else it is {:error, + {:unauthorized, "Forbidden."}} + """ + @spec all_submissions_by_grader_for_index(CourseRegistration.t()) :: + {:ok, String.t()} + def all_submissions_by_grader_for_index( + grader = %CourseRegistration{course_id: course_id}, + group_only \\ false, + ungraded_only \\ false + ) do + show_all = not group_only + + group_where = + if show_all, + do: "", + else: + "where s.student_id in (select cr.id from course_registrations cr inner join groups g on cr.group_id = g.id where g.leader_id = $2) or s.student_id = $2" + + ungraded_where = + if ungraded_only, + do: "where s.\"gradedCount\" < assts.\"questionCount\"", + else: "" + + params = if show_all, do: [course_id], else: [course_id, grader.id] + + # We bypass Ecto here and use a raw query to generate JSON directly from + # PostgreSQL, because doing it in Elixir/Erlang is too inefficient. + + case Repo.query( + """ + select json_agg(q)::TEXT from + ( + select + s.id, + s.status, + s."unsubmittedAt", + s.xp, + s."xpAdjustment", + s."xpBonus", + s."gradedCount", + assts.jsn as assessment, + students.jsn as student, + unsubmitters.jsn as "unsubmittedBy" + from + (select + s.id, + s.student_id, + s.assessment_id, + s.status, + s.unsubmitted_at as "unsubmittedAt", + s.unsubmitted_by_id, + sum(ans.xp) as xp, + sum(ans.xp_adjustment) as "xpAdjustment", + s.xp_bonus as "xpBonus", + count(ans.id) filter (where ans.grader_id is not null) as "gradedCount" + from submissions s + left join + answers ans on s.id = ans.submission_id + #{group_where} + group by s.id) s + inner join + (select + a.id, a."questionCount", to_json(a) as jsn + from + (select + a.id, + a.title, + bool_or(ac.is_manually_graded) as "isManuallyGraded", + max(ac.type) as "type", + sum(q.max_xp) as "maxXp", + count(q.id) as "questionCount" + from assessments a + left join + questions q on a.id = q.assessment_id + inner join + assessment_configs ac on ac.id = a.config_id + where a.course_id = $1 + group by a.id) a) assts on assts.id = s.assessment_id + inner join + (select + cr.id, to_json(cr) as jsn + from + (select + cr.id, + u.name as "name", + g.name as "groupName", + g.leader_id as "groupLeaderId" + from course_registrations cr + left join + groups g on g.id = cr.group_id + inner join + users u on u.id = cr.user_id) cr) students on students.id = s.student_id + left join + (select + cr.id, to_json(cr) as jsn + from + (select + cr.id, + u.name + from course_registrations cr + inner join + users u on u.id = cr.user_id) cr) unsubmitters on s.unsubmitted_by_id = unsubmitters.id + #{ungraded_where} + ) q + """, + params + ) do + {:ok, %{rows: [[nil]]}} -> {:ok, "[]"} + {:ok, %{rows: [[json]]}} -> {:ok, json} + end + end + + @spec get_answers_in_submission(integer() | String.t()) :: + {:ok, [Answer.t()]} | {:error, {:bad_request | :unauthorized, String.t()}} + def get_answers_in_submission(id) when is_ecto_id(id) do + answer_query = + Answer + |> where(submission_id: ^id) + |> join(:inner, [a], q in assoc(a, :question)) + |> join(:inner, [_, q], ast in assoc(q, :assessment)) + |> join(:inner, [a, ..., ast], ac in assoc(ast, :config)) + |> join(:left, [a, ...], g in assoc(a, :grader)) + |> join(:left, [a, ..., g], gu in assoc(g, :user)) + |> join(:inner, [a, ...], s in assoc(a, :submission)) + |> join(:inner, [a, ..., s], st in assoc(s, :student)) + |> join(:inner, [a, ..., st], u in assoc(st, :user)) + |> preload([_, q, ast, ac, g, gu, s, st, u], + question: {q, assessment: {ast, config: ac}}, + grader: {g, user: gu}, + submission: {s, student: {st, user: u}} + ) + + answers = + answer_query + |> Repo.all() + |> Enum.sort_by(& &1.question.display_order) + |> Enum.map(fn ans -> + if ans.question.type == :voting do + empty_contest_entries = Map.put(ans.question.question, :contest_entries, []) + empty_contest_leaderboard = Map.put(empty_contest_entries, :contest_leaderboard, []) + question = Map.put(ans.question, :question, empty_contest_leaderboard) + Map.put(ans, :question, question) + else + ans + end + end) + + if answers == [] do + {:error, {:bad_request, "Submission is not found."}} + else + {:ok, answers} + end + end + + defp is_fully_graded?(%Answer{submission_id: submission_id}) do + submission = + Submission + |> Repo.get_by(id: submission_id) + + question_count = + Question + |> where(assessment_id: ^submission.assessment_id) + |> select([q], count(q.id)) + |> Repo.one() + + graded_count = + Answer + |> where([a], submission_id: ^submission_id) + |> where([a], not is_nil(a.grader_id)) + |> select([a], count(a.id)) + |> Repo.one() + + question_count == graded_count + end + + @spec update_grading_info( + %{submission_id: integer() | String.t(), question_id: integer() | String.t()}, + %{}, + CourseRegistration.t() + ) :: + {:ok, nil} + | {:error, {:unauthorized | :bad_request | :internal_server_error, String.t()}} + def update_grading_info( + %{submission_id: submission_id, question_id: question_id}, + attrs, + %CourseRegistration{id: grader_id} + ) + when is_ecto_id(submission_id) and is_ecto_id(question_id) do + attrs = Map.put(attrs, "grader_id", grader_id) + + answer_query = + Answer + |> where(submission_id: ^submission_id) + |> where(question_id: ^question_id) + + answer_query = + answer_query + |> join(:inner, [a], s in assoc(a, :submission)) + |> preload([_, s], submission: s) + + answer = Repo.one(answer_query) + + is_own_submission = grader_id == answer.submission.student_id + + with {:answer_found?, true} <- {:answer_found?, is_map(answer)}, + {:status, true} <- + {:status, answer.submission.status == :submitted or is_own_submission}, + {:valid, changeset = %Ecto.Changeset{valid?: true}} <- + {:valid, Answer.grading_changeset(answer, attrs)}, + {:ok, _} <- Repo.update(changeset) do + if is_fully_graded?(answer) and not is_own_submission do + # Every answer in this submission has been graded manually + Notifications.write_notification_when_graded(submission_id, :graded) + else + {:ok, nil} + end + else + {:answer_found?, false} -> + {:error, {:bad_request, "Answer not found or user not permitted to grade."}} + + {:valid, changeset} -> + {:error, {:bad_request, full_error_messages(changeset)}} + + {:status, _} -> + {:error, {:method_not_allowed, "Submission is not submitted yet."}} + + {:error, _} -> + {:error, {:internal_server_error, "Please try again later."}} + end + end + + def update_grading_info( + _, + _, + _ + ) do + {:error, {:unauthorized, "User is not permitted to grade."}} + end + + @spec force_regrade_submission(integer() | String.t(), CourseRegistration.t()) :: + {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}} + def force_regrade_submission( + submission_id, + _requesting_user = %CourseRegistration{id: grader_id} + ) + when is_ecto_id(submission_id) do + with {:get, sub} when not is_nil(sub) <- {:get, Repo.get(Submission, submission_id)}, + {:status, true} <- {:status, sub.student_id == grader_id or sub.status == :submitted} do + GradingJob.force_grade_individual_submission(sub, true) + {:ok, nil} + else + {:get, nil} -> + {:error, {:not_found, "Submission not found"}} + + {:status, false} -> + {:error, {:bad_request, "Submission not submitted yet"}} + end + end + + def force_regrade_submission(_, _) do + {:error, {:forbidden, "User is not permitted to grade."}} + end + + @spec force_regrade_answer( + integer() | String.t(), + integer() | String.t(), + CourseRegistration.t() + ) :: + {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}} + def force_regrade_answer( + submission_id, + question_id, + _requesting_user = %CourseRegistration{id: grader_id} + ) + when is_ecto_id(submission_id) and is_ecto_id(question_id) do + answer = + Answer + |> where(submission_id: ^submission_id, question_id: ^question_id) + |> preload([:question, :submission]) + |> Repo.one() + + with {:get, answer} when not is_nil(answer) <- {:get, answer}, + {:status, true} <- + {:status, + answer.submission.student_id == grader_id or answer.submission.status == :submitted} do + GradingJob.grade_answer(answer, answer.question, true) + {:ok, nil} + else + {:get, nil} -> + {:error, {:not_found, "Answer not found"}} + + {:status, false} -> + {:error, {:bad_request, "Submission not submitted yet"}} + end + end + + def force_regrade_answer(_, _, _) do + {:error, {:forbidden, "User is not permitted to grade."}} + end + + defp find_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do + submission = + Submission + |> where(student_id: ^cr.id) + |> where(assessment_id: ^assessment.id) + |> Repo.one() + + if submission do + {:ok, submission} + else + {:error, nil} + end + end + + # Checks if an assessment is open and published. + @spec is_open?(Assessment.t()) :: boolean() + def is_open?(%Assessment{open_at: open_at, close_at: close_at, is_published: is_published}) do + Timex.between?(Timex.now(), open_at, close_at, inclusive: :start) and is_published + end + + @spec get_group_grading_summary(integer()) :: + {:ok, [String.t(), ...], []} + def get_group_grading_summary(course_id) do + subs = + Answer + |> join(:left, [ans], s in Submission, on: s.id == ans.submission_id) + |> join(:left, [ans, s], st in CourseRegistration, on: s.student_id == st.id) + |> join(:left, [ans, s, st], a in Assessment, on: a.id == s.assessment_id) + |> join(:inner, [ans, s, st, a], ac in AssessmentConfig, on: ac.id == a.config_id) + |> where( + [ans, s, st, a, ac], + not is_nil(st.group_id) and s.status == ^:submitted and + ac.show_grading_summary and a.course_id == ^course_id + ) + |> group_by([ans, s, st, a, ac], s.id) + |> select([ans, s, st, a, ac], %{ + group_id: max(st.group_id), + config_id: max(ac.id), + config_type: max(ac.type), + num_submitted: count(), + num_ungraded: filter(count(), is_nil(ans.grader_id)) + }) + + raw_data = + subs + |> subquery() + |> join(:left, [t], g in Group, on: t.group_id == g.id) + |> join(:left, [t, g], l in CourseRegistration, on: l.id == g.leader_id) + |> join(:left, [t, g, l], lu in User, on: lu.id == l.user_id) + |> group_by([t, g, l, lu], [t.group_id, t.config_id, t.config_type, g.name, lu.name]) + |> select([t, g, l, lu], %{ + group_name: g.name, + leader_name: lu.name, + config_id: t.config_id, + config_type: t.config_type, + ungraded: filter(count(), t.num_ungraded > 0), + submitted: count() + }) + |> Repo.all() + + showing_configs = + AssessmentConfig + |> where([ac], ac.course_id == ^course_id and ac.show_grading_summary) + |> order_by(:order) + |> group_by([ac], ac.id) + |> select([ac], %{ + id: ac.id, + type: ac.type + }) + |> Repo.all() + + data_by_groups = + raw_data + |> Enum.reduce(%{}, fn raw, acc -> + if Map.has_key?(acc, raw.group_name) do + acc + |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded) + |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted) + else + acc + |> put_in([raw.group_name], %{}) + |> put_in([raw.group_name, "groupName"], raw.group_name) + |> put_in([raw.group_name, "leaderName"], raw.leader_name) + |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded) + |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted) + end + end) + + headings = + showing_configs + |> Enum.reduce([], fn config, acc -> + acc ++ ["submitted" <> config.type, "ungraded" <> config.type] + end) + + default_row_data = + headings + |> Enum.reduce(%{}, fn heading, acc -> + put_in(acc, [heading], 0) + end) + + rows = data_by_groups |> Enum.map(fn {_k, row} -> Map.merge(default_row_data, row) end) + cols = ["groupName", "leaderName"] ++ headings + + {:ok, cols, rows} + end + + defp create_empty_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do + %Submission{} + |> Submission.changeset(%{student: cr, assessment: assessment}) + |> Repo.insert() + |> case do + {:ok, submission} -> {:ok, submission} + {:error, _} -> {:error, :race_condition} + end + end + + defp find_or_create_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do + case find_submission(cr, assessment) do + {:ok, submission} -> {:ok, submission} + {:error, _} -> create_empty_submission(cr, assessment) + end + end + + defp insert_or_update_answer( + submission = %Submission{}, + question = %Question{}, + raw_answer, + course_reg_id + ) do + answer_content = build_answer_content(raw_answer, question.type) + + if question.type == :voting do + insert_or_update_voting_answer(submission.id, course_reg_id, question.id, answer_content) + else + answer_changeset = + %Answer{} + |> Answer.changeset(%{ + answer: answer_content, + question_id: question.id, + submission_id: submission.id, + type: question.type + }) + + Repo.insert( + answer_changeset, + on_conflict: [set: [answer: get_change(answer_changeset, :answer)]], + conflict_target: [:submission_id, :question_id] + ) + end + end + + def insert_or_update_voting_answer(submission_id, course_reg_id, question_id, answer_content) do + set_score_to_nil = + SubmissionVotes + |> where(voter_id: ^course_reg_id, question_id: ^question_id) + + voting_multi = + Multi.new() + |> Multi.update_all(:set_score_to_nil, set_score_to_nil, set: [score: nil]) + + answer_content + |> Enum.with_index(1) + |> Enum.reduce(voting_multi, fn {entry, index}, multi -> + multi + |> Multi.run("update#{index}", fn _repo, _ -> + SubmissionVotes + |> Repo.get_by( + voter_id: course_reg_id, + submission_id: entry.submission_id + ) + |> SubmissionVotes.changeset(%{score: entry.score}) + |> Repo.insert_or_update() + end) + end) + |> Multi.run("insert into answer table", fn _repo, _ -> + Answer + |> Repo.get_by(submission_id: submission_id, question_id: question_id) + |> case do + nil -> + Repo.insert(%Answer{ + answer: %{completed: true}, + submission_id: submission_id, + question_id: question_id, + type: :voting + }) + + _ -> + {:ok, nil} + end + end) + |> Repo.transaction() + |> case do + {:ok, _result} -> {:ok, nil} + {:error, _name, _changeset, _error} -> {:error, :invalid_vote} + end + end + + defp build_answer_content(raw_answer, question_type) do + case question_type do + :mcq -> + %{choice_id: raw_answer} + + :programming -> + %{code: raw_answer} + + :voting -> + raw_answer + |> Enum.map(fn ans -> + for {key, value} <- ans, into: %{}, do: {String.to_existing_atom(key), value} + end) + end + end +end diff --git a/lib/cadet_web/admin_controllers/admin_assessments_controller.ex b/lib/cadet_web/admin_controllers/admin_assessments_controller.ex index 72cdad12f..bc6181544 100644 --- a/lib/cadet_web/admin_controllers/admin_assessments_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_assessments_controller.ex @@ -1,223 +1,223 @@ -defmodule CadetWeb.AdminAssessmentsController do - use CadetWeb, :controller - - use PhoenixSwagger - - import Ecto.Query, only: [where: 2] - import Cadet.Updater.XMLParser, only: [parse_xml: 4] - - alias Cadet.{Assessments, Repo} - alias Cadet.Assessments.Assessment - alias Cadet.Accounts.CourseRegistration - - def index(conn, %{"course_reg_id" => course_reg_id}) do - course_reg = Repo.get(CourseRegistration, course_reg_id) - {:ok, assessments} = Assessments.all_assessments(course_reg) - - render(conn, "index.json", assessments: assessments) - end - - def get_assessment(conn, %{"course_reg_id" => course_reg_id, "assessmentid" => assessment_id}) - when is_ecto_id(assessment_id) do - course_reg = Repo.get(CourseRegistration, course_reg_id) - - case Assessments.assessment_with_questions_and_answers(assessment_id, course_reg) do - {:ok, assessment} -> render(conn, "show.json", assessment: assessment) - {:error, {status, message}} -> send_resp(conn, status, message) - end - end - - def create(conn, %{ - "course_id" => course_id, - "assessment" => assessment, - "forceUpdate" => force_update, - "assessmentConfigId" => assessment_config_id - }) do - file = - assessment["file"].path - |> File.read!() - - result = - case force_update do - "true" -> parse_xml(file, course_id, assessment_config_id, true) - "false" -> parse_xml(file, course_id, assessment_config_id, false) - end - - case result do - :ok -> - if force_update == "true" do - text(conn, "Force update OK") - else - text(conn, "OK") - end - - {:ok, warning_message} -> - text(conn, warning_message) - - {:error, {status, message}} -> - conn - |> put_status(status) - |> text(message) - end - end - - def delete(conn, %{"course_id" => course_id, "assessmentid" => assessment_id}) do - with {:same_course, true} <- {:same_course, is_same_course(course_id, assessment_id)}, - {:ok, _} <- Assessments.delete_assessment(assessment_id) do - text(conn, "OK") - else - {:same_course, false} -> - conn - |> put_status(403) - |> text("User not allow to delete assessments from another course") - - {:error, {status, message}} -> - conn - |> put_status(status) - |> text(message) - end - end - - def update(conn, params = %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do - open_at = params |> Map.get("openAt") - close_at = params |> Map.get("closeAt") - is_published = params |> Map.get("isPublished") - max_team_size = params |> Map.get("maxTeamSize") - - updated_assessment = - if is_nil(is_published) do - %{} - else - %{:is_published => is_published} - end - - updated_assessment = - if is_nil(max_team_size) do - updated_assessment - else - Map.put(updated_assessment, :max_team_size, max_team_size) - end - - with {:ok, assessment} <- check_dates(open_at, close_at, updated_assessment), - {:ok, _nil} <- Assessments.update_assessment(assessment_id, assessment) do - text(conn, "OK") - else - {:error, {status, message}} -> - conn - |> put_status(status) - |> text(message) - end - end - - defp check_dates(open_at, close_at, assessment) do - if is_nil(open_at) and is_nil(close_at) do - {:ok, assessment} - else - formatted_open_date = elem(DateTime.from_iso8601(open_at), 1) - formatted_close_date = elem(DateTime.from_iso8601(close_at), 1) - - if Timex.before?(formatted_close_date, formatted_open_date) do - {:error, {:bad_request, "New end date should occur after new opening date"}} - else - assessment = Map.put(assessment, :open_at, formatted_open_date) - assessment = Map.put(assessment, :close_at, formatted_close_date) - IO.inspect("good") - {:ok, assessment} - end - end - end - - defp is_same_course(course_id, assessment_id) do - Assessment - |> where(id: ^assessment_id) - |> where(course_id: ^course_id) - |> Repo.exists?() - end - - swagger_path :index do - get("/admin/users/{courseRegId}/assessments") - - summary("Fetches assessment overviews of a user") - - security([%{JWT: []}]) - - parameters do - courseRegId(:path, :integer, "Course Reg ID", required: true) - end - - response(200, "OK", Schema.array(:AssessmentsList)) - response(401, "Unauthorised") - response(403, "Forbidden") - end - - swagger_path :create do - post("/admin/assessments") - - summary("Creates a new assessment or updates an existing assessment") - - security([%{JWT: []}]) - - consumes("multipart/form-data") - - parameters do - assessment(:formData, :file, "Assessment to create or update", required: true) - forceUpdate(:formData, :boolean, "Force update", required: true) - end - - response(200, "OK") - response(400, "XML parse error") - response(403, "Forbidden") - end - - swagger_path :delete do - PhoenixSwagger.Path.delete("/admin/assessments/{assessmentId}") - - summary("Deletes an assessment") - - security([%{JWT: []}]) - - parameters do - assessmentId(:path, :integer, "Assessment ID", required: true) - end - - response(200, "OK") - response(403, "Forbidden") - end - - swagger_path :update do - post("/admin/assessments/{assessmentId}") - - summary("Updates an assessment") - - security([%{JWT: []}]) - - consumes("application/json") - - parameters do - assessmentId(:path, :integer, "Assessment ID", required: true) - - assessment(:body, Schema.ref(:AdminUpdateAssessmentPayload), "Updated assessment details", - required: true - ) - end - - response(200, "OK") - response(401, "Assessment is already opened") - response(403, "Forbidden") - end - - def swagger_definitions do - %{ - # Schemas for payloads to modify data - AdminUpdateAssessmentPayload: - swagger_schema do - properties do - closeAt(:string, "Open date", required: false) - openAt(:string, "Close date", required: false) - isPublished(:boolean, "Whether the assessment is published", required: false) - maxTeamSize(:number, "Max team size of the assessment", required: false) - end - end - } - end -end +defmodule CadetWeb.AdminAssessmentsController do + use CadetWeb, :controller + + use PhoenixSwagger + + import Ecto.Query, only: [where: 2] + import Cadet.Updater.XMLParser, only: [parse_xml: 4] + + alias Cadet.{Assessments, Repo} + alias Cadet.Assessments.Assessment + alias Cadet.Accounts.CourseRegistration + + def index(conn, %{"course_reg_id" => course_reg_id}) do + course_reg = Repo.get(CourseRegistration, course_reg_id) + {:ok, assessments} = Assessments.all_assessments(course_reg) + + render(conn, "index.json", assessments: assessments) + end + + def get_assessment(conn, %{"course_reg_id" => course_reg_id, "assessmentid" => assessment_id}) + when is_ecto_id(assessment_id) do + course_reg = Repo.get(CourseRegistration, course_reg_id) + + case Assessments.assessment_with_questions_and_answers(assessment_id, course_reg) do + {:ok, assessment} -> render(conn, "show.json", assessment: assessment) + {:error, {status, message}} -> send_resp(conn, status, message) + end + end + + def create(conn, %{ + "course_id" => course_id, + "assessment" => assessment, + "forceUpdate" => force_update, + "assessmentConfigId" => assessment_config_id + }) do + file = + assessment["file"].path + |> File.read!() + + result = + case force_update do + "true" -> parse_xml(file, course_id, assessment_config_id, true) + "false" -> parse_xml(file, course_id, assessment_config_id, false) + end + + case result do + :ok -> + if force_update == "true" do + text(conn, "Force update OK") + else + text(conn, "OK") + end + + {:ok, warning_message} -> + text(conn, warning_message) + + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + end + end + + def delete(conn, %{"course_id" => course_id, "assessmentid" => assessment_id}) do + with {:same_course, true} <- {:same_course, is_same_course(course_id, assessment_id)}, + {:ok, _} <- Assessments.delete_assessment(assessment_id) do + text(conn, "OK") + else + {:same_course, false} -> + conn + |> put_status(403) + |> text("User not allow to delete assessments from another course") + + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + end + end + + def update(conn, params = %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do + open_at = params |> Map.get("openAt") + close_at = params |> Map.get("closeAt") + is_published = params |> Map.get("isPublished") + max_team_size = params |> Map.get("maxTeamSize") + + updated_assessment = + if is_nil(is_published) do + %{} + else + %{:is_published => is_published} + end + + updated_assessment = + if is_nil(max_team_size) do + updated_assessment + else + Map.put(updated_assessment, :max_team_size, max_team_size) + end + + with {:ok, assessment} <- check_dates(open_at, close_at, updated_assessment), + {:ok, _nil} <- Assessments.update_assessment(assessment_id, assessment) do + text(conn, "OK") + else + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + end + end + + defp check_dates(open_at, close_at, assessment) do + if is_nil(open_at) and is_nil(close_at) do + {:ok, assessment} + else + formatted_open_date = elem(DateTime.from_iso8601(open_at), 1) + formatted_close_date = elem(DateTime.from_iso8601(close_at), 1) + + if Timex.before?(formatted_close_date, formatted_open_date) do + {:error, {:bad_request, "New end date should occur after new opening date"}} + else + assessment = Map.put(assessment, :open_at, formatted_open_date) + assessment = Map.put(assessment, :close_at, formatted_close_date) + + {:ok, assessment} + end + end + end + + defp is_same_course(course_id, assessment_id) do + Assessment + |> where(id: ^assessment_id) + |> where(course_id: ^course_id) + |> Repo.exists?() + end + + swagger_path :index do + get("/admin/users/{courseRegId}/assessments") + + summary("Fetches assessment overviews of a user") + + security([%{JWT: []}]) + + parameters do + courseRegId(:path, :integer, "Course Reg ID", required: true) + end + + response(200, "OK", Schema.array(:AssessmentsList)) + response(401, "Unauthorised") + response(403, "Forbidden") + end + + swagger_path :create do + post("/admin/assessments") + + summary("Creates a new assessment or updates an existing assessment") + + security([%{JWT: []}]) + + consumes("multipart/form-data") + + parameters do + assessment(:formData, :file, "Assessment to create or update", required: true) + forceUpdate(:formData, :boolean, "Force update", required: true) + end + + response(200, "OK") + response(400, "XML parse error") + response(403, "Forbidden") + end + + swagger_path :delete do + PhoenixSwagger.Path.delete("/admin/assessments/{assessmentId}") + + summary("Deletes an assessment") + + security([%{JWT: []}]) + + parameters do + assessmentId(:path, :integer, "Assessment ID", required: true) + end + + response(200, "OK") + response(403, "Forbidden") + end + + swagger_path :update do + post("/admin/assessments/{assessmentId}") + + summary("Updates an assessment") + + security([%{JWT: []}]) + + consumes("application/json") + + parameters do + assessmentId(:path, :integer, "Assessment ID", required: true) + + assessment(:body, Schema.ref(:AdminUpdateAssessmentPayload), "Updated assessment details", + required: true + ) + end + + response(200, "OK") + response(401, "Assessment is already opened") + response(403, "Forbidden") + end + + def swagger_definitions do + %{ + # Schemas for payloads to modify data + AdminUpdateAssessmentPayload: + swagger_schema do + properties do + closeAt(:string, "Open date", required: false) + openAt(:string, "Close date", required: false) + isPublished(:boolean, "Whether the assessment is published", required: false) + maxTeamSize(:number, "Max team size of the assessment", required: false) + end + end + } + end +end diff --git a/lib/cadet_web/admin_views/admin_assessments_view.ex b/lib/cadet_web/admin_views/admin_assessments_view.ex index 71043d1ab..477ea1b38 100644 --- a/lib/cadet_web/admin_views/admin_assessments_view.ex +++ b/lib/cadet_web/admin_views/admin_assessments_view.ex @@ -1,64 +1,64 @@ -defmodule CadetWeb.AdminAssessmentsView do - use CadetWeb, :view - use Timex - import CadetWeb.AssessmentsHelpers - - def render("index.json", %{assessments: assessments}) do - render_many(assessments, CadetWeb.AdminAssessmentsView, "overview.json", as: :assessment) - end - - def render("overview.json", %{assessment: assessment}) do - transform_map_for_view(assessment, %{ - id: :id, - courseId: :course_id, - title: :title, - shortSummary: :summary_short, - openAt: &format_datetime(&1.open_at), - closeAt: &format_datetime(&1.close_at), - type: & &1.config.type, - isManuallyGraded: & &1.config.is_manually_graded, - story: :story, - number: :number, - reading: :reading, - status: &(&1.user_status || "not_attempted"), - maxXp: :max_xp, - xp: &(&1.xp || 0), - coverImage: :cover_picture, - private: &password_protected?(&1.password), - isPublished: :is_published, - questionCount: :question_count, - gradedCount: &(&1.graded_count || 0), - maxTeamSize: :max_team_size - }) - end - - def render("show.json", %{assessment: assessment}) do - transform_map_for_view( - assessment, - %{ - id: :id, - courseId: :course_id, - title: :title, - type: & &1.config.type, - story: :story, - number: :number, - reading: :reading, - longSummary: :summary_long, - missionPDF: &Cadet.Assessments.Upload.url({&1.mission_pdf, &1}), - questions: - &Enum.map(&1.questions, fn question -> - map = - build_question_with_answer_and_solution_if_ungraded(%{ - question: question - }) - - map - end) - } - ) - end - - defp password_protected?(nil), do: false - - defp password_protected?(_), do: true -end +defmodule CadetWeb.AdminAssessmentsView do + use CadetWeb, :view + use Timex + import CadetWeb.AssessmentsHelpers + + def render("index.json", %{assessments: assessments}) do + render_many(assessments, CadetWeb.AdminAssessmentsView, "overview.json", as: :assessment) + end + + def render("overview.json", %{assessment: assessment}) do + transform_map_for_view(assessment, %{ + id: :id, + courseId: :course_id, + title: :title, + shortSummary: :summary_short, + openAt: &format_datetime(&1.open_at), + closeAt: &format_datetime(&1.close_at), + type: & &1.config.type, + isManuallyGraded: & &1.config.is_manually_graded, + story: :story, + number: :number, + reading: :reading, + status: &(&1.user_status || "not_attempted"), + maxXp: :max_xp, + xp: &(&1.xp || 0), + coverImage: :cover_picture, + private: &password_protected?(&1.password), + isPublished: :is_published, + questionCount: :question_count, + gradedCount: &(&1.graded_count || 0), + maxTeamSize: :max_team_size + }) + end + + def render("show.json", %{assessment: assessment}) do + transform_map_for_view( + assessment, + %{ + id: :id, + courseId: :course_id, + title: :title, + type: & &1.config.type, + story: :story, + number: :number, + reading: :reading, + longSummary: :summary_long, + missionPDF: &Cadet.Assessments.Upload.url({&1.mission_pdf, &1}), + questions: + &Enum.map(&1.questions, fn question -> + map = + build_question_with_answer_and_solution_if_ungraded(%{ + question: question + }) + + map + end) + } + ) + end + + defp password_protected?(nil), do: false + + defp password_protected?(_), do: true +end diff --git a/lib/cadet_web/controllers/assessments_controller.ex b/lib/cadet_web/controllers/assessments_controller.ex index e8b5d3df3..d5a4ea5e5 100644 --- a/lib/cadet_web/controllers/assessments_controller.ex +++ b/lib/cadet_web/controllers/assessments_controller.ex @@ -1,403 +1,403 @@ -defmodule CadetWeb.AssessmentsController do - use CadetWeb, :controller - - use PhoenixSwagger - - alias Cadet.Assessments - - # These roles can save and finalise answers for closed assessments and - # submitted answers - @bypass_closed_roles ~w(staff admin)a - - def submit(conn, %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do - cr = conn.assigns.course_reg - - with {:submission, submission} when not is_nil(submission) <- - {:submission, Assessments.get_submission(assessment_id, cr)}, - {:is_open?, true} <- - {:is_open?, - cr.role in @bypass_closed_roles or Assessments.is_open?(submission.assessment)}, - {:ok, _nil} <- Assessments.finalise_submission(submission) do - text(conn, "OK") - else - {:submission, nil} -> - conn - |> put_status(:not_found) - |> text("Submission not found") - - {:is_open?, false} -> - conn - |> put_status(:forbidden) - |> text("Assessment not open") - - {:error, {status, message}} -> - conn - |> put_status(status) - |> text(message) - end - end - - def index(conn, _) do - cr = conn.assigns.course_reg - {:ok, assessments} = Assessments.all_assessments(cr) - - render(conn, "index.json", assessments: assessments) - end - - def show(conn, %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do - cr = conn.assigns.course_reg - - case Assessments.assessment_with_questions_and_answers(assessment_id, cr) do - {:ok, assessment} -> render(conn, "show.json", assessment: assessment) - {:error, {status, message}} -> send_resp(conn, status, message) - end - end - - def unlock(conn, %{"assessmentid" => assessment_id, "password" => password}) - when is_ecto_id(assessment_id) do - cr = conn.assigns.course_reg - - case Assessments.assessment_with_questions_and_answers(assessment_id, cr, password) do - {:ok, assessment} -> render(conn, "show.json", assessment: assessment) - {:error, {status, message}} -> send_resp(conn, status, message) - end - end - - swagger_path :submit do - post("/assessments/{assessmentId}/submit") - summary("Finalise submission for an assessment") - security([%{JWT: []}]) - - parameters do - assessmentId(:path, :integer, "Assessment ID", required: true) - end - - response(200, "OK") - - response( - 400, - "Invalid parameters or incomplete submission (submission with unanswered questions)" - ) - - response(403, "User not permitted to answer questions or assessment not open") - response(404, "Submission not found") - end - - swagger_path :index do - get("/assessments") - - summary("Get a list of all assessments") - - security([%{JWT: []}]) - - produces("application/json") - - response(200, "OK", Schema.ref(:AssessmentsList)) - response(401, "Unauthorised") - end - - swagger_path :show do - get("/assessments/{assessmentId}") - - summary("Get information about one particular assessment") - - security([%{JWT: []}]) - - consumes("application/json") - produces("application/json") - - parameters do - assessmentId(:path, :integer, "Assessment ID", required: true) - end - - response(200, "OK", Schema.ref(:Assessment)) - response(400, "Missing parameter(s) or invalid assessmentId") - response(401, "Unauthorised") - end - - swagger_path :unlock do - post("/assessments/{assessmentId}/unlock") - - summary("Unlocks a password-protected assessment and returns its information") - - security([%{JWT: []}]) - - consumes("application/json") - produces("application/json") - - parameters do - assessmentId(:path, :integer, "Assessment ID", required: true) - - password(:body, Schema.ref(:UnlockAssessmentPayload), "Password to unlock assessment", - required: true - ) - end - - response(200, "OK", Schema.ref(:Assessment)) - response(400, "Missing parameter(s) or invalid assessmentId") - response(401, "Unauthorised") - response(403, "Password incorrect") - end - - def swagger_definitions do - %{ - AssessmentsList: - swagger_schema do - description("A list of all assessments") - type(:array) - items(Schema.ref(:AssessmentOverview)) - end, - AssessmentOverview: - swagger_schema do - properties do - id(:integer, "The assessment ID", required: true) - title(:string, "The title of the assessment", required: true) - - config(Schema.ref(:AssessmentConfig), "The assessment config", required: true) - - shortSummary(:string, "Short summary", required: true) - - number( - :string, - "The string identifying the relative position of this assessment", - required: true - ) - - story(:string, "The story that should be shown for this assessment") - reading(:string, "The reading for this assessment") - openAt(:string, "The opening date", format: "date-time", required: true) - closeAt(:string, "The closing date", format: "date-time", required: true) - - status( - Schema.ref(:AssessmentStatus), - "One of 'not_attempted/attempting/attempted/submitted' indicating whether the assessment has been attempted by the current user", - required: true - ) - - maxXp( - :integer, - "The maximum XP for this assessment", - required: true - ) - - xp(:integer, "The XP earned for this assessment", required: true) - - coverImage(:string, "The URL to the cover picture", required: true) - - private(:boolean, "Is this an private assessment?", required: true) - - isPublished(:boolean, "Is the assessment published?", required: true) - - questionCount(:integer, "The number of questions in this assessment", required: true) - - gradedCount( - :integer, - "The number of answers in the submission which have been graded", - required: true - ) - - maxTeamSize(:integer, "The maximum team size allowed", required: true) - end - end, - Assessment: - swagger_schema do - properties do - id(:integer, "The assessment ID", required: true) - title(:string, "The title of the assessment", required: true) - - config(Schema.ref(:AssessmentConfig), "The assessment config", required: true) - - number( - :string, - "The string identifying the relative position of this assessment", - required: true - ) - - story(:string, "The story that should be shown for this assessment") - reading(:string, "The reading for this assessment") - longSummary(:string, "Long summary", required: true) - missionPDF(:string, "The URL to the assessment pdf") - - questions(Schema.ref(:Questions), "The list of questions for this assessment") - end - end, - AssessmentConfig: - swagger_schema do - description("Assessment config") - type(:string) - enum([:mission, :sidequest, :path, :contest, :practical]) - end, - AssessmentStatus: - swagger_schema do - type(:string) - enum([:not_attempted, :attempting, :attempted, :submitted]) - end, - Questions: - swagger_schema do - description("A list of questions") - type(:array) - items(Schema.ref(:Question)) - end, - Question: - swagger_schema do - properties do - id(:integer, "The question ID", required: true) - type(:string, "The question type (mcq/programming)", required: true) - content(:string, "The question content", required: true) - - choices( - Schema.new do - type(:array) - items(Schema.ref(:MCQChoice)) - end, - "MCQ choices if question type is mcq" - ) - - solution(:integer, "Solution to a mcq question if it belongs to path assessment") - - answer( - # Note: this is technically an invalid type in Swagger/OpenAPI 2.0, - # but represents that a string or integer could be returned. - :string_or_integer, - "Previous answer for this question (string/int) depending on question type", - required: true - ) - - library( - Schema.ref(:Library), - "The library used for this question" - ) - - prepend(:string, "Prepend program for programming questions") - solutionTemplate(:string, "Solution template for programming questions") - postpend(:string, "Postpend program for programming questions") - - testcases( - Schema.new do - type(:array) - items(Schema.ref(:Testcase)) - end, - "Testcase programs for programming questions" - ) - - grader(Schema.ref(:GraderInfo)) - - gradedAt(:string, "Last graded at", format: "date-time", required: false) - - xp(:integer, "Final XP given to this question. Only provided for students.") - grade(:integer, "Final grade given to this question. Only provided for students.") - comments(:string, "String of comments given to a student's answer", required: false) - - maxGrade( - :integer, - "The max grade for this question", - required: true - ) - - maxXp( - :integer, - "The max xp for this question", - required: true - ) - - autogradingStatus(Schema.ref(:AutogradingStatus), "The status of the autograder") - - autogradingResults( - Schema.new do - type(:array) - items(Schema.ref(:AutogradingResult)) - end - ) - end - end, - MCQChoice: - swagger_schema do - properties do - content(:string, "The choice content", required: true) - hint(:string, "The hint", required: true) - end - end, - ExternalLibrary: - swagger_schema do - properties do - name(:string, "Name of the external library", required: true) - - symbols( - Schema.new do - type(:array) - - items( - Schema.new do - type(:string) - end - ) - end - ) - end - end, - Library: - swagger_schema do - properties do - chapter(:integer) - - globals( - Schema.new do - type(:array) - - items( - Schema.new do - type(:string) - end - ) - end - ) - - external( - Schema.ref(:ExternalLibrary), - "The external library for this question" - ) - end - end, - Testcase: - swagger_schema do - properties do - answer(:string) - score(:integer) - program(:string) - type(Schema.ref(:TestcaseType), "One of public/opaque/secret") - end - end, - TestcaseType: - swagger_schema do - type(:string) - enum([:public, :opaque, :secret]) - end, - AutogradingResult: - swagger_schema do - properties do - resultType(Schema.ref(:AutogradingResultType), "One of pass/fail/error") - expected(:string) - actual(:string) - end - end, - AutogradingResultType: - swagger_schema do - type(:string) - enum([:pass, :fail, :error]) - end, - AutogradingStatus: - swagger_schema do - type(:string) - enum([:none, :processing, :success, :failed]) - end, - - # Schemas for payloads to modify data - UnlockAssessmentPayload: - swagger_schema do - properties do - password(:string, "Password", required: true) - end - end - } - end -end +defmodule CadetWeb.AssessmentsController do + use CadetWeb, :controller + + use PhoenixSwagger + + alias Cadet.Assessments + + # These roles can save and finalise answers for closed assessments and + # submitted answers + @bypass_closed_roles ~w(staff admin)a + + def submit(conn, %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do + cr = conn.assigns.course_reg + + with {:submission, submission} when not is_nil(submission) <- + {:submission, Assessments.get_submission(assessment_id, cr)}, + {:is_open?, true} <- + {:is_open?, + cr.role in @bypass_closed_roles or Assessments.is_open?(submission.assessment)}, + {:ok, _nil} <- Assessments.finalise_submission(submission) do + text(conn, "OK") + else + {:submission, nil} -> + conn + |> put_status(:not_found) + |> text("Submission not found") + + {:is_open?, false} -> + conn + |> put_status(:forbidden) + |> text("Assessment not open") + + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + end + end + + def index(conn, _) do + cr = conn.assigns.course_reg + {:ok, assessments} = Assessments.all_assessments(cr) + + render(conn, "index.json", assessments: assessments) + end + + def show(conn, %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do + cr = conn.assigns.course_reg + + case Assessments.assessment_with_questions_and_answers(assessment_id, cr) do + {:ok, assessment} -> render(conn, "show.json", assessment: assessment) + {:error, {status, message}} -> send_resp(conn, status, message) + end + end + + def unlock(conn, %{"assessmentid" => assessment_id, "password" => password}) + when is_ecto_id(assessment_id) do + cr = conn.assigns.course_reg + + case Assessments.assessment_with_questions_and_answers(assessment_id, cr, password) do + {:ok, assessment} -> render(conn, "show.json", assessment: assessment) + {:error, {status, message}} -> send_resp(conn, status, message) + end + end + + swagger_path :submit do + post("/assessments/{assessmentId}/submit") + summary("Finalise submission for an assessment") + security([%{JWT: []}]) + + parameters do + assessmentId(:path, :integer, "Assessment ID", required: true) + end + + response(200, "OK") + + response( + 400, + "Invalid parameters or incomplete submission (submission with unanswered questions)" + ) + + response(403, "User not permitted to answer questions or assessment not open") + response(404, "Submission not found") + end + + swagger_path :index do + get("/assessments") + + summary("Get a list of all assessments") + + security([%{JWT: []}]) + + produces("application/json") + + response(200, "OK", Schema.ref(:AssessmentsList)) + response(401, "Unauthorised") + end + + swagger_path :show do + get("/assessments/{assessmentId}") + + summary("Get information about one particular assessment") + + security([%{JWT: []}]) + + consumes("application/json") + produces("application/json") + + parameters do + assessmentId(:path, :integer, "Assessment ID", required: true) + end + + response(200, "OK", Schema.ref(:Assessment)) + response(400, "Missing parameter(s) or invalid assessmentId") + response(401, "Unauthorised") + end + + swagger_path :unlock do + post("/assessments/{assessmentId}/unlock") + + summary("Unlocks a password-protected assessment and returns its information") + + security([%{JWT: []}]) + + consumes("application/json") + produces("application/json") + + parameters do + assessmentId(:path, :integer, "Assessment ID", required: true) + + password(:body, Schema.ref(:UnlockAssessmentPayload), "Password to unlock assessment", + required: true + ) + end + + response(200, "OK", Schema.ref(:Assessment)) + response(400, "Missing parameter(s) or invalid assessmentId") + response(401, "Unauthorised") + response(403, "Password incorrect") + end + + def swagger_definitions do + %{ + AssessmentsList: + swagger_schema do + description("A list of all assessments") + type(:array) + items(Schema.ref(:AssessmentOverview)) + end, + AssessmentOverview: + swagger_schema do + properties do + id(:integer, "The assessment ID", required: true) + title(:string, "The title of the assessment", required: true) + + config(Schema.ref(:AssessmentConfig), "The assessment config", required: true) + + shortSummary(:string, "Short summary", required: true) + + number( + :string, + "The string identifying the relative position of this assessment", + required: true + ) + + story(:string, "The story that should be shown for this assessment") + reading(:string, "The reading for this assessment") + openAt(:string, "The opening date", format: "date-time", required: true) + closeAt(:string, "The closing date", format: "date-time", required: true) + + status( + Schema.ref(:AssessmentStatus), + "One of 'not_attempted/attempting/attempted/submitted' indicating whether the assessment has been attempted by the current user", + required: true + ) + + maxXp( + :integer, + "The maximum XP for this assessment", + required: true + ) + + xp(:integer, "The XP earned for this assessment", required: true) + + coverImage(:string, "The URL to the cover picture", required: true) + + private(:boolean, "Is this an private assessment?", required: true) + + isPublished(:boolean, "Is the assessment published?", required: true) + + questionCount(:integer, "The number of questions in this assessment", required: true) + + gradedCount( + :integer, + "The number of answers in the submission which have been graded", + required: true + ) + + maxTeamSize(:integer, "The maximum team size allowed", required: true) + end + end, + Assessment: + swagger_schema do + properties do + id(:integer, "The assessment ID", required: true) + title(:string, "The title of the assessment", required: true) + + config(Schema.ref(:AssessmentConfig), "The assessment config", required: true) + + number( + :string, + "The string identifying the relative position of this assessment", + required: true + ) + + story(:string, "The story that should be shown for this assessment") + reading(:string, "The reading for this assessment") + longSummary(:string, "Long summary", required: true) + missionPDF(:string, "The URL to the assessment pdf") + + questions(Schema.ref(:Questions), "The list of questions for this assessment") + end + end, + AssessmentConfig: + swagger_schema do + description("Assessment config") + type(:string) + enum([:mission, :sidequest, :path, :contest, :practical]) + end, + AssessmentStatus: + swagger_schema do + type(:string) + enum([:not_attempted, :attempting, :attempted, :submitted]) + end, + Questions: + swagger_schema do + description("A list of questions") + type(:array) + items(Schema.ref(:Question)) + end, + Question: + swagger_schema do + properties do + id(:integer, "The question ID", required: true) + type(:string, "The question type (mcq/programming)", required: true) + content(:string, "The question content", required: true) + + choices( + Schema.new do + type(:array) + items(Schema.ref(:MCQChoice)) + end, + "MCQ choices if question type is mcq" + ) + + solution(:integer, "Solution to a mcq question if it belongs to path assessment") + + answer( + # Note: this is technically an invalid type in Swagger/OpenAPI 2.0, + # but represents that a string or integer could be returned. + :string_or_integer, + "Previous answer for this question (string/int) depending on question type", + required: true + ) + + library( + Schema.ref(:Library), + "The library used for this question" + ) + + prepend(:string, "Prepend program for programming questions") + solutionTemplate(:string, "Solution template for programming questions") + postpend(:string, "Postpend program for programming questions") + + testcases( + Schema.new do + type(:array) + items(Schema.ref(:Testcase)) + end, + "Testcase programs for programming questions" + ) + + grader(Schema.ref(:GraderInfo)) + + gradedAt(:string, "Last graded at", format: "date-time", required: false) + + xp(:integer, "Final XP given to this question. Only provided for students.") + grade(:integer, "Final grade given to this question. Only provided for students.") + comments(:string, "String of comments given to a student's answer", required: false) + + maxGrade( + :integer, + "The max grade for this question", + required: true + ) + + maxXp( + :integer, + "The max xp for this question", + required: true + ) + + autogradingStatus(Schema.ref(:AutogradingStatus), "The status of the autograder") + + autogradingResults( + Schema.new do + type(:array) + items(Schema.ref(:AutogradingResult)) + end + ) + end + end, + MCQChoice: + swagger_schema do + properties do + content(:string, "The choice content", required: true) + hint(:string, "The hint", required: true) + end + end, + ExternalLibrary: + swagger_schema do + properties do + name(:string, "Name of the external library", required: true) + + symbols( + Schema.new do + type(:array) + + items( + Schema.new do + type(:string) + end + ) + end + ) + end + end, + Library: + swagger_schema do + properties do + chapter(:integer) + + globals( + Schema.new do + type(:array) + + items( + Schema.new do + type(:string) + end + ) + end + ) + + external( + Schema.ref(:ExternalLibrary), + "The external library for this question" + ) + end + end, + Testcase: + swagger_schema do + properties do + answer(:string) + score(:integer) + program(:string) + type(Schema.ref(:TestcaseType), "One of public/opaque/secret") + end + end, + TestcaseType: + swagger_schema do + type(:string) + enum([:public, :opaque, :secret]) + end, + AutogradingResult: + swagger_schema do + properties do + resultType(Schema.ref(:AutogradingResultType), "One of pass/fail/error") + expected(:string) + actual(:string) + end + end, + AutogradingResultType: + swagger_schema do + type(:string) + enum([:pass, :fail, :error]) + end, + AutogradingStatus: + swagger_schema do + type(:string) + enum([:none, :processing, :success, :failed]) + end, + + # Schemas for payloads to modify data + UnlockAssessmentPayload: + swagger_schema do + properties do + password(:string, "Password", required: true) + end + end + } + end +end diff --git a/mix.exs b/mix.exs index 361ccc9eb..df2b7cec6 100644 --- a/mix.exs +++ b/mix.exs @@ -1,132 +1,132 @@ -defmodule Cadet.Mixfile do - use Mix.Project - - def project do - [ - app: :cadet, - version: "0.0.1", - elixir: "~> 1.10", - elixirc_paths: elixirc_paths(Mix.env()), - compilers: Mix.compilers() ++ [:phoenix_swagger], - start_permanent: Mix.env() == :prod, - test_coverage: [tool: ExCoveralls], - preferred_cli_env: [ - coveralls: :test, - "coveralls.detail": :test, - "coveralls.post": :test, - "coveralls.html": :test - ], - aliases: aliases(), - deps: deps(), - dialyzer: [ - plt_add_apps: [:mix, :ex_unit], - plt_local_path: "priv/plts", - plt_core_path: "priv/plts" - ], - releases: [ - cadet: [ - steps: [:assemble, :tar] - ] - ] - ] - end - - # Configuration for the OTP application. - # - # Type `mix help compile.app` for more information. - def application do - [ - mod: {Cadet.Application, []}, - extra_applications: [:sentry, :logger, :que, :runtime_tools] - ] - end - - # Specifies which paths to compile per environment. - defp elixirc_paths(:test), do: ["lib", "test/support", "test/factories"] - defp elixirc_paths(:dev), do: ["lib", "test/factories"] - defp elixirc_paths(_), do: ["lib"] - - # Specifies your project dependencies. - # - # Type `mix help deps` for examples and options. - defp deps do - [ - {:arc, "~> 0.11"}, - {:arc_ecto, "~> 0.11"}, - {:corsica, "~> 1.1"}, - {:csv, "~> 2.3"}, - {:ecto_enum, "~> 1.0"}, - {:ex_aws, "~> 2.1", override: true}, - {:ex_aws_lambda, "~> 2.0"}, - {:ex_aws_s3, "~> 2.0"}, - {:ex_aws_secretsmanager, "~> 2.0"}, - {:ex_aws_sts, "~> 2.1"}, - {:ex_json_schema, "~> 0.7.4"}, - {:ex_machina, "~> 2.3"}, - {:guardian, "~> 2.0"}, - {:guardian_db, "~> 2.0"}, - {:hackney, "~> 1.6"}, - {:httpoison, "~> 1.6"}, - {:jason, "~> 1.2"}, - {:openid_connect, "~> 0.2"}, - {:phoenix, "~> 1.5"}, - {:phoenix_view, "~> 2.0"}, - {:phoenix_ecto, "~> 4.0"}, - {:phoenix_swagger, "~> 0.8"}, - {:plug_cowboy, "~> 2.0"}, - {:postgrex, ">= 0.0.0"}, - {:quantum, "~> 3.0"}, - {:que, "~> 0.10"}, - {:recase, "~> 0.7", override: true}, - {:sentry, "~> 8.0"}, - {:sweet_xml, "~> 0.6"}, - {:timex, "~> 3.7"}, - - # notifiations system dependencies - {:phoenix_html, "~> 3.0"}, - {:bamboo, "~> 2.3.0"}, - {:bamboo_ses, "~> 0.3.0"}, - {:bamboo_phoenix, "~> 1.0.0"}, - {:oban, "~> 2.13"}, - - # development dependencies - {:configparser_ex, "~> 4.0", only: [:dev, :test]}, - {:credo, "~> 1.0", only: [:dev, :test], runtime: false}, - {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, - {:distillery, "~> 2.1", runtime: false}, - {:faker, "~> 0.10", only: [:dev, :test]}, - {:git_hooks, "~> 0.4", only: [:dev, :test]}, - - # RC to fix https://github.com/rrrene/inch_ex/pull/68 - {:inch_ex, "~> 2.1-rc", only: [:dev, :test]}, - - # unit testing dependencies - {:bypass, "~> 2.1", only: :test}, - {:excoveralls, "~> 0.8", only: :test}, - {:exvcr, "~> 0.10", only: :test}, - {:mock, "~> 0.3.0", only: :test}, - - # The following are indirect dependencies, but we need to override the - # versions due to conflicts - {:jsx, "~> 3.1", override: true}, - {:xml_builder, "~> 2.1", override: true} - ] - end - - # Aliases are shortcuts or tasks specific to the current project. - # For example, to create, migrate and run the seeds file at once: - # - # $ mix ecto.setup - # - # See the documentation for `Mix` for more info on aliases. - defp aliases do - [ - "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], - "ecto.reset": ["ecto.drop", "ecto.setup"], - test: ["ecto.create --quiet", "ecto.migrate", "test"], - "phx.server": ["cadet.server"], - "phx.digest": ["cadet.digest"], - sentry_recompile: ["deps.compile sentry --force", "compile"] - ] - end -end +defmodule Cadet.Mixfile do + use Mix.Project + + def project do + [ + app: :cadet, + version: "0.0.1", + elixir: "~> 1.10", + elixirc_paths: elixirc_paths(Mix.env()), + compilers: Mix.compilers() ++ [:phoenix_swagger], + start_permanent: Mix.env() == :prod, + test_coverage: [tool: ExCoveralls], + preferred_cli_env: [ + coveralls: :test, + "coveralls.detail": :test, + "coveralls.post": :test, + "coveralls.html": :test + ], + aliases: aliases(), + deps: deps(), + dialyzer: [ + plt_add_apps: [:mix, :ex_unit], + plt_local_path: "priv/plts", + plt_core_path: "priv/plts" + ], + releases: [ + cadet: [ + steps: [:assemble, :tar] + ] + ] + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {Cadet.Application, []}, + extra_applications: [:sentry, :logger, :que, :runtime_tools] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support", "test/factories"] + defp elixirc_paths(:dev), do: ["lib", "test/factories"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:arc, "~> 0.11"}, + {:arc_ecto, "~> 0.11"}, + {:corsica, "~> 1.1"}, + {:csv, "~> 2.3"}, + {:ecto_enum, "~> 1.0"}, + {:ex_aws, "~> 2.1", override: true}, + {:ex_aws_lambda, "~> 2.0"}, + {:ex_aws_s3, "~> 2.0"}, + {:ex_aws_secretsmanager, "~> 2.0"}, + {:ex_aws_sts, "~> 2.1"}, + {:ex_json_schema, "~> 0.7.4"}, + {:ex_machina, "~> 2.3"}, + {:guardian, "~> 2.0"}, + {:guardian_db, "~> 2.0"}, + {:hackney, "~> 1.6"}, + {:httpoison, "~> 1.6"}, + {:jason, "~> 1.2"}, + {:openid_connect, "~> 0.2"}, + {:phoenix, "~> 1.5"}, + {:phoenix_view, "~> 2.0"}, + {:phoenix_ecto, "~> 4.0"}, + {:phoenix_swagger, "~> 0.8"}, + {:plug_cowboy, "~> 2.0"}, + {:postgrex, ">= 0.0.0"}, + {:quantum, "~> 3.0"}, + {:que, "~> 0.10"}, + {:recase, "~> 0.7", override: true}, + {:sentry, "~> 8.0"}, + {:sweet_xml, "~> 0.6"}, + {:timex, "~> 3.7"}, + + # notifiations system dependencies + {:phoenix_html, "~> 3.0"}, + {:bamboo, "~> 2.3.0"}, + {:bamboo_ses, "~> 0.3.0"}, + {:bamboo_phoenix, "~> 1.0.0"}, + {:oban, "~> 2.13"}, + + # development dependencies + {:configparser_ex, "~> 4.0", only: [:dev, :test]}, + {:credo, "~> 1.0", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, + {:distillery, "~> 2.1", runtime: false}, + {:faker, "~> 0.10", only: [:dev, :test]}, + {:git_hooks, "~> 0.4", only: [:dev, :test]}, + + # RC to fix https://github.com/rrrene/inch_ex/pull/68 + {:inch_ex, "~> 2.1-rc", only: [:dev, :test]}, + + # unit testing dependencies + {:bypass, "~> 2.1", only: :test}, + {:excoveralls, "~> 0.8", only: :test}, + {:exvcr, "~> 0.10", only: :test}, + {:mock, "~> 0.3.0", only: :test}, + + # The following are indirect dependencies, but we need to override the + # versions due to conflicts + {:jsx, "~> 3.1", override: true}, + {:xml_builder, "~> 2.1", override: true} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to create, migrate and run the seeds file at once: + # + # $ mix ecto.setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate", "test"], + "phx.server": ["cadet.server"], + "phx.digest": ["cadet.digest"], + sentry_recompile: ["deps.compile sentry --force", "compile"] + ] + end +end diff --git a/mix.lock b/mix.lock index 1eb412fe1..45a64308d 100644 --- a/mix.lock +++ b/mix.lock @@ -1,90 +1,90 @@ -%{ - "arc": {:hex, :arc, "0.11.0", "ac7a0cc03035317b6fef9fe94c97d7d9bd183a3e7ce1606aa0c175cfa8d1ba6d", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:ex_aws_s3, "~> 2.0", [hex: :ex_aws_s3, repo: "hexpm", optional: true]}, {:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.1", [hex: :poison, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "e91a8bd676fca716f6e46275ae81fb96c0bbc7a9d5b96cac511ae190588eddd0"}, - "arc_ecto": {:hex, :arc_ecto, "0.11.3", "52f278330fe3a29472ce5d9682514ca09eaed4b33453cbaedb5241a491464f7d", [:mix], [{:arc, "~> 0.11.0", [hex: :arc, repo: "hexpm", optional: false]}, {:ecto, ">= 2.1.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "24beed35003707434a778caece7d71e46e911d46d1e82e7787345264fc8e96d0"}, - "artificery": {:hex, :artificery, "0.4.3", "0bc4260f988dcb9dda4b23f9fc3c6c8b99a6220a331534fdf5bf2fd0d4333b02", [:mix], [], "hexpm", "12e95333a30e20884e937abdbefa3e7f5e05609c2ba8cf37b33f000b9ffc0504"}, - "bamboo": {:hex, :bamboo, "2.3.0", "d2392a2cabe91edf488553d3c70638b532e8db7b76b84b0a39e3dfe492ffd6fc", [:mix], [{:hackney, ">= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.4 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dd0037e68e108fd04d0e8773921512c940e35d981e097b5793543e3b2f9cd3f6"}, - "bamboo_phoenix": {:hex, :bamboo_phoenix, "1.0.0", "f3cc591ffb163ed0bf935d256f1f4645cd870cf436545601215745fb9cc9953f", [:mix], [{:bamboo, ">= 2.0.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.3.0", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "6db88fbb26019c84a47994bb2bd879c0887c29ce6c559bc6385fd54eb8b37dee"}, - "bamboo_ses": {:hex, :bamboo_ses, "0.3.1", "3c172fc5bf2bbb1f9eec632750496ae1e6468cec4c2f0ac2a6b04351a674e2f2", [:mix], [{:bamboo, "~> 2.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:ex_aws, "~> 2.4.1", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "0.2.2", [hex: :mail, repo: "hexpm", optional: false]}], "hexpm", "7e67997479115501da674627b15322a570b41042fc0031be8a5c80e734354c26"}, - "blankable": {:hex, :blankable, "1.0.0", "89ab564a63c55af117e115144e3b3b57eb53ad43ba0f15553357eb283e0ed425", [:mix], [], "hexpm", "7cf11aac0e44f4eedbee0c15c1d37d94c090cb72a8d9fddf9f7aec30f9278899"}, - "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, - "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, - "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, - "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, - "configparser_ex": {:hex, :configparser_ex, "4.0.0", "17e2b831cfa33a08c56effc610339b2986f0d82a9caa0ed18880a07658292ab6", [:mix], [], "hexpm", "02e6d1a559361a063cba7b75bc3eb2d6ad7e62730c551cc4703541fd11e65e5b"}, - "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, - "corsica": {:hex, :corsica, "1.2.0", "5774cb77fd1d66ab89ffc2f04b2249f8e386bc37790a9f4bf101330ca247c02d", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c71f870555ce7a3eded55bbe937234cc48c546e73ce75745df9f59531687a759"}, - "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, - "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, - "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, - "credo": {:hex, :credo, "1.6.4", "ddd474afb6e8c240313f3a7b0d025cc3213f0d171879429bf8535d7021d9ad78", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "c28f910b61e1ff829bffa056ef7293a8db50e87f2c57a9b5c3f57eee124536b7"}, - "crontab": {:hex, :crontab, "1.1.11", "4028ced51b813a5061f85b689d4391ef0c27550c8ab09aaf139e4295c3d93ea4", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "ecb045f9ac14a3e2990e54368f70cdb6e2f2abafc5bc329d6c31f0c74b653787"}, - "csv": {:hex, :csv, "2.4.1", "50e32749953b6bf9818dbfed81cf1190e38cdf24f95891303108087486c5925e", [:mix], [{:parallel_stream, "~> 1.0.4", [hex: :parallel_stream, repo: "hexpm", optional: false]}], "hexpm", "54508938ac67e27966b10ef49606e3ad5995d665d7fc2688efb3eab1307c9079"}, - "db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"}, - "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, - "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, - "distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm", "bbc7008b0161a6f130d8d903b5b3232351fccc9c31a991f8fcbf2a12ace22995"}, - "ecto": {:hex, :ecto, "3.8.4", "e06b8b87e62b27fea17fd2ff6041572ddd10339fd16cdf58446e402c6c90a74b", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f9244288b8d42db40515463a008cf3f4e0e564bb9c249fe87bf28a6d79fe82d4"}, - "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, - "ecto_sql": {:hex, :ecto_sql, "3.8.3", "a7d22c624202546a39d615ed7a6b784580391e65723f2d24f65941b4dd73d471", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.8.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "348cb17fb9e6daf6f251a87049eafcb57805e2892e5e6a0f5dea0985d367329b"}, - "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, - "ex_aws": {:hex, :ex_aws, "2.4.2", "d2686c34b69287cc8dd7629e70131aec05fef3cd3eae13698c9422933f7bc9ee", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0a2c07bd1541b0bef315f67e050d3cb9f947ab1a281896a8c35e3ee4976889f6"}, - "ex_aws_lambda": {:hex, :ex_aws_lambda, "2.1.0", "f28bffae6dde34ba17ef815ef50a82a1e7f3af26d36fe31dbda3a7657e0de449", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "25608630b8b45fe22b0237696662f88be724bc89f77ee42708a5871511f531a0"}, - "ex_aws_s3": {:hex, :ex_aws_s3, "2.3.3", "61412e524616ea31d3f31675d8bc4c73f277e367dee0ae8245610446f9b778aa", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "0044f0b6f9ce925666021eafd630de64c2b3404d79c85245cc7c8a9a32d7f104"}, - "ex_aws_secretsmanager": {:hex, :ex_aws_secretsmanager, "2.0.0", "deff8c12335f0160882afeb9687e55a97fddcd7d9a82fc3a6fbb270797374773", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "8b2838af536c32263ff797012b29e87bad73ef34f43cfa60ebca8e84576f6d45"}, - "ex_aws_sts": {:hex, :ex_aws_sts, "2.3.0", "ce48c4cba7f1595a7d544458d0202ca313124026dba7b1a0021bbb1baa3d66d0", [:mix], [{:ex_aws, "~> 2.2", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "f14e4c7da3454514bf253b331e9422d25825485c211896ab3b81d2a4bdbf62f5"}, - "ex_json_schema": {:hex, :ex_json_schema, "0.7.4", "09eb5b0c8184e5702bc89625a9d0c05c7a0a845d382e9f6f406a0fc1c9a8cc3f", [:mix], [], "hexpm", "45c67fa840f0d719a2b5578126dc29bcdc1f92499c0f61bcb8a3bcb5935f9684"}, - "ex_machina": {:hex, :ex_machina, "2.7.0", "b792cc3127fd0680fecdb6299235b4727a4944a09ff0fa904cc639272cd92dc7", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "419aa7a39bde11894c87a615c4ecaa52d8f107bbdd81d810465186f783245bf8"}, - "ex_utils": {:hex, :ex_utils, "0.1.7", "2c133e0bcdc49a858cf8dacf893308ebc05bc5fba501dc3d2935e65365ec0bf3", [:mix], [], "hexpm", "66d4fe75285948f2d1e69c2a5ddd651c398c813574f8d36a9eef11dc20356ef6"}, - "exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], [], "hexpm", "1222419f706e01bfa1095aec9acf6421367dcfab798a6f67c54cf784733cd6b5"}, - "excoveralls": {:hex, :excoveralls, "0.16.1", "0bd42ed05c7d2f4d180331a20113ec537be509da31fed5c8f7047ce59ee5a7c5", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dae763468e2008cf7075a64cb1249c97cb4bc71e236c5c2b5e5cdf1cfa2bf138"}, - "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm", "32e95820a97cffea67830e91514a2ad53b888850442d6d395f53a1ac60c82e07"}, - "exvcr": {:hex, :exvcr, "0.13.3", "fcd5f54ea0ebd41db7fe16701f3c67871d1b51c3c104ab88f11135a173d47134", [:mix], [{:exactor, "~> 2.2", [hex: :exactor, repo: "hexpm", optional: false]}, {:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.1", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "db61057447388b7adc4443a55047d11d09acc75eeb5548507c775a8402e02689"}, - "faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"}, - "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, - "gen_stage": {:hex, :gen_stage, "1.1.2", "b1656cd4ba431ed02c5656fe10cb5423820847113a07218da68eae5d6a260c23", [:mix], [], "hexpm", "9e39af23140f704e2b07a3e29d8f05fd21c2aaf4088ff43cb82be4b9e3148d02"}, - "gettext": {:hex, :gettext, "0.19.1", "564953fd21f29358e68b91634799d9d26989f8d039d7512622efb3c3b1c97892", [:mix], [], "hexpm", "10c656c0912b8299adba9b061c06947511e3f109ab0d18b44a866a4498e77222"}, - "git_hooks": {:hex, :git_hooks, "0.7.3", "09489e94d88dfc767662e22aff2b6208bd7cf555a19dd0e1477cca4683ce0701", [:mix], [{:blankable, "~> 1.0.0", [hex: :blankable, repo: "hexpm", optional: false]}, {:recase, "~> 0.7.0", [hex: :recase, repo: "hexpm", optional: false]}], "hexpm", "d6ddedeb4d3a8602bc3f84e087a38f6150a86d9e790628ed8bc70e6d90681659"}, - "guardian": {:hex, :guardian, "2.2.4", "3dafdc19665411c96b2796d184064d691bc08813a132da5119e39302a252b755", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "6f83d4309c16ec2469da8606bb2a9815512cc2fac1595ad34b79940a224eb110"}, - "guardian_db": {:hex, :guardian_db, "2.1.0", "ec95a9d99cdd1e550555d09a7bb4a340d8887aad0697f594590c2fd74be02426", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:guardian, "~> 1.0 or ~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "f8e7d543ac92c395f3a7fd5acbe6829faeade57d688f7562e2f0fca8f94a0d70"}, - "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, - "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "inch_ex": {:hex, :inch_ex, "2.1.0-rc.1", "7642a8902c0d2ed5d9b5754b2fc88fedf630500d630fc03db7caca2e92dedb36", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4ceee988760f9382d1c1d0b93ea5875727f6071693e89a0a3c49c456ef1be75d"}, - "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, - "jose": {:hex, :jose, "1.11.2", "f4c018ccf4fdce22c71e44d471f15f723cb3efab5d909ab2ba202b5bf35557b3", [:mix, :rebar3], [], "hexpm", "98143fbc48d55f3a18daba82d34fe48959d44538e9697c08f34200fa5f0947d2"}, - "jsx": {:hex, :jsx, "3.1.0", "d12516baa0bb23a59bb35dccaf02a1bd08243fcbb9efe24f2d9d056ccff71268", [:rebar3], [], "hexpm", "0c5cc8fdc11b53cc25cf65ac6705ad39e54ecc56d1c22e4adb8f5a53fb9427f3"}, - "mail": {:hex, :mail, "0.2.2", "b1d31beaa2a7b23d7b84b2794f037ef4dfdaba9e66d877142bedbaf0625b9c16", [:mix], [], "hexpm", "1c9d31548a60c44ded1806369e07a7dd4d05737eb47fa3238bbf2436b3da8a32"}, - "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, - "memento": {:hex, :memento, "0.3.2", "38cfc8ff9bcb1adff7cbd0f3b78a762636b86dff764729d1c82d0464c539bdd0", [:mix], [], "hexpm", "25cf691a98a0cb70262f4a7543c04bab24648cb2041d937eb64154a8d6f8012b"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, - "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, - "mock": {:hex, :mock, "0.3.7", "75b3bbf1466d7e486ea2052a73c6e062c6256fb429d6797999ab02fa32f29e03", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "4da49a4609e41fd99b7836945c26f373623ea968cfb6282742bcb94440cf7e5c"}, - "oban": {:hex, :oban, "2.14.1", "99e28a814ca9faa759cd3f88d9adc56eb5dd0b8d4a5dabb8d2e989cb57c86f52", [:mix], [{:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6c368b5face9b1e96ba42a1d39710c5193f4b38b62c8aeb651e37897aa3feecd"}, - "openid_connect": {:hex, :openid_connect, "0.2.2", "c05055363330deab39ffd89e609db6b37752f255a93802006d83b45596189c0b", [:mix], [{:httpoison, "~> 1.2", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "735769b6d592124b58edd0582554ce638524c0214cd783d8903d33357d74cc13"}, - "parallel_stream": {:hex, :parallel_stream, "1.0.6", "b967be2b23f0f6787fab7ed681b4c45a215a81481fb62b01a5b750fa8f30f76c", [:mix], [], "hexpm", "639b2e8749e11b87b9eb42f2ad325d161c170b39b288ac8d04c4f31f8f0823eb"}, - "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, - "phoenix": {:hex, :phoenix, "1.6.10", "7a9e8348c5c62e7fd2f74a1884b88d98251f87186a430048bfbdbab3e3f46736", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "08cf70d42f61dd0ea381805bac3cddef57b7b92ade5acc6f6036aa25ecaca9a2"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, - "phoenix_html": {:hex, :phoenix_html, "3.3.0", "bf451c71ebdaac8d2f40d3b703435e819ccfbb9ff243140ca3bd10c155f134cc", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "272c5c1533499f0132309936c619186480bafcc2246588f99a69ce85095556ef"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, - "phoenix_swagger": {:hex, :phoenix_swagger, "0.8.3", "298d6204802409d3b0b4fc1013873839478707cf3a62532a9e10fec0e26d0e37", [:mix], [{:ex_json_schema, "~> 0.7.1", [hex: :ex_json_schema, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "3bc0fa9f5b679b8a61b90a52b2c67dd932320e9a84a6f91a4af872a0ab367337"}, - "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, - "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, - "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, - "postgrex": {:hex, :postgrex, "0.16.3", "fac79a81a9a234b11c44235a4494d8565303fa4b9147acf57e48978a074971db", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "aeaae1d2d1322da4e5fe90d241b0a564ce03a3add09d7270fb85362166194590"}, - "quantum": {:hex, :quantum, "3.5.0", "8d2c5ba68c55991e8975aca368e3ab844ba01f4b87c4185a7403280e2c99cf34", [:mix], [{:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.14 or ~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.2", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "cab737d1d9779f43cb1d701f46dd05ea58146fd96238d91c9e0da662c1982bb6"}, - "que": {:hex, :que, "0.10.1", "788ed0ec92ed69bdf9cfb29bf41a94ca6355b8d44959bd0669cf706e557ac891", [:mix], [{:ex_utils, "~> 0.1.6", [hex: :ex_utils, repo: "hexpm", optional: false]}, {:memento, "~> 0.3.0", [hex: :memento, repo: "hexpm", optional: false]}], "hexpm", "a737b365253e75dbd24b2d51acc1d851049e87baae08cd0c94e2bc5cd65088d5"}, - "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, - "recase": {:hex, :recase, "0.7.0", "3f2f719f0886c7a3b7fe469058ec539cb7bbe0023604ae3bce920e186305e5ae", [:mix], [], "hexpm", "36f5756a9f552f4a94b54a695870e32f4e72d5fad9c25e61bc4a3151c08a4e0c"}, - "sentry": {:hex, :sentry, "8.0.6", "c8de1bf0523bc120ec37d596c55260901029ecb0994e7075b0973328779ceef7", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.3", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "051a2d0472162f3137787c7c9d6e6e4ef239de9329c8c45b1f1bf1e9379e1883"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, - "sweet_xml": {:hex, :sweet_xml, "0.7.3", "debb256781c75ff6a8c5cbf7981146312b66f044a2898f453709a53e5031b45b", [:mix], [], "hexpm", "e110c867a1b3fe74bfc7dd9893aa851f0eed5518d0d7cad76d7baafd30e4f5ba"}, - "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, - "telemetry_registry": {:hex, :telemetry_registry, "0.3.0", "6768f151ea53fc0fbca70dbff5b20a8d663ee4e0c0b2ae589590e08658e76f1e", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "492e2adbc609f3e79ece7f29fec363a97a2c484ac78a83098535d6564781e917"}, - "timex": {:hex, :timex, "3.7.8", "0e6e8bf7c0aba95f1e13204889b2446e7a5297b1c8e408f15ab58b2c8dc85f81", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8f3b8edc5faab5205d69e5255a1d64a83b190bab7f16baa78aefcb897cf81435"}, - "tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, - "xml_builder": {:hex, :xml_builder, "2.2.0", "cc5f1eeefcfcde6e90a9b77fb6c490a20bc1b856a7010ce6396f6da9719cbbab", [:mix], [], "hexpm", "9d66d52fb917565d358166a4314078d39ef04d552904de96f8e73f68f64a62c9"}, -} +%{ + "arc": {:hex, :arc, "0.11.0", "ac7a0cc03035317b6fef9fe94c97d7d9bd183a3e7ce1606aa0c175cfa8d1ba6d", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:ex_aws_s3, "~> 2.0", [hex: :ex_aws_s3, repo: "hexpm", optional: true]}, {:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.1", [hex: :poison, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "e91a8bd676fca716f6e46275ae81fb96c0bbc7a9d5b96cac511ae190588eddd0"}, + "arc_ecto": {:hex, :arc_ecto, "0.11.3", "52f278330fe3a29472ce5d9682514ca09eaed4b33453cbaedb5241a491464f7d", [:mix], [{:arc, "~> 0.11.0", [hex: :arc, repo: "hexpm", optional: false]}, {:ecto, ">= 2.1.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "24beed35003707434a778caece7d71e46e911d46d1e82e7787345264fc8e96d0"}, + "artificery": {:hex, :artificery, "0.4.3", "0bc4260f988dcb9dda4b23f9fc3c6c8b99a6220a331534fdf5bf2fd0d4333b02", [:mix], [], "hexpm", "12e95333a30e20884e937abdbefa3e7f5e05609c2ba8cf37b33f000b9ffc0504"}, + "bamboo": {:hex, :bamboo, "2.3.0", "d2392a2cabe91edf488553d3c70638b532e8db7b76b84b0a39e3dfe492ffd6fc", [:mix], [{:hackney, ">= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.4 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dd0037e68e108fd04d0e8773921512c940e35d981e097b5793543e3b2f9cd3f6"}, + "bamboo_phoenix": {:hex, :bamboo_phoenix, "1.0.0", "f3cc591ffb163ed0bf935d256f1f4645cd870cf436545601215745fb9cc9953f", [:mix], [{:bamboo, ">= 2.0.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.3.0", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "6db88fbb26019c84a47994bb2bd879c0887c29ce6c559bc6385fd54eb8b37dee"}, + "bamboo_ses": {:hex, :bamboo_ses, "0.3.1", "3c172fc5bf2bbb1f9eec632750496ae1e6468cec4c2f0ac2a6b04351a674e2f2", [:mix], [{:bamboo, "~> 2.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:ex_aws, "~> 2.4.1", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "0.2.2", [hex: :mail, repo: "hexpm", optional: false]}], "hexpm", "7e67997479115501da674627b15322a570b41042fc0031be8a5c80e734354c26"}, + "blankable": {:hex, :blankable, "1.0.0", "89ab564a63c55af117e115144e3b3b57eb53ad43ba0f15553357eb283e0ed425", [:mix], [], "hexpm", "7cf11aac0e44f4eedbee0c15c1d37d94c090cb72a8d9fddf9f7aec30f9278899"}, + "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, + "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, + "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, + "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, + "configparser_ex": {:hex, :configparser_ex, "4.0.0", "17e2b831cfa33a08c56effc610339b2986f0d82a9caa0ed18880a07658292ab6", [:mix], [], "hexpm", "02e6d1a559361a063cba7b75bc3eb2d6ad7e62730c551cc4703541fd11e65e5b"}, + "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, + "corsica": {:hex, :corsica, "1.2.0", "5774cb77fd1d66ab89ffc2f04b2249f8e386bc37790a9f4bf101330ca247c02d", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c71f870555ce7a3eded55bbe937234cc48c546e73ce75745df9f59531687a759"}, + "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, + "credo": {:hex, :credo, "1.6.4", "ddd474afb6e8c240313f3a7b0d025cc3213f0d171879429bf8535d7021d9ad78", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "c28f910b61e1ff829bffa056ef7293a8db50e87f2c57a9b5c3f57eee124536b7"}, + "crontab": {:hex, :crontab, "1.1.11", "4028ced51b813a5061f85b689d4391ef0c27550c8ab09aaf139e4295c3d93ea4", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "ecb045f9ac14a3e2990e54368f70cdb6e2f2abafc5bc329d6c31f0c74b653787"}, + "csv": {:hex, :csv, "2.4.1", "50e32749953b6bf9818dbfed81cf1190e38cdf24f95891303108087486c5925e", [:mix], [{:parallel_stream, "~> 1.0.4", [hex: :parallel_stream, repo: "hexpm", optional: false]}], "hexpm", "54508938ac67e27966b10ef49606e3ad5995d665d7fc2688efb3eab1307c9079"}, + "db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"}, + "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, + "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, + "distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm", "bbc7008b0161a6f130d8d903b5b3232351fccc9c31a991f8fcbf2a12ace22995"}, + "ecto": {:hex, :ecto, "3.8.4", "e06b8b87e62b27fea17fd2ff6041572ddd10339fd16cdf58446e402c6c90a74b", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f9244288b8d42db40515463a008cf3f4e0e564bb9c249fe87bf28a6d79fe82d4"}, + "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, + "ecto_sql": {:hex, :ecto_sql, "3.8.3", "a7d22c624202546a39d615ed7a6b784580391e65723f2d24f65941b4dd73d471", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.8.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "348cb17fb9e6daf6f251a87049eafcb57805e2892e5e6a0f5dea0985d367329b"}, + "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, + "ex_aws": {:hex, :ex_aws, "2.4.2", "d2686c34b69287cc8dd7629e70131aec05fef3cd3eae13698c9422933f7bc9ee", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0a2c07bd1541b0bef315f67e050d3cb9f947ab1a281896a8c35e3ee4976889f6"}, + "ex_aws_lambda": {:hex, :ex_aws_lambda, "2.1.0", "f28bffae6dde34ba17ef815ef50a82a1e7f3af26d36fe31dbda3a7657e0de449", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "25608630b8b45fe22b0237696662f88be724bc89f77ee42708a5871511f531a0"}, + "ex_aws_s3": {:hex, :ex_aws_s3, "2.3.3", "61412e524616ea31d3f31675d8bc4c73f277e367dee0ae8245610446f9b778aa", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "0044f0b6f9ce925666021eafd630de64c2b3404d79c85245cc7c8a9a32d7f104"}, + "ex_aws_secretsmanager": {:hex, :ex_aws_secretsmanager, "2.0.0", "deff8c12335f0160882afeb9687e55a97fddcd7d9a82fc3a6fbb270797374773", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "8b2838af536c32263ff797012b29e87bad73ef34f43cfa60ebca8e84576f6d45"}, + "ex_aws_sts": {:hex, :ex_aws_sts, "2.3.0", "ce48c4cba7f1595a7d544458d0202ca313124026dba7b1a0021bbb1baa3d66d0", [:mix], [{:ex_aws, "~> 2.2", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "f14e4c7da3454514bf253b331e9422d25825485c211896ab3b81d2a4bdbf62f5"}, + "ex_json_schema": {:hex, :ex_json_schema, "0.7.4", "09eb5b0c8184e5702bc89625a9d0c05c7a0a845d382e9f6f406a0fc1c9a8cc3f", [:mix], [], "hexpm", "45c67fa840f0d719a2b5578126dc29bcdc1f92499c0f61bcb8a3bcb5935f9684"}, + "ex_machina": {:hex, :ex_machina, "2.7.0", "b792cc3127fd0680fecdb6299235b4727a4944a09ff0fa904cc639272cd92dc7", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "419aa7a39bde11894c87a615c4ecaa52d8f107bbdd81d810465186f783245bf8"}, + "ex_utils": {:hex, :ex_utils, "0.1.7", "2c133e0bcdc49a858cf8dacf893308ebc05bc5fba501dc3d2935e65365ec0bf3", [:mix], [], "hexpm", "66d4fe75285948f2d1e69c2a5ddd651c398c813574f8d36a9eef11dc20356ef6"}, + "exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], [], "hexpm", "1222419f706e01bfa1095aec9acf6421367dcfab798a6f67c54cf784733cd6b5"}, + "excoveralls": {:hex, :excoveralls, "0.16.1", "0bd42ed05c7d2f4d180331a20113ec537be509da31fed5c8f7047ce59ee5a7c5", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dae763468e2008cf7075a64cb1249c97cb4bc71e236c5c2b5e5cdf1cfa2bf138"}, + "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm", "32e95820a97cffea67830e91514a2ad53b888850442d6d395f53a1ac60c82e07"}, + "exvcr": {:hex, :exvcr, "0.13.3", "fcd5f54ea0ebd41db7fe16701f3c67871d1b51c3c104ab88f11135a173d47134", [:mix], [{:exactor, "~> 2.2", [hex: :exactor, repo: "hexpm", optional: false]}, {:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.1", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "db61057447388b7adc4443a55047d11d09acc75eeb5548507c775a8402e02689"}, + "faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"}, + "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "gen_stage": {:hex, :gen_stage, "1.1.2", "b1656cd4ba431ed02c5656fe10cb5423820847113a07218da68eae5d6a260c23", [:mix], [], "hexpm", "9e39af23140f704e2b07a3e29d8f05fd21c2aaf4088ff43cb82be4b9e3148d02"}, + "gettext": {:hex, :gettext, "0.19.1", "564953fd21f29358e68b91634799d9d26989f8d039d7512622efb3c3b1c97892", [:mix], [], "hexpm", "10c656c0912b8299adba9b061c06947511e3f109ab0d18b44a866a4498e77222"}, + "git_hooks": {:hex, :git_hooks, "0.7.3", "09489e94d88dfc767662e22aff2b6208bd7cf555a19dd0e1477cca4683ce0701", [:mix], [{:blankable, "~> 1.0.0", [hex: :blankable, repo: "hexpm", optional: false]}, {:recase, "~> 0.7.0", [hex: :recase, repo: "hexpm", optional: false]}], "hexpm", "d6ddedeb4d3a8602bc3f84e087a38f6150a86d9e790628ed8bc70e6d90681659"}, + "guardian": {:hex, :guardian, "2.2.4", "3dafdc19665411c96b2796d184064d691bc08813a132da5119e39302a252b755", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "6f83d4309c16ec2469da8606bb2a9815512cc2fac1595ad34b79940a224eb110"}, + "guardian_db": {:hex, :guardian_db, "2.1.0", "ec95a9d99cdd1e550555d09a7bb4a340d8887aad0697f594590c2fd74be02426", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:guardian, "~> 1.0 or ~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "f8e7d543ac92c395f3a7fd5acbe6829faeade57d688f7562e2f0fca8f94a0d70"}, + "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, + "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "inch_ex": {:hex, :inch_ex, "2.1.0-rc.1", "7642a8902c0d2ed5d9b5754b2fc88fedf630500d630fc03db7caca2e92dedb36", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4ceee988760f9382d1c1d0b93ea5875727f6071693e89a0a3c49c456ef1be75d"}, + "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, + "jose": {:hex, :jose, "1.11.2", "f4c018ccf4fdce22c71e44d471f15f723cb3efab5d909ab2ba202b5bf35557b3", [:mix, :rebar3], [], "hexpm", "98143fbc48d55f3a18daba82d34fe48959d44538e9697c08f34200fa5f0947d2"}, + "jsx": {:hex, :jsx, "3.1.0", "d12516baa0bb23a59bb35dccaf02a1bd08243fcbb9efe24f2d9d056ccff71268", [:rebar3], [], "hexpm", "0c5cc8fdc11b53cc25cf65ac6705ad39e54ecc56d1c22e4adb8f5a53fb9427f3"}, + "mail": {:hex, :mail, "0.2.2", "b1d31beaa2a7b23d7b84b2794f037ef4dfdaba9e66d877142bedbaf0625b9c16", [:mix], [], "hexpm", "1c9d31548a60c44ded1806369e07a7dd4d05737eb47fa3238bbf2436b3da8a32"}, + "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, + "memento": {:hex, :memento, "0.3.2", "38cfc8ff9bcb1adff7cbd0f3b78a762636b86dff764729d1c82d0464c539bdd0", [:mix], [], "hexpm", "25cf691a98a0cb70262f4a7543c04bab24648cb2041d937eb64154a8d6f8012b"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "mock": {:hex, :mock, "0.3.7", "75b3bbf1466d7e486ea2052a73c6e062c6256fb429d6797999ab02fa32f29e03", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "4da49a4609e41fd99b7836945c26f373623ea968cfb6282742bcb94440cf7e5c"}, + "oban": {:hex, :oban, "2.14.1", "99e28a814ca9faa759cd3f88d9adc56eb5dd0b8d4a5dabb8d2e989cb57c86f52", [:mix], [{:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6c368b5face9b1e96ba42a1d39710c5193f4b38b62c8aeb651e37897aa3feecd"}, + "openid_connect": {:hex, :openid_connect, "0.2.2", "c05055363330deab39ffd89e609db6b37752f255a93802006d83b45596189c0b", [:mix], [{:httpoison, "~> 1.2", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "735769b6d592124b58edd0582554ce638524c0214cd783d8903d33357d74cc13"}, + "parallel_stream": {:hex, :parallel_stream, "1.0.6", "b967be2b23f0f6787fab7ed681b4c45a215a81481fb62b01a5b750fa8f30f76c", [:mix], [], "hexpm", "639b2e8749e11b87b9eb42f2ad325d161c170b39b288ac8d04c4f31f8f0823eb"}, + "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, + "phoenix": {:hex, :phoenix, "1.6.10", "7a9e8348c5c62e7fd2f74a1884b88d98251f87186a430048bfbdbab3e3f46736", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "08cf70d42f61dd0ea381805bac3cddef57b7b92ade5acc6f6036aa25ecaca9a2"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, + "phoenix_html": {:hex, :phoenix_html, "3.3.0", "bf451c71ebdaac8d2f40d3b703435e819ccfbb9ff243140ca3bd10c155f134cc", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "272c5c1533499f0132309936c619186480bafcc2246588f99a69ce85095556ef"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, + "phoenix_swagger": {:hex, :phoenix_swagger, "0.8.3", "298d6204802409d3b0b4fc1013873839478707cf3a62532a9e10fec0e26d0e37", [:mix], [{:ex_json_schema, "~> 0.7.1", [hex: :ex_json_schema, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "3bc0fa9f5b679b8a61b90a52b2c67dd932320e9a84a6f91a4af872a0ab367337"}, + "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, + "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, + "postgrex": {:hex, :postgrex, "0.16.3", "fac79a81a9a234b11c44235a4494d8565303fa4b9147acf57e48978a074971db", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "aeaae1d2d1322da4e5fe90d241b0a564ce03a3add09d7270fb85362166194590"}, + "quantum": {:hex, :quantum, "3.5.0", "8d2c5ba68c55991e8975aca368e3ab844ba01f4b87c4185a7403280e2c99cf34", [:mix], [{:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.14 or ~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.2", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "cab737d1d9779f43cb1d701f46dd05ea58146fd96238d91c9e0da662c1982bb6"}, + "que": {:hex, :que, "0.10.1", "788ed0ec92ed69bdf9cfb29bf41a94ca6355b8d44959bd0669cf706e557ac891", [:mix], [{:ex_utils, "~> 0.1.6", [hex: :ex_utils, repo: "hexpm", optional: false]}, {:memento, "~> 0.3.0", [hex: :memento, repo: "hexpm", optional: false]}], "hexpm", "a737b365253e75dbd24b2d51acc1d851049e87baae08cd0c94e2bc5cd65088d5"}, + "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "recase": {:hex, :recase, "0.7.0", "3f2f719f0886c7a3b7fe469058ec539cb7bbe0023604ae3bce920e186305e5ae", [:mix], [], "hexpm", "36f5756a9f552f4a94b54a695870e32f4e72d5fad9c25e61bc4a3151c08a4e0c"}, + "sentry": {:hex, :sentry, "8.0.6", "c8de1bf0523bc120ec37d596c55260901029ecb0994e7075b0973328779ceef7", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.3", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "051a2d0472162f3137787c7c9d6e6e4ef239de9329c8c45b1f1bf1e9379e1883"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, + "sweet_xml": {:hex, :sweet_xml, "0.7.3", "debb256781c75ff6a8c5cbf7981146312b66f044a2898f453709a53e5031b45b", [:mix], [], "hexpm", "e110c867a1b3fe74bfc7dd9893aa851f0eed5518d0d7cad76d7baafd30e4f5ba"}, + "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, + "telemetry_registry": {:hex, :telemetry_registry, "0.3.0", "6768f151ea53fc0fbca70dbff5b20a8d663ee4e0c0b2ae589590e08658e76f1e", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "492e2adbc609f3e79ece7f29fec363a97a2c484ac78a83098535d6564781e917"}, + "timex": {:hex, :timex, "3.7.8", "0e6e8bf7c0aba95f1e13204889b2446e7a5297b1c8e408f15ab58b2c8dc85f81", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8f3b8edc5faab5205d69e5255a1d64a83b190bab7f16baa78aefcb897cf81435"}, + "tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "xml_builder": {:hex, :xml_builder, "2.2.0", "cc5f1eeefcfcde6e90a9b77fb6c490a20bc1b856a7010ce6396f6da9719cbbab", [:mix], [], "hexpm", "9d66d52fb917565d358166a4314078d39ef04d552904de96f8e73f68f64a62c9"}, +} diff --git a/priv/repo/migrations/20190510152804_drop_announcements_table.exs b/priv/repo/migrations/20190510152804_drop_announcements_table.exs index 9b8ae9eb9..8a7c2b08d 100644 --- a/priv/repo/migrations/20190510152804_drop_announcements_table.exs +++ b/priv/repo/migrations/20190510152804_drop_announcements_table.exs @@ -1,7 +1,7 @@ -defmodule Cadet.Repo.Migrations.DropAnnouncementsTable do - use Ecto.Migration - - def change do - drop_if_exists(table(:announcements)) - end -end +defmodule Cadet.Repo.Migrations.DropAnnouncementsTable do + use Ecto.Migration + + def change do + drop_if_exists(table(:announcements)) + end +end diff --git a/test/cadet_web/controllers/assessments_controller_test.exs b/test/cadet_web/controllers/assessments_controller_test.exs index 7f9da3827..709fa960e 100644 --- a/test/cadet_web/controllers/assessments_controller_test.exs +++ b/test/cadet_web/controllers/assessments_controller_test.exs @@ -1,1573 +1,1573 @@ -defmodule CadetWeb.AssessmentsControllerTest do - use CadetWeb.ConnCase - use Timex - - import Ecto.Query - import Mock - - alias Cadet.{Assessments, Repo} - alias Cadet.Accounts.{Role, CourseRegistration} - alias Cadet.Assessments.{Assessment, Submission, SubmissionStatus} - alias Cadet.Autograder.GradingJob - alias CadetWeb.AssessmentsController - - @local_name "test/fixtures/local_repo" - - setup do - File.rm_rf!(@local_name) - - on_exit(fn -> - File.rm_rf!(@local_name) - end) - - Cadet.Test.Seeds.assessments() - end - - test "swagger" do - AssessmentsController.swagger_definitions() - AssessmentsController.swagger_path_index(nil) - AssessmentsController.swagger_path_show(nil) - AssessmentsController.swagger_path_unlock(nil) - AssessmentsController.swagger_path_submit(nil) - end - - describe "GET /, unauthenticated" do - test "unauthorized", %{conn: conn, courses: %{course1: course1}} do - conn = get(conn, build_url(course1.id)) - assert response(conn, 401) =~ "Unauthorised" - end - end - - describe "GET /:assessment_id, unauthenticated" do - test "unauthorized", %{conn: conn, courses: %{course1: course1}} do - conn = get(conn, build_url(course1.id, 1)) - assert response(conn, 401) =~ "Unauthorised" - end - end - - # All roles should see almost the same overview - describe "GET /, all roles" do - test "renders assessments overview", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for {_role, course_reg} <- role_crs do - expected = - assessments - |> Map.values() - |> Enum.map(& &1.assessment) - |> Enum.sort(&open_at_asc_comparator/2) - |> Enum.map( - &%{ - "courseId" => &1.course_id, - "id" => &1.id, - "title" => &1.title, - "shortSummary" => &1.summary_short, - "story" => &1.story, - "number" => &1.number, - "reading" => &1.reading, - "openAt" => format_datetime(&1.open_at), - "closeAt" => format_datetime(&1.close_at), - "type" => &1.config.type, - "isManuallyGraded" => &1.config.is_manually_graded, - "coverImage" => &1.cover_picture, - "maxXp" => 4800, - "status" => get_assessment_status(course_reg, &1), - "private" => false, - "isPublished" => &1.is_published, - "gradedCount" => 0, - "questionCount" => 9 - } - ) - - resp = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id)) - |> json_response(200) - |> Enum.map(&Map.delete(&1, "xp")) - - assert expected == resp - end - end - - test "render password protected assessments properly", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessment_configs: configs, - assessments: assessments - } do - for {_role, course_reg} <- role_crs do - mission = assessments[hd(configs).type] - - {:ok, _} = - mission.assessment - |> Assessment.changeset(%{password: "mysupersecretpassword"}) - |> Repo.update() - - resp = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id)) - |> json_response(200) - |> Enum.find(&(&1["type"] == hd(configs).type)) - |> Map.get("private") - - assert resp == true - end - end - end - - describe "GET /, student only" do - test "does not render unpublished assessments", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: %{student: student}, - assessment_configs: configs, - assessments: assessments - } do - mission = assessments[hd(configs).type] - - {:ok, _} = - mission.assessment - |> Assessment.changeset(%{is_published: false}) - |> Repo.update() - - expected = - assessments - |> Map.delete(hd(configs).type) - |> Map.values() - |> Enum.map(fn a -> a.assessment end) - |> Enum.sort(&open_at_asc_comparator/2) - |> Enum.map( - &%{ - "courseId" => &1.course_id, - "id" => &1.id, - "title" => &1.title, - "shortSummary" => &1.summary_short, - "story" => &1.story, - "number" => &1.number, - "reading" => &1.reading, - "openAt" => format_datetime(&1.open_at), - "closeAt" => format_datetime(&1.close_at), - "type" => &1.config.type, - "isManuallyGraded" => &1.config.is_manually_graded, - "coverImage" => &1.cover_picture, - "maxXp" => 4800, - "status" => get_assessment_status(student, &1), - "private" => false, - "isPublished" => &1.is_published, - "gradedCount" => 0, - "questionCount" => 9 - } - ) - - resp = - conn - |> sign_in(student.user) - |> get(build_url(course1.id)) - |> json_response(200) - |> Enum.map(&Map.delete(&1, "xp")) - - assert expected == resp - end - - test "renders student submission status in overview", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: %{student: student}, - assessment_configs: configs, - assessments: assessments - } do - assessment = assessments[hd(configs).type].assessment - [submission | _] = assessments[hd(configs).type].submissions - - for status <- SubmissionStatus.__enum_map__() do - submission - |> Submission.changeset(%{status: status}) - |> Repo.update() - - resp = - conn - |> sign_in(student.user) - |> get(build_url(course1.id)) - |> json_response(200) - |> Enum.find(&(&1["id"] == assessment.id)) - |> Map.get("status") - - assert get_assessment_status(student, assessment) == resp - end - end - - test "renders xp for students", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: %{student: student}, - assessment_configs: configs, - assessments: assessments - } do - assessment = assessments[hd(configs).type].assessment - - resp = - conn - |> sign_in(student.user) - |> get(build_url(course1.id)) - |> json_response(200) - |> Enum.find(&(&1["id"] == assessment.id)) - |> Map.get("xp") - - assert resp == 800 * 3 + 500 * 3 + 100 * 3 - end - end - - describe "GET /, non-students" do - test "renders unpublished assessments", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessment_configs: configs, - assessments: assessments - } do - for role <- ~w(staff admin)a do - course_reg = Map.get(role_crs, role) - mission = assessments[hd(configs).type] - - {:ok, _} = - mission.assessment - |> Assessment.changeset(%{is_published: false}) - |> Repo.update() - - resp = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id)) - |> json_response(200) - |> Enum.map(&Map.delete(&1, "xp")) - - expected = - assessments - |> Map.values() - |> Enum.map(fn a -> a.assessment end) - |> Enum.sort(&open_at_asc_comparator/2) - |> Enum.map( - &%{ - "id" => &1.id, - "courseId" => &1.course_id, - "title" => &1.title, - "shortSummary" => &1.summary_short, - "story" => &1.story, - "number" => &1.number, - "reading" => &1.reading, - "openAt" => format_datetime(&1.open_at), - "closeAt" => format_datetime(&1.close_at), - "type" => &1.config.type, - "isManuallyGraded" => &1.config.is_manually_graded, - "coverImage" => &1.cover_picture, - "maxXp" => 4800, - "status" => get_assessment_status(course_reg, &1), - "private" => false, - "gradedCount" => 0, - "questionCount" => 9, - "isPublished" => - if &1.config.type == hd(configs).type do - false - else - &1.is_published - end - } - ) - - assert expected == resp - end - end - end - - describe "GET /assessment_id, all roles" do - test "it renders assessment details", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for role <- Role.__enum_map__() do - course_reg = Map.get(role_crs, role) - - for {type, %{assessment: assessment}} <- assessments do - expected_assessments = %{ - "courseId" => assessment.course_id, - "id" => assessment.id, - "title" => assessment.title, - "type" => type, - "story" => assessment.story, - "number" => assessment.number, - "reading" => assessment.reading, - "longSummary" => assessment.summary_long, - "missionPDF" => Cadet.Assessments.Upload.url({assessment.mission_pdf, assessment}) - } - - resp_assessments = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.delete("questions") - - assert expected_assessments == resp_assessments - end - end - end - - test "it renders assessment questions", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for role <- Role.__enum_map__() do - course_reg = Map.get(role_crs, role) - - for {_type, - %{ - assessment: assessment, - mcq_questions: mcq_questions, - programming_questions: programming_questions, - voting_questions: voting_questions - }} <- assessments do - # Programming questions should come first due to seeding order - expected_programming_questions = - Enum.map( - programming_questions, - &%{ - "id" => &1.id, - "type" => "#{&1.type}", - "blocking" => &1.blocking, - "content" => &1.question.content, - "solutionTemplate" => &1.question.template, - "prepend" => &1.question.prepend, - "postpend" => &1.question.postpend, - "testcases" => - Enum.map( - &1.question.public, - fn testcase -> - for {k, v} <- testcase, - into: %{"type" => "public"}, - do: {Atom.to_string(k), v} - end - ) ++ - Enum.map( - &1.question.opaque, - fn testcase -> - for {k, v} <- testcase, - into: %{"type" => "opaque"}, - do: {Atom.to_string(k), v} - end - ) - } - ) - - expected_mcq_questions = - Enum.map( - mcq_questions, - &%{ - "id" => &1.id, - "type" => "#{&1.type}", - "blocking" => &1.blocking, - "content" => &1.question.content, - "choices" => - Enum.map( - &1.question.choices, - fn choice -> - %{ - "id" => choice.choice_id, - "content" => choice.content, - "hint" => choice.hint - } - end - ) - } - ) - - expected_voting_questions = - Enum.map( - voting_questions, - &%{ - "id" => &1.id, - "type" => "#{&1.type}", - "blocking" => &1.blocking, - "content" => &1.question.content, - "solutionTemplate" => &1.question.template, - "prepend" => &1.question.prepend - } - ) - - contests_submissions = - Enum.map(0..2, fn _ -> Enum.map(0..2, fn _ -> insert(:submission) end) end) - - contests_answers = - Enum.map(contests_submissions, fn contest_submissions -> - Enum.map(contest_submissions, fn submission -> - insert(:answer, %{ - submission: submission, - answer: %{code: "return 2;"}, - question: build(:programming_question) - }) - end) - end) - - voting_questions - |> Enum.zip(contests_submissions) - |> Enum.map(fn {question, contest_submissions} -> - Enum.map(contest_submissions, fn submission -> - insert(:submission_vote, %{ - voter: course_reg, - submission: submission, - question: question - }) - end) - end) - - contests_entries = - Enum.map(contests_answers, fn contest_answers -> - Enum.map(contest_answers, fn answer -> - %{ - "submission_id" => answer.submission.id, - "answer" => %{"code" => answer.answer.code}, - "score" => nil - } - end) - end) - - expected_voting_questions = - expected_voting_questions - |> Enum.zip(contests_entries) - |> Enum.map(fn {question, contest_entries} -> - question = Map.put(question, "contestEntries", contest_entries) - Map.put(question, "contestLeaderboard", []) - end) - - expected_questions = - expected_programming_questions ++ expected_mcq_questions ++ expected_voting_questions - - resp_questions = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.map(&Map.delete(&1, "answer")) - |> Enum.map(&Map.delete(&1, "solution")) - |> Enum.map(&Map.delete(&1, "library")) - |> Enum.map(&Map.delete(&1, "xp")) - |> Enum.map(&Map.delete(&1, "maxXp")) - |> Enum.map(&Map.delete(&1, "grader")) - |> Enum.map(&Map.delete(&1, "gradedAt")) - |> Enum.map(&Map.delete(&1, "autogradingResults")) - |> Enum.map(&Map.delete(&1, "autogradingStatus")) - |> Enum.map(&Map.delete(&1, "comments")) - - assert expected_questions == resp_questions - end - end - end - - test "renders open leaderboard for all roles", %{ - conn: conn, - course_regs: course_regs, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - voting_assessment = assessments["practical"].assessment - - voting_assessment - |> Assessment.changeset(%{ - open_at: Timex.shift(Timex.now(), days: -30), - close_at: Timex.shift(Timex.now(), days: -20) - }) - |> Repo.update() - - voting_question = assessments["practical"].voting_questions |> List.first() - contest_assessment_number = voting_question.question.contest_number - - contest_assessment = Repo.get_by(Assessment, number: contest_assessment_number) - - # insert contest question - contest_question = - insert(:programming_question, %{ - display_order: 1, - assessment: contest_assessment, - max_xp: 1000 - }) - - # insert contest submissions and answers - contest_submissions = - for student <- Enum.take(course_regs.students, 5) do - insert(:submission, %{assessment: contest_assessment, student: student}) - end - - contest_answers = - for {submission, score} <- Enum.with_index(contest_submissions, 1) do - insert(:answer, %{ - xp: 1000, - question: contest_question, - submission: submission, - answer: build(:programming_answer), - relative_score: score / 1 - }) - end - - expected_leaderboard = - for answer <- contest_answers do - %{ - "answer" => %{"code" => answer.answer.code}, - "final_score" => answer.relative_score, - "student_name" => answer.submission.student.user.name, - "submission_id" => answer.submission.id - } - end - |> Enum.sort_by(& &1["final_score"], &>=/2) - - for role <- Role.__enum_map__() do - course_reg = Map.get(role_crs, role) - - resp_leaderboard = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, voting_question.assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.find(&(&1["id"] == voting_question.id)) - |> Map.get("contestLeaderboard") - - assert resp_leaderboard == expected_leaderboard - end - end - - test "renders close leaderboard for staff and admin", %{ - conn: conn, - course_regs: course_regs, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - voting_assessment = assessments["practical"].assessment - - voting_assessment - |> Assessment.changeset(%{ - close_at: Timex.shift(Timex.now(), days: 20) - }) - |> Repo.update() - - voting_question = assessments["practical"].voting_questions |> List.first() - contest_assessment_number = voting_question.question.contest_number - - contest_assessment = Repo.get_by(Assessment, number: contest_assessment_number) - - # insert contest question - contest_question = - insert(:programming_question, %{ - display_order: 1, - assessment: contest_assessment, - max_xp: 1000 - }) - - # insert contest submissions and answers - contest_submissions = - for student <- Enum.take(course_regs.students, 5) do - insert(:submission, %{assessment: contest_assessment, student: student}) - end - - contest_answers = - for {submission, score} <- Enum.with_index(contest_submissions, 1) do - insert(:answer, %{ - xp: 1000, - question: contest_question, - submission: submission, - answer: build(:programming_answer), - relative_score: score / 1 - }) - end - - expected_leaderboard = - for answer <- contest_answers do - %{ - "answer" => %{"code" => answer.answer.code}, - "final_score" => answer.relative_score, - "student_name" => answer.submission.student.user.name, - "submission_id" => answer.submission.id - } - end - |> Enum.sort_by(& &1["final_score"], &>=/2) - - for role <- [:admin, :staff] do - course_reg = Map.get(role_crs, role) - - resp_leaderboard = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, voting_question.assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.find(&(&1["id"] == voting_question.id)) - |> Map.get("contestLeaderboard") - - assert resp_leaderboard == expected_leaderboard - end - end - - test "does not render close leaderboard for students", %{ - conn: conn, - course_regs: course_regs, - courses: %{course1: course1}, - role_crs: %{student: course_reg}, - assessments: assessments - } do - voting_assessment = assessments["practical"].assessment - - voting_assessment - |> Assessment.changeset(%{ - close_at: Timex.shift(Timex.now(), days: 20) - }) - |> Repo.update() - - voting_question = assessments["practical"].voting_questions |> List.first() - contest_assessment_number = voting_question.question.contest_number - - contest_assessment = Repo.get_by(Assessment, number: contest_assessment_number) - - # insert contest question - contest_question = - insert(:programming_question, %{ - display_order: 1, - assessment: contest_assessment, - max_xp: 1000 - }) - - # insert contest submissions and answers - contest_submissions = - for student <- Enum.take(course_regs.students, 5) do - insert(:submission, %{assessment: contest_assessment, student: student}) - end - - _contest_answers = - for {submission, score} <- Enum.with_index(contest_submissions, 1) do - insert(:answer, %{ - xp: 1000, - question: contest_question, - submission: submission, - answer: build(:programming_answer), - relative_score: score / 1 - }) - end - - expected_leaderboard = [] - - resp_leaderboard = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, voting_question.assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.find(&(&1["id"] == voting_question.id)) - |> Map.get("contestLeaderboard") - - assert resp_leaderboard == expected_leaderboard - end - - test "it renders assessment question libraries", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for role <- Role.__enum_map__() do - course_reg = Map.get(role_crs, role) - - for {_type, - %{ - assessment: assessment, - mcq_questions: mcq_questions, - programming_questions: programming_questions, - voting_question: voting_questions - }} <- assessments do - # Programming questions should come first due to seeding order - - expected_libraries = - (programming_questions ++ mcq_questions ++ voting_questions) - |> Enum.map(&Map.get(&1, :library)) - |> Enum.map( - &%{ - "chapter" => &1.chapter, - "globals" => &1.globals, - "external" => %{ - "name" => "#{&1.external.name}", - "symbols" => &1.external.symbols - } - } - ) - - resp_libraries = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.map(&Map.get(&1, "library")) - - assert resp_libraries == expected_libraries - end - end - end - - test "it renders solutions for ungraded assessments (path)", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for role <- Role.__enum_map__() do - course_reg = Map.get(role_crs, role) - - %{ - assessment: assessment, - mcq_questions: mcq_questions, - programming_questions: programming_questions, - voting_questions: voting_questions - } = assessments["path"] - - # This is the case cuz the seed set "path" to build_soultion = true - - # Seeds set solution as 0 - expected_mcq_solutions = Enum.map(mcq_questions, fn _ -> %{"solution" => 0} end) - - expected_programming_solutions = - Enum.map(programming_questions, &%{"solution" => &1.question.solution}) - - # No solution in a voting question - expected_voting_solutions = Enum.map(voting_questions, fn _ -> %{"solution" => nil} end) - - expected_solutions = - Enum.sort( - expected_mcq_solutions ++ expected_programming_solutions ++ expected_voting_solutions - ) - - resp_solutions = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.map(&Map.take(&1, ["solution"])) - |> Enum.sort() - - assert expected_solutions == resp_solutions - end - end - - test "it renders xp, grade for students", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for role <- Role.__enum_map__() do - course_reg = Map.get(role_crs, role) - - for {_type, - %{ - assessment: assessment, - mcq_answers: [mcq_answers | _], - programming_answers: [programming_answers | _], - voting_answers: [voting_answers | _] - }} <- assessments do - expected = - if role == :student do - Enum.map( - programming_answers ++ mcq_answers ++ voting_answers, - &%{ - "xp" => &1.xp + &1.xp_adjustment - } - ) - else - fn -> %{"xp" => 0} end - |> Stream.repeatedly() - |> Enum.take( - length(programming_answers) + length(mcq_answers) + length(voting_answers) - ) - end - - resp = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.map(&Map.take(&1, ~w(xp))) - - assert expected == resp - end - end - end - - test "it does not render solutions for ungraded assessments (path)", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for role <- Role.__enum_map__() do - course_reg = Map.get(role_crs, role) - - for {_type, - %{ - assessment: assessment - }} <- Map.delete(assessments, "path") do - resp_solutions = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.map(&Map.get(&1, ["solution"])) - - assert Enum.uniq(resp_solutions) == [nil] - end - end - end - end - - describe "GET /assessment_id, student" do - test "it renders previously submitted answers", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: %{student: student}, - assessments: assessments - } do - for {_type, - %{ - assessment: assessment, - mcq_answers: [mcq_answers | _], - programming_answers: [programming_answers | _], - voting_answers: [voting_answers | _] - }} <- assessments do - # Programming questions should come first due to seeding order - expected_programming_answers = - Enum.map(programming_answers, &%{"answer" => &1.answer.code}) - - expected_mcq_answers = Enum.map(mcq_answers, &%{"answer" => &1.answer.choice_id}) - - # Answers are not rendered for voting questions - expected_voting_answers = Enum.map(voting_answers, fn _ -> %{"answer" => nil} end) - - expected_answers = - expected_programming_answers ++ expected_mcq_answers ++ expected_voting_answers - - resp_answers = - conn - |> sign_in(student.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.map(&Map.take(&1, ["answer"])) - - assert expected_answers == resp_answers - end - end - - test "it does not permit access to not yet open assessments", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: %{student: student}, - assessments: %{"mission" => mission} - } do - mission.assessment - |> Assessment.changeset(%{ - open_at: Timex.shift(Timex.now(), days: 5), - close_at: Timex.shift(Timex.now(), days: 10) - }) - |> Repo.update!() - - conn = - conn - |> sign_in(student.user) - |> get(build_url(course1.id, mission.assessment.id)) - - assert response(conn, 401) == "Assessment not open" - end - - test "it does not permit access to unpublished assessments", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: %{student: student}, - assessments: %{"mission" => mission} - } do - {:ok, _} = - mission.assessment - |> Assessment.changeset(%{is_published: false}) - |> Repo.update() - - conn = - conn - |> sign_in(student.user) - |> get(build_url(course1.id, mission.assessment.id)) - - assert response(conn, 400) == "Assessment not found" - end - end - - describe "GET /assessment_id, non-students" do - test "it renders empty answers", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for role <- ~w(staff admin)a do - course_reg = Map.get(role_crs, role) - - for {_type, %{assessment: assessment}} <- assessments do - resp_answers = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.map(&Map.get(&1, ["answer"])) - - assert Enum.uniq(resp_answers) == [nil] - end - end - end - - test "it permits access to not yet open assessments", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: %{"mission" => mission} - } do - for role <- ~w(staff admin)a do - course_reg = Map.get(role_crs, role) - - mission.assessment - |> Assessment.changeset(%{ - open_at: Timex.shift(Timex.now(), days: 5), - close_at: Timex.shift(Timex.now(), days: 10) - }) - |> Repo.update!() - - resp = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, mission.assessment.id)) - |> json_response(200) - - assert resp["id"] == mission.assessment.id - end - end - - test "it permits access to unpublished assessments", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: %{"mission" => mission} - } do - for role <- ~w(staff admin)a do - course_reg = Map.get(role_crs, role) - - {:ok, _} = - mission.assessment - |> Assessment.changeset(%{is_published: false}) - |> Repo.update() - - resp = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, mission.assessment.id)) - |> json_response(200) - - assert resp["id"] == mission.assessment.id - end - end - end - - describe "GET /assessment_id/submit unauthenticated" do - test "is not permitted", %{ - conn: conn, - courses: %{course1: course1}, - assessments: %{"mission" => %{assessment: assessment}} - } do - conn = post(conn, build_url_submit(course1.id, assessment.id)) - assert response(conn, 401) == "Unauthorised" - end - end - - describe "GET /assessment_id/submit students" do - for role <- ~w(student staff admin)a do - @tag role: role - test "is successful for attempted assessments for #{role}", %{ - conn: conn, - courses: %{course1: course1}, - assessments: %{"mission" => %{assessment: assessment}}, - role_crs: role_crs, - role: role - } do - with_mock GradingJob, - force_grade_individual_submission: fn _ -> nil end do - group = - if(role == :student, - do: insert(:group, %{course: course1, leader: role_crs.staff}), - else: nil - ) - - course_reg = insert(:course_registration, %{role: role, group: group, course: course1}) - - submission = - insert(:submission, %{student: course_reg, assessment: assessment, status: :attempted}) - - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - - assert response(conn, 200) == "OK" - - # Preloading is necessary because Mock does an exact match, including metadata - submission_db = Submission |> Repo.get(submission.id) |> Repo.preload(:assessment) - - assert submission_db.status == :submitted - - assert_called(GradingJob.force_grade_individual_submission(submission_db)) - end - end - end - - test "submission of answer within early hours(seeded 48) of opening grants full XP bonus", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs - } do - with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do - assessment_config = - insert( - :assessment_config, - early_submission_xp: 100, - hours_before_early_xp_decay: 48, - course: course1 - ) - - assessment = - insert( - :assessment, - open_at: Timex.shift(Timex.now(), hours: -40), - close_at: Timex.shift(Timex.now(), days: 7), - is_published: true, - config: assessment_config, - course: course1 - ) - - question = insert(:programming_question, assessment: assessment) - - group = insert(:group, leader: role_crs.staff) - - course_reg = - insert(:course_registration, %{role: :student, group: group, course: course1}) - - submission = - insert(:submission, assessment: assessment, student: course_reg, status: :attempted) - - insert( - :answer, - submission: submission, - question: question, - answer: %{code: "f => f(f);"} - ) - - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - |> response(200) - - submission_db = Repo.get(Submission, submission.id) - - assert submission_db.status == :submitted - assert submission_db.xp_bonus == 100 - end - end - - test "submission of answer after early hours before deadline get decaying XP bonus", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs - } do - with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do - for hours_after <- 48..148 do - assessment_config = - insert( - :assessment_config, - early_submission_xp: 100, - hours_before_early_xp_decay: 48, - course: course1 - ) - - assessment = - insert( - :assessment, - open_at: Timex.shift(Timex.now(), hours: -hours_after), - close_at: Timex.shift(Timex.now(), hours: 100), - is_published: true, - config: assessment_config, - course: course1 - ) - - question = insert(:programming_question, assessment: assessment) - - group = insert(:group, leader: role_crs.staff) - - course_reg = - insert(:course_registration, %{role: :student, group: group, course: course1}) - - submission = - insert(:submission, assessment: assessment, student: course_reg, status: :attempted) - - insert( - :answer, - submission: submission, - question: question, - answer: %{code: "f => f(f);"} - ) - - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - |> response(200) - - submission_db = Repo.get(Submission, submission.id) - - proportion = - Timex.diff(assessment.close_at, Timex.now(), :hours) / (100 + hours_after - 48) - - assert submission_db.status == :submitted - assert submission_db.xp_bonus == round(proportion * 100) - end - end - end - - test "submission of answer at the last hour yield 0 XP bonus", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs - } do - with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do - for hours_after <- 48..148 do - assessment_config = - insert( - :assessment_config, - early_submission_xp: 100, - hours_before_early_xp_decay: 48, - course: course1 - ) - - assessment = - insert( - :assessment, - open_at: Timex.shift(Timex.now(), hours: -hours_after), - close_at: Timex.shift(Timex.now(), hours: 1), - is_published: true, - config: assessment_config, - course: course1 - ) - - question = insert(:programming_question, assessment: assessment) - - group = insert(:group, leader: role_crs.staff) - - course_reg = - insert(:course_registration, %{role: :student, group: group, course: course1}) - - submission = - insert(:submission, assessment: assessment, student: course_reg, status: :attempted) - - insert( - :answer, - submission: submission, - question: question, - answer: %{code: "f => f(f);"} - ) - - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - |> response(200) - - submission_db = Repo.get(Submission, submission.id) - - assert submission_db.status == :submitted - assert submission_db.xp_bonus == 0 - end - end - end - - test "give 0 bonus for configs with 0 max", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs - } do - with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do - for hours_after <- 0..148 do - assessment_config = - insert( - :assessment_config, - early_submission_xp: 0, - hours_before_early_xp_decay: 48, - course: course1 - ) - - assessment = - insert( - :assessment, - open_at: Timex.shift(Timex.now(), hours: -hours_after), - close_at: Timex.shift(Timex.now(), days: 7), - is_published: true, - config: assessment_config, - course: course1 - ) - - question = insert(:programming_question, assessment: assessment) - - group = insert(:group, leader: role_crs.staff) - - course_reg = - insert(:course_registration, %{role: :student, group: group, course: course1}) - - submission = - insert(:submission, assessment: assessment, student: course_reg, status: :attempted) - - insert( - :answer, - submission: submission, - question: question, - answer: %{code: "f => f(f);"} - ) - - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - |> response(200) - - submission_db = Repo.get(Submission, submission.id) - - assert submission_db.status == :submitted - assert submission_db.xp_bonus == 0 - end - end - end - - # This also covers unpublished and assessments that are not open yet since they cannot be - # answered. - test "is not permitted for unattempted assessments", %{ - conn: conn, - courses: %{course1: course1}, - assessments: %{"mission" => %{assessment: assessment}} - } do - course_reg = insert(:course_registration, %{role: :student, course: course1}) - - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - - assert response(conn, 404) == "Submission not found" - end - - test "is not permitted for incomplete assessments", %{ - conn: conn, - courses: %{course1: course1}, - assessments: %{"mission" => %{assessment: assessment}} - } do - course_reg = insert(:course_registration, %{role: :student, course: course1}) - insert(:submission, %{student: course_reg, assessment: assessment, status: :attempting}) - - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - - assert response(conn, 400) == "Some questions have not been attempted" - end - - test "is not permitted for already submitted assessments", %{ - conn: conn, - courses: %{course1: course1}, - assessments: %{"mission" => %{assessment: assessment}} - } do - course_reg = insert(:course_registration, %{role: :student, course: course1}) - insert(:submission, %{student: course_reg, assessment: assessment, status: :submitted}) - - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - - assert response(conn, 403) == "Assessment has already been submitted" - end - - test "is not permitted for closed assessments", %{conn: conn, courses: %{course1: course1}} do - course_reg = insert(:course_registration, %{role: :student, course: course1}) - - # Only check for after-closing because submission shouldn't exist if unpublished or - # before opening and would fall under "Submission not found" - after_close_at_assessment = - insert(:assessment, %{ - open_at: Timex.shift(Timex.now(), days: -10), - close_at: Timex.shift(Timex.now(), days: -5), - course: course1 - }) - - insert(:submission, %{ - student: course_reg, - assessment: after_close_at_assessment, - status: :attempted - }) - - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, after_close_at_assessment.id)) - - assert response(conn, 403) == "Assessment not open" - end - - test "not found if not in same course", %{ - conn: conn, - courses: %{course2: course2}, - role_crs: %{student: student}, - assessments: %{"mission" => %{assessment: assessment}} - } do - # user is in both course, but assessment belongs to a course and no submission will be found - conn = - conn - |> sign_in(student.user) - |> post(build_url_submit(course2.id, assessment.id)) - - assert response(conn, 404) == "Submission not found" - end - - test "forbidden if not in course", %{ - conn: conn, - courses: %{course2: course2}, - course_regs: %{students: students}, - assessments: %{"mission" => %{assessment: assessment}} - } do - # user is not in the course - student2 = hd(tl(students)) - - conn = - conn - |> sign_in(student2.user) - |> post(build_url_submit(course2.id, assessment.id)) - - assert response(conn, 403) == "Forbidden" - end - end - - test "graded count is updated when assessment is graded", %{ - conn: conn, - courses: %{course1: course1}, - assessment_configs: [config | _], - role_crs: %{staff: avenger} - } do - assessment = - insert( - :assessment, - open_at: Timex.shift(Timex.now(), hours: -2), - close_at: Timex.shift(Timex.now(), days: 7), - is_published: true, - config: config, - course: course1 - ) - - [question_one, question_two] = insert_list(2, :programming_question, assessment: assessment) - - course_reg = insert(:course_registration, role: :student, course: course1) - - submission = - insert(:submission, assessment: assessment, student: course_reg, status: :submitted) - - Enum.each( - [question_one, question_two], - &insert(:answer, submission: submission, question: &1, answer: %{code: "f => f(f);"}) - ) - - get_graded_count = fn -> - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id)) - |> json_response(200) - |> Enum.find(&(&1["id"] == assessment.id)) - |> Map.get("gradedCount") - end - - grade_question = fn question -> - Assessments.update_grading_info( - %{submission_id: submission.id, question_id: question.id}, - %{"xp_adjustment" => 0}, - avenger - ) - end - - assert get_graded_count.() == 0 - - grade_question.(question_one) - - assert get_graded_count.() == 1 - - grade_question.(question_two) - - assert get_graded_count.() == 2 - end - - describe "Password protected assessments render properly" do - setup %{courses: %{course1: course1}, assessment_configs: configs} do - assessment = - insert(:assessment, %{config: Enum.at(configs, 4), course: course1, is_published: true}) - - assessment - |> Assessment.changeset(%{ - password: "mysupersecretpassword", - open_at: Timex.shift(Timex.now(), days: -2), - close_at: Timex.shift(Timex.now(), days: +1) - }) - |> Repo.update!() - - {:ok, protected_assessment: assessment} - end - - test "returns 403 when trying to access a password protected assessment without a password", - %{ - conn: conn, - courses: %{course1: course1}, - protected_assessment: protected_assessment, - role_crs: role_crs - } do - for {_role, course_reg} <- role_crs do - conn = - conn |> sign_in(course_reg.user) |> get(build_url(course1.id, protected_assessment.id)) - - assert response(conn, 403) == "Missing Password." - end - end - - test "returns 403 when password is wrong/invalid", %{ - conn: conn, - courses: %{course1: course1}, - protected_assessment: protected_assessment, - role_crs: role_crs - } do - for {_role, course_reg} <- role_crs do - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_unlock(course1.id, protected_assessment.id), %{:password => "wrong"}) - - assert response(conn, 403) == "Invalid Password." - end - end - - test "allow role_crs with preexisting submission to access private assessment without a password", - %{ - conn: conn, - courses: %{course1: course1}, - protected_assessment: protected_assessment, - role_crs: %{student: student} - } do - insert(:submission, %{assessment: protected_assessment, student: student}) - conn = conn |> sign_in(student.user) |> get(build_url(course1.id, protected_assessment.id)) - assert response(conn, 200) - end - - test "ignore password when assessment is not password protected", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - assessment = assessments["mission"].assessment - - for {_role, course_reg} <- role_crs do - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_unlock(course1.id, assessment.id), %{:password => "wrong"}) - |> json_response(200) - - assert conn["id"] == assessment.id - end - end - - test "render assessment when password is correct", %{ - conn: conn, - courses: %{course1: course1}, - protected_assessment: protected_assessment, - role_crs: role_crs - } do - for {_role, course_reg} <- role_crs do - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_unlock(course1.id, protected_assessment.id), %{ - :password => "mysupersecretpassword" - }) - |> json_response(200) - - assert conn["id"] == protected_assessment.id - end - end - - test "permit global access to private assessment after closed", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: %{student: student}, - assessments: %{"mission" => mission} - } do - mission.assessment - |> Assessment.changeset(%{ - open_at: Timex.shift(Timex.now(), days: -2), - close_at: Timex.shift(Timex.now(), days: -1) - }) - |> Repo.update!() - - conn = - conn - |> sign_in(student.user) - |> get(build_url(course1.id, mission.assessment.id)) - - assert response(conn, 200) - end - end - - defp build_url(course_id), do: "/v2/courses/#{course_id}/assessments/" - - defp build_url(course_id, assessment_id), - do: "/v2/courses/#{course_id}/assessments/#{assessment_id}" - - defp build_url_submit(course_id, assessment_id), - do: "/v2/courses/#{course_id}/assessments/#{assessment_id}/submit" - - defp build_url_unlock(course_id, assessment_id), - do: "/v2/courses/#{course_id}/assessments/#{assessment_id}/unlock" - - defp open_at_asc_comparator(x, y), do: Timex.before?(x.open_at, y.open_at) - - defp get_assessment_status(course_reg = %CourseRegistration{}, assessment = %Assessment{}) do - submission = - Submission - |> where(student_id: ^course_reg.id) - |> where(assessment_id: ^assessment.id) - |> Repo.one() - - (submission && submission.status |> Atom.to_string()) || "not_attempted" - end -end +defmodule CadetWeb.AssessmentsControllerTest do + use CadetWeb.ConnCase + use Timex + + import Ecto.Query + import Mock + + alias Cadet.{Assessments, Repo} + alias Cadet.Accounts.{Role, CourseRegistration} + alias Cadet.Assessments.{Assessment, Submission, SubmissionStatus} + alias Cadet.Autograder.GradingJob + alias CadetWeb.AssessmentsController + + @local_name "test/fixtures/local_repo" + + setup do + File.rm_rf!(@local_name) + + on_exit(fn -> + File.rm_rf!(@local_name) + end) + + Cadet.Test.Seeds.assessments() + end + + test "swagger" do + AssessmentsController.swagger_definitions() + AssessmentsController.swagger_path_index(nil) + AssessmentsController.swagger_path_show(nil) + AssessmentsController.swagger_path_unlock(nil) + AssessmentsController.swagger_path_submit(nil) + end + + describe "GET /, unauthenticated" do + test "unauthorized", %{conn: conn, courses: %{course1: course1}} do + conn = get(conn, build_url(course1.id)) + assert response(conn, 401) =~ "Unauthorised" + end + end + + describe "GET /:assessment_id, unauthenticated" do + test "unauthorized", %{conn: conn, courses: %{course1: course1}} do + conn = get(conn, build_url(course1.id, 1)) + assert response(conn, 401) =~ "Unauthorised" + end + end + + # All roles should see almost the same overview + describe "GET /, all roles" do + test "renders assessments overview", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for {_role, course_reg} <- role_crs do + expected = + assessments + |> Map.values() + |> Enum.map(& &1.assessment) + |> Enum.sort(&open_at_asc_comparator/2) + |> Enum.map( + &%{ + "courseId" => &1.course_id, + "id" => &1.id, + "title" => &1.title, + "shortSummary" => &1.summary_short, + "story" => &1.story, + "number" => &1.number, + "reading" => &1.reading, + "openAt" => format_datetime(&1.open_at), + "closeAt" => format_datetime(&1.close_at), + "type" => &1.config.type, + "isManuallyGraded" => &1.config.is_manually_graded, + "coverImage" => &1.cover_picture, + "maxXp" => 4800, + "status" => get_assessment_status(course_reg, &1), + "private" => false, + "isPublished" => &1.is_published, + "gradedCount" => 0, + "questionCount" => 9 + } + ) + + resp = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id)) + |> json_response(200) + |> Enum.map(&Map.delete(&1, "xp")) + + assert expected == resp + end + end + + test "render password protected assessments properly", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessment_configs: configs, + assessments: assessments + } do + for {_role, course_reg} <- role_crs do + mission = assessments[hd(configs).type] + + {:ok, _} = + mission.assessment + |> Assessment.changeset(%{password: "mysupersecretpassword"}) + |> Repo.update() + + resp = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id)) + |> json_response(200) + |> Enum.find(&(&1["type"] == hd(configs).type)) + |> Map.get("private") + + assert resp == true + end + end + end + + describe "GET /, student only" do + test "does not render unpublished assessments", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessment_configs: configs, + assessments: assessments + } do + mission = assessments[hd(configs).type] + + {:ok, _} = + mission.assessment + |> Assessment.changeset(%{is_published: false}) + |> Repo.update() + + expected = + assessments + |> Map.delete(hd(configs).type) + |> Map.values() + |> Enum.map(fn a -> a.assessment end) + |> Enum.sort(&open_at_asc_comparator/2) + |> Enum.map( + &%{ + "courseId" => &1.course_id, + "id" => &1.id, + "title" => &1.title, + "shortSummary" => &1.summary_short, + "story" => &1.story, + "number" => &1.number, + "reading" => &1.reading, + "openAt" => format_datetime(&1.open_at), + "closeAt" => format_datetime(&1.close_at), + "type" => &1.config.type, + "isManuallyGraded" => &1.config.is_manually_graded, + "coverImage" => &1.cover_picture, + "maxXp" => 4800, + "status" => get_assessment_status(student, &1), + "private" => false, + "isPublished" => &1.is_published, + "gradedCount" => 0, + "questionCount" => 9 + } + ) + + resp = + conn + |> sign_in(student.user) + |> get(build_url(course1.id)) + |> json_response(200) + |> Enum.map(&Map.delete(&1, "xp")) + + assert expected == resp + end + + test "renders student submission status in overview", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessment_configs: configs, + assessments: assessments + } do + assessment = assessments[hd(configs).type].assessment + [submission | _] = assessments[hd(configs).type].submissions + + for status <- SubmissionStatus.__enum_map__() do + submission + |> Submission.changeset(%{status: status}) + |> Repo.update() + + resp = + conn + |> sign_in(student.user) + |> get(build_url(course1.id)) + |> json_response(200) + |> Enum.find(&(&1["id"] == assessment.id)) + |> Map.get("status") + + assert get_assessment_status(student, assessment) == resp + end + end + + test "renders xp for students", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessment_configs: configs, + assessments: assessments + } do + assessment = assessments[hd(configs).type].assessment + + resp = + conn + |> sign_in(student.user) + |> get(build_url(course1.id)) + |> json_response(200) + |> Enum.find(&(&1["id"] == assessment.id)) + |> Map.get("xp") + + assert resp == 800 * 3 + 500 * 3 + 100 * 3 + end + end + + describe "GET /, non-students" do + test "renders unpublished assessments", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessment_configs: configs, + assessments: assessments + } do + for role <- ~w(staff admin)a do + course_reg = Map.get(role_crs, role) + mission = assessments[hd(configs).type] + + {:ok, _} = + mission.assessment + |> Assessment.changeset(%{is_published: false}) + |> Repo.update() + + resp = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id)) + |> json_response(200) + |> Enum.map(&Map.delete(&1, "xp")) + + expected = + assessments + |> Map.values() + |> Enum.map(fn a -> a.assessment end) + |> Enum.sort(&open_at_asc_comparator/2) + |> Enum.map( + &%{ + "id" => &1.id, + "courseId" => &1.course_id, + "title" => &1.title, + "shortSummary" => &1.summary_short, + "story" => &1.story, + "number" => &1.number, + "reading" => &1.reading, + "openAt" => format_datetime(&1.open_at), + "closeAt" => format_datetime(&1.close_at), + "type" => &1.config.type, + "isManuallyGraded" => &1.config.is_manually_graded, + "coverImage" => &1.cover_picture, + "maxXp" => 4800, + "status" => get_assessment_status(course_reg, &1), + "private" => false, + "gradedCount" => 0, + "questionCount" => 9, + "isPublished" => + if &1.config.type == hd(configs).type do + false + else + &1.is_published + end + } + ) + + assert expected == resp + end + end + end + + describe "GET /assessment_id, all roles" do + test "it renders assessment details", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for role <- Role.__enum_map__() do + course_reg = Map.get(role_crs, role) + + for {type, %{assessment: assessment}} <- assessments do + expected_assessments = %{ + "courseId" => assessment.course_id, + "id" => assessment.id, + "title" => assessment.title, + "type" => type, + "story" => assessment.story, + "number" => assessment.number, + "reading" => assessment.reading, + "longSummary" => assessment.summary_long, + "missionPDF" => Cadet.Assessments.Upload.url({assessment.mission_pdf, assessment}) + } + + resp_assessments = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.delete("questions") + + assert expected_assessments == resp_assessments + end + end + end + + test "it renders assessment questions", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for role <- Role.__enum_map__() do + course_reg = Map.get(role_crs, role) + + for {_type, + %{ + assessment: assessment, + mcq_questions: mcq_questions, + programming_questions: programming_questions, + voting_questions: voting_questions + }} <- assessments do + # Programming questions should come first due to seeding order + expected_programming_questions = + Enum.map( + programming_questions, + &%{ + "id" => &1.id, + "type" => "#{&1.type}", + "blocking" => &1.blocking, + "content" => &1.question.content, + "solutionTemplate" => &1.question.template, + "prepend" => &1.question.prepend, + "postpend" => &1.question.postpend, + "testcases" => + Enum.map( + &1.question.public, + fn testcase -> + for {k, v} <- testcase, + into: %{"type" => "public"}, + do: {Atom.to_string(k), v} + end + ) ++ + Enum.map( + &1.question.opaque, + fn testcase -> + for {k, v} <- testcase, + into: %{"type" => "opaque"}, + do: {Atom.to_string(k), v} + end + ) + } + ) + + expected_mcq_questions = + Enum.map( + mcq_questions, + &%{ + "id" => &1.id, + "type" => "#{&1.type}", + "blocking" => &1.blocking, + "content" => &1.question.content, + "choices" => + Enum.map( + &1.question.choices, + fn choice -> + %{ + "id" => choice.choice_id, + "content" => choice.content, + "hint" => choice.hint + } + end + ) + } + ) + + expected_voting_questions = + Enum.map( + voting_questions, + &%{ + "id" => &1.id, + "type" => "#{&1.type}", + "blocking" => &1.blocking, + "content" => &1.question.content, + "solutionTemplate" => &1.question.template, + "prepend" => &1.question.prepend + } + ) + + contests_submissions = + Enum.map(0..2, fn _ -> Enum.map(0..2, fn _ -> insert(:submission) end) end) + + contests_answers = + Enum.map(contests_submissions, fn contest_submissions -> + Enum.map(contest_submissions, fn submission -> + insert(:answer, %{ + submission: submission, + answer: %{code: "return 2;"}, + question: build(:programming_question) + }) + end) + end) + + voting_questions + |> Enum.zip(contests_submissions) + |> Enum.map(fn {question, contest_submissions} -> + Enum.map(contest_submissions, fn submission -> + insert(:submission_vote, %{ + voter: course_reg, + submission: submission, + question: question + }) + end) + end) + + contests_entries = + Enum.map(contests_answers, fn contest_answers -> + Enum.map(contest_answers, fn answer -> + %{ + "submission_id" => answer.submission.id, + "answer" => %{"code" => answer.answer.code}, + "score" => nil + } + end) + end) + + expected_voting_questions = + expected_voting_questions + |> Enum.zip(contests_entries) + |> Enum.map(fn {question, contest_entries} -> + question = Map.put(question, "contestEntries", contest_entries) + Map.put(question, "contestLeaderboard", []) + end) + + expected_questions = + expected_programming_questions ++ expected_mcq_questions ++ expected_voting_questions + + resp_questions = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.map(&Map.delete(&1, "answer")) + |> Enum.map(&Map.delete(&1, "solution")) + |> Enum.map(&Map.delete(&1, "library")) + |> Enum.map(&Map.delete(&1, "xp")) + |> Enum.map(&Map.delete(&1, "maxXp")) + |> Enum.map(&Map.delete(&1, "grader")) + |> Enum.map(&Map.delete(&1, "gradedAt")) + |> Enum.map(&Map.delete(&1, "autogradingResults")) + |> Enum.map(&Map.delete(&1, "autogradingStatus")) + |> Enum.map(&Map.delete(&1, "comments")) + + assert expected_questions == resp_questions + end + end + end + + test "renders open leaderboard for all roles", %{ + conn: conn, + course_regs: course_regs, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + voting_assessment = assessments["practical"].assessment + + voting_assessment + |> Assessment.changeset(%{ + open_at: Timex.shift(Timex.now(), days: -30), + close_at: Timex.shift(Timex.now(), days: -20) + }) + |> Repo.update() + + voting_question = assessments["practical"].voting_questions |> List.first() + contest_assessment_number = voting_question.question.contest_number + + contest_assessment = Repo.get_by(Assessment, number: contest_assessment_number) + + # insert contest question + contest_question = + insert(:programming_question, %{ + display_order: 1, + assessment: contest_assessment, + max_xp: 1000 + }) + + # insert contest submissions and answers + contest_submissions = + for student <- Enum.take(course_regs.students, 5) do + insert(:submission, %{assessment: contest_assessment, student: student}) + end + + contest_answers = + for {submission, score} <- Enum.with_index(contest_submissions, 1) do + insert(:answer, %{ + xp: 1000, + question: contest_question, + submission: submission, + answer: build(:programming_answer), + relative_score: score / 1 + }) + end + + expected_leaderboard = + for answer <- contest_answers do + %{ + "answer" => %{"code" => answer.answer.code}, + "final_score" => answer.relative_score, + "student_name" => answer.submission.student.user.name, + "submission_id" => answer.submission.id + } + end + |> Enum.sort_by(& &1["final_score"], &>=/2) + + for role <- Role.__enum_map__() do + course_reg = Map.get(role_crs, role) + + resp_leaderboard = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, voting_question.assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.find(&(&1["id"] == voting_question.id)) + |> Map.get("contestLeaderboard") + + assert resp_leaderboard == expected_leaderboard + end + end + + test "renders close leaderboard for staff and admin", %{ + conn: conn, + course_regs: course_regs, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + voting_assessment = assessments["practical"].assessment + + voting_assessment + |> Assessment.changeset(%{ + close_at: Timex.shift(Timex.now(), days: 20) + }) + |> Repo.update() + + voting_question = assessments["practical"].voting_questions |> List.first() + contest_assessment_number = voting_question.question.contest_number + + contest_assessment = Repo.get_by(Assessment, number: contest_assessment_number) + + # insert contest question + contest_question = + insert(:programming_question, %{ + display_order: 1, + assessment: contest_assessment, + max_xp: 1000 + }) + + # insert contest submissions and answers + contest_submissions = + for student <- Enum.take(course_regs.students, 5) do + insert(:submission, %{assessment: contest_assessment, student: student}) + end + + contest_answers = + for {submission, score} <- Enum.with_index(contest_submissions, 1) do + insert(:answer, %{ + xp: 1000, + question: contest_question, + submission: submission, + answer: build(:programming_answer), + relative_score: score / 1 + }) + end + + expected_leaderboard = + for answer <- contest_answers do + %{ + "answer" => %{"code" => answer.answer.code}, + "final_score" => answer.relative_score, + "student_name" => answer.submission.student.user.name, + "submission_id" => answer.submission.id + } + end + |> Enum.sort_by(& &1["final_score"], &>=/2) + + for role <- [:admin, :staff] do + course_reg = Map.get(role_crs, role) + + resp_leaderboard = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, voting_question.assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.find(&(&1["id"] == voting_question.id)) + |> Map.get("contestLeaderboard") + + assert resp_leaderboard == expected_leaderboard + end + end + + test "does not render close leaderboard for students", %{ + conn: conn, + course_regs: course_regs, + courses: %{course1: course1}, + role_crs: %{student: course_reg}, + assessments: assessments + } do + voting_assessment = assessments["practical"].assessment + + voting_assessment + |> Assessment.changeset(%{ + close_at: Timex.shift(Timex.now(), days: 20) + }) + |> Repo.update() + + voting_question = assessments["practical"].voting_questions |> List.first() + contest_assessment_number = voting_question.question.contest_number + + contest_assessment = Repo.get_by(Assessment, number: contest_assessment_number) + + # insert contest question + contest_question = + insert(:programming_question, %{ + display_order: 1, + assessment: contest_assessment, + max_xp: 1000 + }) + + # insert contest submissions and answers + contest_submissions = + for student <- Enum.take(course_regs.students, 5) do + insert(:submission, %{assessment: contest_assessment, student: student}) + end + + _contest_answers = + for {submission, score} <- Enum.with_index(contest_submissions, 1) do + insert(:answer, %{ + xp: 1000, + question: contest_question, + submission: submission, + answer: build(:programming_answer), + relative_score: score / 1 + }) + end + + expected_leaderboard = [] + + resp_leaderboard = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, voting_question.assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.find(&(&1["id"] == voting_question.id)) + |> Map.get("contestLeaderboard") + + assert resp_leaderboard == expected_leaderboard + end + + test "it renders assessment question libraries", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for role <- Role.__enum_map__() do + course_reg = Map.get(role_crs, role) + + for {_type, + %{ + assessment: assessment, + mcq_questions: mcq_questions, + programming_questions: programming_questions, + voting_question: voting_questions + }} <- assessments do + # Programming questions should come first due to seeding order + + expected_libraries = + (programming_questions ++ mcq_questions ++ voting_questions) + |> Enum.map(&Map.get(&1, :library)) + |> Enum.map( + &%{ + "chapter" => &1.chapter, + "globals" => &1.globals, + "external" => %{ + "name" => "#{&1.external.name}", + "symbols" => &1.external.symbols + } + } + ) + + resp_libraries = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.map(&Map.get(&1, "library")) + + assert resp_libraries == expected_libraries + end + end + end + + test "it renders solutions for ungraded assessments (path)", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for role <- Role.__enum_map__() do + course_reg = Map.get(role_crs, role) + + %{ + assessment: assessment, + mcq_questions: mcq_questions, + programming_questions: programming_questions, + voting_questions: voting_questions + } = assessments["path"] + + # This is the case cuz the seed set "path" to build_soultion = true + + # Seeds set solution as 0 + expected_mcq_solutions = Enum.map(mcq_questions, fn _ -> %{"solution" => 0} end) + + expected_programming_solutions = + Enum.map(programming_questions, &%{"solution" => &1.question.solution}) + + # No solution in a voting question + expected_voting_solutions = Enum.map(voting_questions, fn _ -> %{"solution" => nil} end) + + expected_solutions = + Enum.sort( + expected_mcq_solutions ++ expected_programming_solutions ++ expected_voting_solutions + ) + + resp_solutions = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.map(&Map.take(&1, ["solution"])) + |> Enum.sort() + + assert expected_solutions == resp_solutions + end + end + + test "it renders xp, grade for students", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for role <- Role.__enum_map__() do + course_reg = Map.get(role_crs, role) + + for {_type, + %{ + assessment: assessment, + mcq_answers: [mcq_answers | _], + programming_answers: [programming_answers | _], + voting_answers: [voting_answers | _] + }} <- assessments do + expected = + if role == :student do + Enum.map( + programming_answers ++ mcq_answers ++ voting_answers, + &%{ + "xp" => &1.xp + &1.xp_adjustment + } + ) + else + fn -> %{"xp" => 0} end + |> Stream.repeatedly() + |> Enum.take( + length(programming_answers) + length(mcq_answers) + length(voting_answers) + ) + end + + resp = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.map(&Map.take(&1, ~w(xp))) + + assert expected == resp + end + end + end + + test "it does not render solutions for ungraded assessments (path)", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for role <- Role.__enum_map__() do + course_reg = Map.get(role_crs, role) + + for {_type, + %{ + assessment: assessment + }} <- Map.delete(assessments, "path") do + resp_solutions = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.map(&Map.get(&1, ["solution"])) + + assert Enum.uniq(resp_solutions) == [nil] + end + end + end + end + + describe "GET /assessment_id, student" do + test "it renders previously submitted answers", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessments: assessments + } do + for {_type, + %{ + assessment: assessment, + mcq_answers: [mcq_answers | _], + programming_answers: [programming_answers | _], + voting_answers: [voting_answers | _] + }} <- assessments do + # Programming questions should come first due to seeding order + expected_programming_answers = + Enum.map(programming_answers, &%{"answer" => &1.answer.code}) + + expected_mcq_answers = Enum.map(mcq_answers, &%{"answer" => &1.answer.choice_id}) + + # Answers are not rendered for voting questions + expected_voting_answers = Enum.map(voting_answers, fn _ -> %{"answer" => nil} end) + + expected_answers = + expected_programming_answers ++ expected_mcq_answers ++ expected_voting_answers + + resp_answers = + conn + |> sign_in(student.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.map(&Map.take(&1, ["answer"])) + + assert expected_answers == resp_answers + end + end + + test "it does not permit access to not yet open assessments", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessments: %{"mission" => mission} + } do + mission.assessment + |> Assessment.changeset(%{ + open_at: Timex.shift(Timex.now(), days: 5), + close_at: Timex.shift(Timex.now(), days: 10) + }) + |> Repo.update!() + + conn = + conn + |> sign_in(student.user) + |> get(build_url(course1.id, mission.assessment.id)) + + assert response(conn, 401) == "Assessment not open" + end + + test "it does not permit access to unpublished assessments", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessments: %{"mission" => mission} + } do + {:ok, _} = + mission.assessment + |> Assessment.changeset(%{is_published: false}) + |> Repo.update() + + conn = + conn + |> sign_in(student.user) + |> get(build_url(course1.id, mission.assessment.id)) + + assert response(conn, 400) == "Assessment not found" + end + end + + describe "GET /assessment_id, non-students" do + test "it renders empty answers", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for role <- ~w(staff admin)a do + course_reg = Map.get(role_crs, role) + + for {_type, %{assessment: assessment}} <- assessments do + resp_answers = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.map(&Map.get(&1, ["answer"])) + + assert Enum.uniq(resp_answers) == [nil] + end + end + end + + test "it permits access to not yet open assessments", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: %{"mission" => mission} + } do + for role <- ~w(staff admin)a do + course_reg = Map.get(role_crs, role) + + mission.assessment + |> Assessment.changeset(%{ + open_at: Timex.shift(Timex.now(), days: 5), + close_at: Timex.shift(Timex.now(), days: 10) + }) + |> Repo.update!() + + resp = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, mission.assessment.id)) + |> json_response(200) + + assert resp["id"] == mission.assessment.id + end + end + + test "it permits access to unpublished assessments", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: %{"mission" => mission} + } do + for role <- ~w(staff admin)a do + course_reg = Map.get(role_crs, role) + + {:ok, _} = + mission.assessment + |> Assessment.changeset(%{is_published: false}) + |> Repo.update() + + resp = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, mission.assessment.id)) + |> json_response(200) + + assert resp["id"] == mission.assessment.id + end + end + end + + describe "GET /assessment_id/submit unauthenticated" do + test "is not permitted", %{ + conn: conn, + courses: %{course1: course1}, + assessments: %{"mission" => %{assessment: assessment}} + } do + conn = post(conn, build_url_submit(course1.id, assessment.id)) + assert response(conn, 401) == "Unauthorised" + end + end + + describe "GET /assessment_id/submit students" do + for role <- ~w(student staff admin)a do + @tag role: role + test "is successful for attempted assessments for #{role}", %{ + conn: conn, + courses: %{course1: course1}, + assessments: %{"mission" => %{assessment: assessment}}, + role_crs: role_crs, + role: role + } do + with_mock GradingJob, + force_grade_individual_submission: fn _ -> nil end do + group = + if(role == :student, + do: insert(:group, %{course: course1, leader: role_crs.staff}), + else: nil + ) + + course_reg = insert(:course_registration, %{role: role, group: group, course: course1}) + + submission = + insert(:submission, %{student: course_reg, assessment: assessment, status: :attempted}) + + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + + assert response(conn, 200) == "OK" + + # Preloading is necessary because Mock does an exact match, including metadata + submission_db = Submission |> Repo.get(submission.id) |> Repo.preload(:assessment) + + assert submission_db.status == :submitted + + assert_called(GradingJob.force_grade_individual_submission(submission_db)) + end + end + end + + test "submission of answer within early hours(seeded 48) of opening grants full XP bonus", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs + } do + with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do + assessment_config = + insert( + :assessment_config, + early_submission_xp: 100, + hours_before_early_xp_decay: 48, + course: course1 + ) + + assessment = + insert( + :assessment, + open_at: Timex.shift(Timex.now(), hours: -40), + close_at: Timex.shift(Timex.now(), days: 7), + is_published: true, + config: assessment_config, + course: course1 + ) + + question = insert(:programming_question, assessment: assessment) + + group = insert(:group, leader: role_crs.staff) + + course_reg = + insert(:course_registration, %{role: :student, group: group, course: course1}) + + submission = + insert(:submission, assessment: assessment, student: course_reg, status: :attempted) + + insert( + :answer, + submission: submission, + question: question, + answer: %{code: "f => f(f);"} + ) + + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + |> response(200) + + submission_db = Repo.get(Submission, submission.id) + + assert submission_db.status == :submitted + assert submission_db.xp_bonus == 100 + end + end + + test "submission of answer after early hours before deadline get decaying XP bonus", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs + } do + with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do + for hours_after <- 48..148 do + assessment_config = + insert( + :assessment_config, + early_submission_xp: 100, + hours_before_early_xp_decay: 48, + course: course1 + ) + + assessment = + insert( + :assessment, + open_at: Timex.shift(Timex.now(), hours: -hours_after), + close_at: Timex.shift(Timex.now(), hours: 100), + is_published: true, + config: assessment_config, + course: course1 + ) + + question = insert(:programming_question, assessment: assessment) + + group = insert(:group, leader: role_crs.staff) + + course_reg = + insert(:course_registration, %{role: :student, group: group, course: course1}) + + submission = + insert(:submission, assessment: assessment, student: course_reg, status: :attempted) + + insert( + :answer, + submission: submission, + question: question, + answer: %{code: "f => f(f);"} + ) + + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + |> response(200) + + submission_db = Repo.get(Submission, submission.id) + + proportion = + Timex.diff(assessment.close_at, Timex.now(), :hours) / (100 + hours_after - 48) + + assert submission_db.status == :submitted + assert submission_db.xp_bonus == round(proportion * 100) + end + end + end + + test "submission of answer at the last hour yield 0 XP bonus", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs + } do + with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do + for hours_after <- 48..148 do + assessment_config = + insert( + :assessment_config, + early_submission_xp: 100, + hours_before_early_xp_decay: 48, + course: course1 + ) + + assessment = + insert( + :assessment, + open_at: Timex.shift(Timex.now(), hours: -hours_after), + close_at: Timex.shift(Timex.now(), hours: 1), + is_published: true, + config: assessment_config, + course: course1 + ) + + question = insert(:programming_question, assessment: assessment) + + group = insert(:group, leader: role_crs.staff) + + course_reg = + insert(:course_registration, %{role: :student, group: group, course: course1}) + + submission = + insert(:submission, assessment: assessment, student: course_reg, status: :attempted) + + insert( + :answer, + submission: submission, + question: question, + answer: %{code: "f => f(f);"} + ) + + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + |> response(200) + + submission_db = Repo.get(Submission, submission.id) + + assert submission_db.status == :submitted + assert submission_db.xp_bonus == 0 + end + end + end + + test "give 0 bonus for configs with 0 max", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs + } do + with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do + for hours_after <- 0..148 do + assessment_config = + insert( + :assessment_config, + early_submission_xp: 0, + hours_before_early_xp_decay: 48, + course: course1 + ) + + assessment = + insert( + :assessment, + open_at: Timex.shift(Timex.now(), hours: -hours_after), + close_at: Timex.shift(Timex.now(), days: 7), + is_published: true, + config: assessment_config, + course: course1 + ) + + question = insert(:programming_question, assessment: assessment) + + group = insert(:group, leader: role_crs.staff) + + course_reg = + insert(:course_registration, %{role: :student, group: group, course: course1}) + + submission = + insert(:submission, assessment: assessment, student: course_reg, status: :attempted) + + insert( + :answer, + submission: submission, + question: question, + answer: %{code: "f => f(f);"} + ) + + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + |> response(200) + + submission_db = Repo.get(Submission, submission.id) + + assert submission_db.status == :submitted + assert submission_db.xp_bonus == 0 + end + end + end + + # This also covers unpublished and assessments that are not open yet since they cannot be + # answered. + test "is not permitted for unattempted assessments", %{ + conn: conn, + courses: %{course1: course1}, + assessments: %{"mission" => %{assessment: assessment}} + } do + course_reg = insert(:course_registration, %{role: :student, course: course1}) + + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + + assert response(conn, 404) == "Submission not found" + end + + test "is not permitted for incomplete assessments", %{ + conn: conn, + courses: %{course1: course1}, + assessments: %{"mission" => %{assessment: assessment}} + } do + course_reg = insert(:course_registration, %{role: :student, course: course1}) + insert(:submission, %{student: course_reg, assessment: assessment, status: :attempting}) + + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + + assert response(conn, 400) == "Some questions have not been attempted" + end + + test "is not permitted for already submitted assessments", %{ + conn: conn, + courses: %{course1: course1}, + assessments: %{"mission" => %{assessment: assessment}} + } do + course_reg = insert(:course_registration, %{role: :student, course: course1}) + insert(:submission, %{student: course_reg, assessment: assessment, status: :submitted}) + + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + + assert response(conn, 403) == "Assessment has already been submitted" + end + + test "is not permitted for closed assessments", %{conn: conn, courses: %{course1: course1}} do + course_reg = insert(:course_registration, %{role: :student, course: course1}) + + # Only check for after-closing because submission shouldn't exist if unpublished or + # before opening and would fall under "Submission not found" + after_close_at_assessment = + insert(:assessment, %{ + open_at: Timex.shift(Timex.now(), days: -10), + close_at: Timex.shift(Timex.now(), days: -5), + course: course1 + }) + + insert(:submission, %{ + student: course_reg, + assessment: after_close_at_assessment, + status: :attempted + }) + + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, after_close_at_assessment.id)) + + assert response(conn, 403) == "Assessment not open" + end + + test "not found if not in same course", %{ + conn: conn, + courses: %{course2: course2}, + role_crs: %{student: student}, + assessments: %{"mission" => %{assessment: assessment}} + } do + # user is in both course, but assessment belongs to a course and no submission will be found + conn = + conn + |> sign_in(student.user) + |> post(build_url_submit(course2.id, assessment.id)) + + assert response(conn, 404) == "Submission not found" + end + + test "forbidden if not in course", %{ + conn: conn, + courses: %{course2: course2}, + course_regs: %{students: students}, + assessments: %{"mission" => %{assessment: assessment}} + } do + # user is not in the course + student2 = hd(tl(students)) + + conn = + conn + |> sign_in(student2.user) + |> post(build_url_submit(course2.id, assessment.id)) + + assert response(conn, 403) == "Forbidden" + end + end + + test "graded count is updated when assessment is graded", %{ + conn: conn, + courses: %{course1: course1}, + assessment_configs: [config | _], + role_crs: %{staff: avenger} + } do + assessment = + insert( + :assessment, + open_at: Timex.shift(Timex.now(), hours: -2), + close_at: Timex.shift(Timex.now(), days: 7), + is_published: true, + config: config, + course: course1 + ) + + [question_one, question_two] = insert_list(2, :programming_question, assessment: assessment) + + course_reg = insert(:course_registration, role: :student, course: course1) + + submission = + insert(:submission, assessment: assessment, student: course_reg, status: :submitted) + + Enum.each( + [question_one, question_two], + &insert(:answer, submission: submission, question: &1, answer: %{code: "f => f(f);"}) + ) + + get_graded_count = fn -> + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id)) + |> json_response(200) + |> Enum.find(&(&1["id"] == assessment.id)) + |> Map.get("gradedCount") + end + + grade_question = fn question -> + Assessments.update_grading_info( + %{submission_id: submission.id, question_id: question.id}, + %{"xp_adjustment" => 0}, + avenger + ) + end + + assert get_graded_count.() == 0 + + grade_question.(question_one) + + assert get_graded_count.() == 1 + + grade_question.(question_two) + + assert get_graded_count.() == 2 + end + + describe "Password protected assessments render properly" do + setup %{courses: %{course1: course1}, assessment_configs: configs} do + assessment = + insert(:assessment, %{config: Enum.at(configs, 4), course: course1, is_published: true}) + + assessment + |> Assessment.changeset(%{ + password: "mysupersecretpassword", + open_at: Timex.shift(Timex.now(), days: -2), + close_at: Timex.shift(Timex.now(), days: +1) + }) + |> Repo.update!() + + {:ok, protected_assessment: assessment} + end + + test "returns 403 when trying to access a password protected assessment without a password", + %{ + conn: conn, + courses: %{course1: course1}, + protected_assessment: protected_assessment, + role_crs: role_crs + } do + for {_role, course_reg} <- role_crs do + conn = + conn |> sign_in(course_reg.user) |> get(build_url(course1.id, protected_assessment.id)) + + assert response(conn, 403) == "Missing Password." + end + end + + test "returns 403 when password is wrong/invalid", %{ + conn: conn, + courses: %{course1: course1}, + protected_assessment: protected_assessment, + role_crs: role_crs + } do + for {_role, course_reg} <- role_crs do + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_unlock(course1.id, protected_assessment.id), %{:password => "wrong"}) + + assert response(conn, 403) == "Invalid Password." + end + end + + test "allow role_crs with preexisting submission to access private assessment without a password", + %{ + conn: conn, + courses: %{course1: course1}, + protected_assessment: protected_assessment, + role_crs: %{student: student} + } do + insert(:submission, %{assessment: protected_assessment, student: student}) + conn = conn |> sign_in(student.user) |> get(build_url(course1.id, protected_assessment.id)) + assert response(conn, 200) + end + + test "ignore password when assessment is not password protected", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + assessment = assessments["mission"].assessment + + for {_role, course_reg} <- role_crs do + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_unlock(course1.id, assessment.id), %{:password => "wrong"}) + |> json_response(200) + + assert conn["id"] == assessment.id + end + end + + test "render assessment when password is correct", %{ + conn: conn, + courses: %{course1: course1}, + protected_assessment: protected_assessment, + role_crs: role_crs + } do + for {_role, course_reg} <- role_crs do + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_unlock(course1.id, protected_assessment.id), %{ + :password => "mysupersecretpassword" + }) + |> json_response(200) + + assert conn["id"] == protected_assessment.id + end + end + + test "permit global access to private assessment after closed", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessments: %{"mission" => mission} + } do + mission.assessment + |> Assessment.changeset(%{ + open_at: Timex.shift(Timex.now(), days: -2), + close_at: Timex.shift(Timex.now(), days: -1) + }) + |> Repo.update!() + + conn = + conn + |> sign_in(student.user) + |> get(build_url(course1.id, mission.assessment.id)) + + assert response(conn, 200) + end + end + + defp build_url(course_id), do: "/v2/courses/#{course_id}/assessments/" + + defp build_url(course_id, assessment_id), + do: "/v2/courses/#{course_id}/assessments/#{assessment_id}" + + defp build_url_submit(course_id, assessment_id), + do: "/v2/courses/#{course_id}/assessments/#{assessment_id}/submit" + + defp build_url_unlock(course_id, assessment_id), + do: "/v2/courses/#{course_id}/assessments/#{assessment_id}/unlock" + + defp open_at_asc_comparator(x, y), do: Timex.before?(x.open_at, y.open_at) + + defp get_assessment_status(course_reg = %CourseRegistration{}, assessment = %Assessment{}) do + submission = + Submission + |> where(student_id: ^course_reg.id) + |> where(assessment_id: ^assessment.id) + |> Repo.one() + + (submission && submission.status |> Atom.to_string()) || "not_attempted" + end +end From 995c56e6d187fd6f1758af772a3cee27ae727111 Mon Sep 17 00:00:00 2001 From: LuYiting0913 Date: Mon, 17 Jul 2023 17:43:00 +0800 Subject: [PATCH 008/128] Fix line endings --- lib/cadet/assessments/assessments.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index a16b66bb2..6412ada2a 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -621,7 +621,6 @@ defmodule Cadet.Assessments do end def update_assessment(id, params) when is_ecto_id(id) do - IO.inspect(params) simple_update( Assessment, id, From 186350f9a7b6aca120c70f192d5ecdb3671a31f8 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Thu, 20 Jul 2023 03:39:40 +0800 Subject: [PATCH 009/128] Establish connection with Frontend --- lib/cadet/accounts/team.ex | 92 +-------- lib/cadet/accounts/teams.ex | 104 +++++++++++ lib/cadet/assessments/assessment.ex | 5 +- lib/cadet/assessments/submission.ex | 35 +++- .../admin_teams_controller.ex | 175 ++++++++++++++++++ lib/cadet_web/admin_views/admin_teams_view.ex | 18 ++ lib/cadet_web/router.ex | 6 + 7 files changed, 335 insertions(+), 100 deletions(-) create mode 100644 lib/cadet/accounts/teams.ex create mode 100644 lib/cadet_web/admin_controllers/admin_teams_controller.ex create mode 100644 lib/cadet_web/admin_views/admin_teams_view.ex diff --git a/lib/cadet/accounts/team.ex b/lib/cadet/accounts/team.ex index 1c207c034..7f03a192a 100644 --- a/lib/cadet/accounts/team.ex +++ b/lib/cadet/accounts/team.ex @@ -1,6 +1,5 @@ defmodule Cadet.Accounts.Team do - use Ecto.Schema - import Ecto.Changeset + use Cadet, :model alias Cadet.Accounts.TeamMember alias Cadet.Assessments.{Assessment, Submission} @@ -22,93 +21,4 @@ defmodule Cadet.Accounts.Team do |> validate_required(@required_fields) |> foreign_key_constraint(:assessment_id) end - - def create_team(attrs) do - assessment_id = attrs["assessment_id"] - student_ids = attrs["student_ids"] - - %Team{} - |> cast(attrs, [:assessment_id]) - |> validate_required([:assessment_id]) - |> foreign_key_constraint(:assessment_id) - |> Repo.insert() - |> case do - {:ok, team} -> - create_team_members(team, student_ids) - {:ok, team} - - error -> - error - end - end - - defp create_team_members(team, student_ids) do - Enum.each(student_ids, fn student_id -> - %TeamMember{} - |> Ecto.Changeset.change(%{team_id: team.id, student_id: student_id}) - |> Repo.insert() - end) - end - - def update_team(%Team{} = team, attrs) do - assessment_id = attrs["assessment_id"] - student_ids = attrs["student_ids"] - - team - |> cast(attrs, [:assessment_id]) - |> validate_required([:assessment_id]) - |> foreign_key_constraint(:assessment_id) - |> Ecto.Changeset.change() - |> Repo.update() - |> case do - {:ok, updated_team} -> - update_team_members(updated_team, student_ids) - {:ok, updated_team} - - error -> - error - end - end - - defp update_team_members(team, student_ids) do - current_student_ids = team.team_members |> Enum.map(&(&1.student_id)) - - student_ids_to_add = List.difference(student_ids, current_student_ids) - student_ids_to_remove = List.difference(current_student_ids, student_ids) - - Enum.each(student_ids_to_add, fn student_id -> - %TeamMember{} - |> Ecto.Changeset.change(%{team_id: team.id, student_id: student_id}) - |> Repo.insert() - end) - - Enum.each(student_ids_to_remove, fn student_id -> - team.team_members - |> where([tm], tm.student_id == ^student_id) - |> Repo.delete_all() - end) - end - - def delete_team(%Team{} = team) do - team - |> Ecto.Changeset.delete_assoc(:submission) - |> Ecto.Changeset.delete_assoc(:team_members) - |> Repo.delete() - end - - def bulk_upload_teams(teams_params) do - teams = Jason.decode!(teams_params) - Enum.map(teams, fn team -> - case get_by_assessment_id(team["assessment_id"]) do - nil -> create_team(team) - existing_team -> update_team(existing_team, team) - end - end) - end - - def get_by_assessment_id(assessment_id) do - from(Team) - |> where(assessment_id: ^assessment_id) - |> Repo.one() - end end diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex new file mode 100644 index 000000000..5678a20e5 --- /dev/null +++ b/lib/cadet/accounts/teams.ex @@ -0,0 +1,104 @@ +defmodule Cadet.Accounts.Teams do + + use Cadet, [:context, :display] + + use Ecto.Schema + import Ecto.Changeset + import Ecto.Query + alias Cadet.Repo + alias Cadet.Accounts.{Team, TeamMember} + alias Cadet.Assessments.Assessment + + def create_team(attrs) do + assessment_id = attrs["assessment_id"] + student_ids = attrs["student_ids"] + + %Team{} + |> cast(attrs, [:assessment_id]) + |> validate_required([:assessment_id]) + |> foreign_key_constraint(:assessment_id) + |> Repo.transaction(fn -> + with {:ok, team} <- Repo.insert(Team.changeset(%Team{}, attrs)), + :ok <- create_team_members(team, student_ids) do + {:ok, team} + else + error -> + error + end + end) + end + + defp create_team_members(team, student_ids) do + team_member_changesets = + Enum.map(student_ids, fn student_id -> + %TeamMember{} + |> Ecto.build_assoc(:team) + |> Ecto.Changeset.change(team_id: team.id, student_id: student_id) + end) + + Repo.insert_all(team_member_changesets) + end + + def update_team(%Team{} = team, attrs) do + assessment_id = attrs["assessment_id"] + student_ids = attrs["student_ids"] + + team_id = team.id # Introduce a variable for team.id + + team + |> cast(attrs, [:assessment_id]) + |> validate_required([:assessment_id]) + |> foreign_key_constraint(:assessment_id) + |> Ecto.Changeset.change() + |> Repo.update() + |> case do + {:ok, updated_team} -> + update_team_members(updated_team, student_ids, team_id) # Pass team_id here + {:ok, updated_team} + + error -> + error + end + end + + defp update_team_members(team, student_ids, team_id) do # Add team_id parameter here + current_student_ids = team.team_members |> Enum.map(&(&1.student_id)) + + student_ids_to_add = Enum.difference(student_ids, current_student_ids) + student_ids_to_remove = Enum.difference(current_student_ids, student_ids) + + Enum.each(student_ids_to_add, fn student_id -> + %TeamMember{} + |> Ecto.Changeset.change(team_id: team_id, student_id: student_id) # Use team_id here + |> Repo.insert() + end) + + Enum.each(student_ids_to_remove, fn student_id -> + from(tm in TeamMember, where: tm.team_id == ^team_id and tm.student_id == ^student_id) # Remove ^ for student_id + |> Repo.delete_all() + end) + end + + def delete_team(%Team{} = team) do + team + |> Ecto.Changeset.delete_assoc(:submission) + |> Ecto.Changeset.delete_assoc(:team_members) + |> Repo.delete() + end + + def bulk_upload_teams(teams_params) do + teams = Jason.decode!(teams_params) + Enum.map(teams, fn team -> + case get_by_assessment_id(team["assessment_id"]) do + nil -> create_team(team) + existing_team -> update_team(existing_team, team) + end + end) + end + + def get_by_assessment_id(assessment_id) do + from(Team) + |> where(assessment_id: ^assessment_id) + |> Repo.one() + end +end diff --git a/lib/cadet/assessments/assessment.ex b/lib/cadet/assessments/assessment.ex index fe30c0a45..d51c63c02 100644 --- a/lib/cadet/assessments/assessment.ex +++ b/lib/cadet/assessments/assessment.ex @@ -32,7 +32,7 @@ defmodule Cadet.Assessments.Assessment do field(:story, :string) field(:reading, :string) field(:password, :string, default: nil) - field(:max_team_size, :integer, default: 1) + field(:max_team_size, :integer, default: 0) belongs_to(:config, AssessmentConfig) belongs_to(:course, Course) @@ -67,7 +67,8 @@ defmodule Cadet.Assessments.Assessment do defp validate_config_course(changeset) do config_id = get_field(changeset, :config_id) course_id = get_field(changeset, :course_id) - + IO.puts("Course ID: #{inspect(course_id)}") + IO.puts("Config ID: #{inspect(config_id)}") case Repo.get(AssessmentConfig, config_id) do nil -> add_error(changeset, :config, "does not exist") diff --git a/lib/cadet/assessments/submission.ex b/lib/cadet/assessments/submission.ex index eb8164065..b50155c29 100644 --- a/lib/cadet/assessments/submission.ex +++ b/lib/cadet/assessments/submission.ex @@ -20,26 +20,47 @@ defmodule Cadet.Assessments.Submission do belongs_to(:assessment, Assessment) belongs_to(:student, CourseRegistration) + belongs_to(:team, Team) belongs_to(:unsubmitted_by, CourseRegistration) + has_many(:answers, Answer) timestamps() end - @required_fields ~w(student_id assessment_id status)a + @required_fields [ + :assessment_id, + :status, + # XOR relationship between student_id and team_id + {:one_of, [:student_id, :team_id]} + ] + @optional_fields ~w(xp_bonus unsubmitted_by_id unsubmitted_at)a def changeset(submission, params) do submission |> cast(params, @required_fields ++ @optional_fields) - |> validate_number( - :xp_bonus, - greater_than_or_equal_to: 0 - ) - |> add_belongs_to_id_from_model([:student, :assessment, :unsubmitted_by], params) + |> validate_number(:xp_bonus, greater_than_or_equal_to: 0) + |> validate_xor_relationship() + |> add_belongs_to_id_from_model([:team, :student, :assessment, :unsubmitted_by], params) |> validate_required(@required_fields) - |> foreign_key_constraint(:student_id) |> foreign_key_constraint(:assessment_id) |> foreign_key_constraint(:unsubmitted_by_id) end + + + defp validate_xor_relationship(changeset) do + case {get_field(changeset, :student_id), get_field(changeset, :team_id)} do + {nil, nil} -> + add_error(changeset, :student_id, "either student_id or team_id must be present") + |> add_error(changeset, :team_id, "either student_id or team_id must be present") + {nil, _} -> + changeset + {_, nil} -> + changeset + {_student_id, _team_id} -> + add_error(changeset, :student_id, "student_id and team_id cannot be present at the same time") + |> add_error(changeset, :team_id, "student_id and team_id cannot be present at the same time") + end + end end diff --git a/lib/cadet_web/admin_controllers/admin_teams_controller.ex b/lib/cadet_web/admin_controllers/admin_teams_controller.ex new file mode 100644 index 000000000..df508e92a --- /dev/null +++ b/lib/cadet_web/admin_controllers/admin_teams_controller.ex @@ -0,0 +1,175 @@ +defmodule CadetWeb.AdminTeamsController do + use CadetWeb, :controller + use PhoenixSwagger + alias Cadet.Repo + + alias Cadet.Accounts.{Teams, Team} + alias CadetWeb.Router.Helpers, as: Routes + + def index(conn, _params) do + teams = Team + |> Repo.all() + |> Repo.preload([assessment: [:config], team_members: [student: [:user]]]) + + teamFormationOverviews = teams + |> Enum.map(&team_to_team_formation_overview/1) + + conn + |> put_status(:ok) + |> put_resp_content_type("application/json") + |> render("index.json", teamFormationOverviews: teamFormationOverviews) + end + + defp team_to_team_formation_overview(team) do + assessment = team.assessment + + teamFormationOverview = %{ + teamId: team.id, + assessmentId: assessment.id, + assessmentName: assessment.title, + assessmentType: assessment.config.type, + studentIds: team.team_members |> Enum.map(&(&1.student.user.id)), + studentNames: team.team_members |> Enum.map(&(&1.student.user.name)) + } + + teamFormationOverview + end + + def create(conn, %{"team" => team_params}) do + case Teams.create_team(team_params) do + {:ok, team} -> + conn + |> put_flash(:info, "Team created successfully.") + |> render("create.json", team: team) + + {:error, changeset} -> + render(conn, "create.json", changeset: changeset) + end + end + + def update(conn, %{"id" => id, "team" => team_params}) do + team = Teams + |> Repo.get!(id) + |> Repo.preload([:assessment, team_members: [:student]]) + + case Teams.update_team(team, team_params) do + {:ok, updated_team} -> + conn + |> put_flash(:info, "Team updated successfully.") + |> redirect(to: Routes.admin_teams_path(conn, :show, updated_team)) + + {:error, changeset} -> + render(conn, "edit.json", team: team, changeset: changeset) + end + end + + def bulk_upload(conn, %{"teams" => teams_params}) do + case Teams.bulk_upload_teams(teams_params) do + {:ok, _teams} -> + text(conn, "Teams uploaded successfully.") + + {:error, changesets} -> + render(conn, "bulk_upload.json", changesets: changesets) + end + end + + def delete(conn, %{"id" => id}) do + team = Teams |> Repo.get!(id) + {:ok, _} = Teams.delete_team(team) + text(conn, "Team deleted successfully.") + end + + swagger_path :index do + get("/admin/users/{courseRegId}/assessments") + + summary("Fetches assessment overviews of a user") + + security([%{JWT: []}]) + + parameters do + courseRegId(:path, :integer, "Course Reg ID", required: true) + end + + response(200, "OK", Schema.array(:AssessmentsList)) + response(401, "Unauthorised") + response(403, "Forbidden") + end + + swagger_path :create do + post("/admin/assessments") + + summary("Creates a new team or updates an existing team") + + security([%{JWT: []}]) + + consumes("multipart/form-data") + + parameters do + assessment(:formData, :file, "Assessment to create or update", required: true) + forceUpdate(:formData, :boolean, "Force update", required: true) + end + + response(200, "OK") + response(400, "XML parse error") + response(403, "Forbidden") + end + + swagger_path :update do + post("/admin/assessments/{teamId}") + + summary("Updates a team") + + security([%{JWT: []}]) + + consumes("application/json") + + parameters do + teamId(:path, :integer, "Team ID", required: true) + + team(:body, Schema.ref(:AdminUpdateAssessmentPayload), "Updated team details", + required: true + ) + end + + response(200, "OK") + response(401, "Assessment is already opened") + response(403, "Forbidden") + end + + swagger_path :bulk_update do + post("/admin/assessments/{assessmentId}") + + summary("Updates an assessment") + + security([%{JWT: []}]) + + consumes("application/json") + + parameters do + assessmentId(:path, :integer, "Assessment ID", required: true) + + assessment(:body, Schema.ref(:AdminUpdateAssessmentPayload), "Updated assessment details", + required: true + ) + end + + response(200, "OK") + response(401, "Assessment is already opened") + response(403, "Forbidden") + end + + swagger_path :delete do + PhoenixSwagger.Path.delete("/admin/teams/{assessmentId}") + + summary("Deletes an assessment") + + security([%{JWT: []}]) + + parameters do + assessmentId(:path, :integer, "Assessment ID", required: true) + end + + response(200, "OK") + response(403, "Forbidden") + end +end diff --git a/lib/cadet_web/admin_views/admin_teams_view.ex b/lib/cadet_web/admin_views/admin_teams_view.ex new file mode 100644 index 000000000..1f7f6c826 --- /dev/null +++ b/lib/cadet_web/admin_views/admin_teams_view.ex @@ -0,0 +1,18 @@ +defmodule CadetWeb.AdminTeamsView do + use CadetWeb, :view + + def render("index.json", %{teamFormationOverviews: teamFormationOverviews}) do + render_many(teamFormationOverviews, CadetWeb.AdminTeamsView, "team_formation_overview.json", as: :team_formation_overview) + end + + def render("team_formation_overview.json", %{team_formation_overview: team_formation_overview}) do + %{ + teamId: team_formation_overview.teamId, + assessmentId: team_formation_overview.assessmentId, + assessmentName: team_formation_overview.assessmentName, + assessmentType: team_formation_overview.assessmentType, + studentIds: team_formation_overview.studentIds, + studentNames: team_formation_overview.studentNames + } + end +end diff --git a/lib/cadet_web/router.ex b/lib/cadet_web/router.ex index cbd3fb755..3785044d3 100644 --- a/lib/cadet_web/router.ex +++ b/lib/cadet_web/router.ex @@ -167,6 +167,12 @@ defmodule CadetWeb.Router do AdminCoursesController, :delete_assessment_config ) + + get("/teams", AdminTeamsController, :index) + post("/teams", AdminTeamsController, :create) + delete("/teams/:teamid", AdminTeamsController, :delete) + put("/teams/:teamid", AdminTeamsController, :update) + post("/teams/upload", AdminTeamsController, :bulk_upload) end # Other scopes may use custom stacks. From 54f1a4848c4361069c9fa0c863441dfca543562d Mon Sep 17 00:00:00 2001 From: LuYiting0913 Date: Thu, 20 Jul 2023 13:10:37 +0800 Subject: [PATCH 010/128] Generate seeds for team and team_member --- priv/repo/seeds.exs | 52 +++++++++++++++---- test/factories/accounts/team_factory.ex | 19 +++++++ .../factories/accounts/team_member_factory.ex | 18 +++++++ test/factories/factory.ex | 8 ++- 4 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 test/factories/accounts/team_factory.ex create mode 100644 test/factories/accounts/team_member_factory.ex diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index dee7be580..161ff03da 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -12,6 +12,10 @@ import Cadet.Factory alias Cadet.Assessments.SubmissionStatus +alias Cadet.Accounts.Team +alias Cadet.Accounts.Teams + + # insert default source version # Cadet.Repo.insert!(%Cadet.Settings.Sublanguage{chapter: 1, variant: "default"}) @@ -20,17 +24,21 @@ if Cadet.Env.env() == :dev do # Course course1 = insert(:course) course2 = insert(:course, %{course_name: "Algorithm", course_short_name: "CS2040S"}) + # IO.inspect(course1) # Users avenger1 = insert(:user, %{name: "avenger", latest_viewed_course: course1}) admin1 = insert(:user, %{name: "admin", latest_viewed_course: course1}) studenta1admin2 = insert(:user, %{name: "student a", latest_viewed_course: course1}) - studentb1 = insert(:user, %{latest_viewed_course: course1}) - studentc1 = insert(:user, %{latest_viewed_course: course1}) + studentb1 = insert(:user, %{name: "student 1", latest_viewed_course: course1}) + studentc1 = insert(:user, %{name: "student 2", latest_viewed_course: course1}) + studentd1 = insert(:user, %{name: "student 3", latest_viewed_course: course1}) + studente1 = insert(:user, %{name: "student 4", latest_viewed_course: course1}) + # CourseRegistration and Group avenger1_cr = insert(:course_registration, %{user: avenger1, course: course1, role: :staff}) - _admin1_cr = insert(:course_registration, %{user: admin1, course: course1, role: :admin}) + # _admin1_cr = insert(:course_registration, %{user: admin1, course: course1, role: :admin}) group = insert(:group, %{leader: avenger1_cr}) student1a_cr = @@ -47,7 +55,13 @@ if Cadet.Env.env() == :dev do student1c_cr = insert(:course_registration, %{user: studentc1, course: course1, role: :student, group: group}) - students = [student1a_cr, student1b_cr, student1c_cr] + student1d_cr = + insert(:course_registration, %{user: studentd1, course: course1, role: :student, group: group}) + + student1e_cr = + insert(:course_registration, %{user: studente1, course: course1, role: :student, group: group}) + + students = [student1a_cr, student1b_cr, student1c_cr, student1d_cr, student1e_cr] _admin2cr = insert(:course_registration, %{user: studenta1admin2, course: course2, role: :admin}) @@ -55,20 +69,23 @@ if Cadet.Env.env() == :dev do # Assessments for i <- 1..5 do config = insert(:assessment_config, %{type: "Mission#{i}", order: i, course: course1}) - assessment = insert(:assessment, %{is_published: true, config: config, course: course1}) + assessment1 = insert(:assessment, %{is_published: true, config: config, course: course1}) config2 = insert(:assessment_config, %{type: "Homework#{i}", order: i, course: course2}) - _assessment2 = insert(:assessment, %{is_published: true, config: config2, course: course2}) + assessment2 = insert(:assessment, %{is_published: true, config: config2, course: course2}) + + config3 = insert(:assessment_config, %{type: "Path#{i}", order: i, course: course1}) + assessment3 = insert(:assessment, %{is_published: true, config: config3, course: course1}) programming_questions = insert_list(3, :programming_question, %{ - assessment: assessment, + assessment: assessment1, max_xp: 1_000 }) mcq_questions = insert_list(3, :mcq_question, %{ - assessment: assessment, + assessment: assessment1, max_xp: 500 }) @@ -77,7 +94,7 @@ if Cadet.Env.env() == :dev do |> Enum.take(2) |> Enum.map( &insert(:submission, %{ - assessment: assessment, + assessment: assessment1, student: &1, status: Enum.random(SubmissionStatus.__enum_map__()) }) @@ -105,6 +122,23 @@ if Cadet.Env.env() == :dev do }) end + # Teams + team1a = insert(:team, %{assessment: assessment1}) + team1b = insert(:team, %{assessment: assessment1}) + + team1a = insert(:team, %{assessment: assessment1}) + team1b = insert(:team, %{assessment: assessment1}) + IO.inspect(team1a) + IO.inspect(team1b) + # Team members + member1 = insert(:team_member, %{student: student1d_cr, team: team1a}) + member2 = insert(:team_member, %{student: student1e_cr, team: team1a}) + IO.inspect(member1) + IO.inspect(member2) + + member3 = insert(:team_member, %{student: student1b_cr, team: team1b}) + member4 = insert(:team_member, %{student: student1c_cr, team: team1b}) + # # Notifications # for submission <- submissions do # case submission.status do diff --git a/test/factories/accounts/team_factory.ex b/test/factories/accounts/team_factory.ex new file mode 100644 index 000000000..57140de2d --- /dev/null +++ b/test/factories/accounts/team_factory.ex @@ -0,0 +1,19 @@ +defmodule Cadet.Accounts.TeamFactory do + @moduledoc """ + Factory(ies) for Cadet.Accounts.Team entity + """ + + defmacro __using__(_opts) do + quote do + # alias Cadet.Accounts.{Role, User} + alias Cadet.Accounts.Team + + def team_factory do + %Team{ + assessment: build(:assessment) + } + end + + end + end +end diff --git a/test/factories/accounts/team_member_factory.ex b/test/factories/accounts/team_member_factory.ex new file mode 100644 index 000000000..2c799affd --- /dev/null +++ b/test/factories/accounts/team_member_factory.ex @@ -0,0 +1,18 @@ +defmodule Cadet.Accounts.TeamMemberFactory do + @moduledoc """ + Factory(ies) for Cadet.Accounts.TeamMember entity + """ + + defmacro __using__(_opts) do + quote do + # alias Cadet.Accounts.{Role, User} + alias Cadet.Accounts.TeamMember + + def team_member_factory do + %TeamMember{ + } + end + + end + end +end diff --git a/test/factories/factory.ex b/test/factories/factory.ex index 469346ff5..6daf24364 100644 --- a/test/factories/factory.ex +++ b/test/factories/factory.ex @@ -4,7 +4,13 @@ defmodule Cadet.Factory do """ use ExMachina.Ecto, repo: Cadet.Repo - use Cadet.Accounts.{NotificationFactory, UserFactory, CourseRegistrationFactory} + use Cadet.Accounts.{ + NotificationFactory, + UserFactory, + CourseRegistrationFactory, + TeamFactory, + TeamMemberFactory + } use Cadet.Assessments.{ AnswerFactory, From 6a641d26e67bc4705ab67137f1319bb6f6af9660 Mon Sep 17 00:00:00 2001 From: LuYiting0913 Date: Thu, 20 Jul 2023 13:14:24 +0800 Subject: [PATCH 011/128] Fix line endings --- .credo.exs | 280 +- .iex.exs | 4 + lib/cadet/accounts/notification.ex | 72 +- lib/cadet/assessments/assessment.ex | 188 +- lib/cadet/assessments/assessments.ex | 3346 ++++++++--------- .../admin_assessments_controller.ex | 446 +-- .../admin_views/admin_assessments_view.ex | 128 +- .../controllers/assessments_controller.ex | 806 ++-- mix.exs | 264 +- mix.lock | 180 +- ...0190510152804_drop_announcements_table.exs | 14 +- .../assessments_controller_test.exs | 3146 ++++++++-------- 12 files changed, 4439 insertions(+), 4435 deletions(-) diff --git a/.credo.exs b/.credo.exs index 0b50b3681..cc2e7c08b 100644 --- a/.credo.exs +++ b/.credo.exs @@ -1,140 +1,140 @@ -# This file contains the configuration for Credo and you are probably reading -# this after creating it with `mix credo.gen.config`. -# -# If you find anything wrong or unclear in this file, please report an -# issue on GitHub: https://github.com/rrrene/credo/issues -# -%{ - # - # You can have as many configs as you like in the `configs:` field. - configs: [ - %{ - # - # Run any exec using `mix credo -C `. If no exec name is given - # "default" is used. - # - name: "default", - # - # These are the files included in the analysis: - files: %{ - # - # You can give explicit globs or simply directories. - # In the latter case `**/*.{ex,exs}` will be used. - included: ["lib/", "src/", "web/", "apps/", "test/"], - excluded: [~r"/_build/", ~r"/deps/"] - }, - # - # If you create your own checks, you must specify the source files for - # them here, so they can be loaded by Credo before running the analysis. - # - requires: [], - # - # If you want to enforce a style guide and need a more traditional linting - # experience, you can change `strict` to `true` below: - # - strict: true, - # - # If you want to use uncolored output by default, you can change `color` - # to `false` below: - # - color: true, - # - # You can customize the parameters of any check by adding a second element - # to the tuple. - # - # To disable a check put `false` as second element: - # - # {Credo.Check.Design.DuplicatedCode, false} - # - checks: [ - {Credo.Check.Consistency.ExceptionNames}, - {Credo.Check.Consistency.LineEndings}, - {Credo.Check.Consistency.ParameterPatternMatching}, - {Credo.Check.Consistency.SpaceAroundOperators}, - {Credo.Check.Consistency.SpaceInParentheses}, - {Credo.Check.Consistency.TabsOrSpaces}, - - # For some checks, like AliasUsage, you can only customize the priority - # Priority values are: `low, normal, high, higher` - # - {Credo.Check.Design.AliasUsage, - if_called_more_often_than: 2, excluded_namespaces: ["Faker"]}, - - # For others you can set parameters - - # If you don't want the `setup` and `test` macro calls in ExUnit tests - # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just - # set the `excluded_macros` parameter to `[:schema, :setup, :test]`. - # - {Credo.Check.Design.DuplicatedCode, excluded_macros: [], exit_status: 0}, - - # You can also customize the exit_status of each check. - # If you don't want TODO comments to cause `mix credo` to fail, just - # set this value to 0 (zero). - # - {Credo.Check.Design.TagTODO, exit_status: 0}, - {Credo.Check.Design.TagFIXME}, - {Credo.Check.Readability.AliasOrder, false}, - {Credo.Check.Readability.FunctionNames}, - {Credo.Check.Readability.LargeNumbers}, - {Credo.Check.Readability.MaxLineLength, max_length: 101}, - {Credo.Check.Readability.ModuleAttributeNames}, - {Credo.Check.Readability.ModuleDoc}, - {Credo.Check.Readability.ModuleNames}, - {Credo.Check.Readability.ParenthesesOnZeroArityDefs}, - {Credo.Check.Readability.ParenthesesInCondition}, - {Credo.Check.Readability.PredicateFunctionNames}, - {Credo.Check.Readability.PreferImplicitTry}, - {Credo.Check.Readability.RedundantBlankLines}, - {Credo.Check.Readability.StringSigils}, - {Credo.Check.Readability.TrailingBlankLine}, - {Credo.Check.Readability.TrailingWhiteSpace}, - {Credo.Check.Readability.VariableNames}, - {Credo.Check.Readability.Semicolons}, - {Credo.Check.Readability.SpaceAfterCommas}, - {Credo.Check.Refactor.DoubleBooleanNegation}, - {Credo.Check.Refactor.CondStatements}, - {Credo.Check.Refactor.CyclomaticComplexity}, - {Credo.Check.Refactor.FunctionArity}, - {Credo.Check.Refactor.LongQuoteBlocks}, - {Credo.Check.Refactor.MatchInCondition}, - {Credo.Check.Refactor.NegatedConditionsInUnless}, - {Credo.Check.Refactor.NegatedConditionsWithElse}, - {Credo.Check.Refactor.Nesting}, - {Credo.Check.Refactor.PipeChainStart}, - {Credo.Check.Refactor.UnlessWithElse}, - {Credo.Check.Warning.BoolOperationOnSameValues}, - {Credo.Check.Warning.IExPry}, - {Credo.Check.Warning.IoInspect}, - {Credo.Check.Warning.LazyLogging, false}, - {Credo.Check.Warning.OperationOnSameValues}, - {Credo.Check.Warning.OperationWithConstantResult}, - {Credo.Check.Warning.UnusedEnumOperation}, - {Credo.Check.Warning.UnusedFileOperation}, - {Credo.Check.Warning.UnusedKeywordOperation}, - {Credo.Check.Warning.UnusedListOperation}, - {Credo.Check.Warning.UnusedPathOperation}, - {Credo.Check.Warning.UnusedRegexOperation}, - {Credo.Check.Warning.UnusedStringOperation}, - {Credo.Check.Warning.UnusedTupleOperation}, - {Credo.Check.Warning.RaiseInsideRescue}, - - # Controversial and experimental checks (opt-in, just remove `, false`) - # - {Credo.Check.Refactor.ABCSize, false}, - {Credo.Check.Refactor.AppendSingleItem, false}, - {Credo.Check.Refactor.VariableRebinding, false}, - {Credo.Check.Warning.MapGetUnsafePass}, - {Credo.Check.Consistency.MultiAliasImportRequireUse}, - - # Deprecated checks (these will be deleted after a grace period) - # - {Credo.Check.Readability.Specs, false}, - {Credo.Check.Refactor.MapInto, false} - - # Custom checks can be created using `mix credo.gen.check`. - # - ] - } - ] -} +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any exec using `mix credo -C `. If no exec name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + included: ["lib/", "src/", "web/", "apps/", "test/"], + excluded: [~r"/_build/", ~r"/deps/"] + }, + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: [], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: true, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: [ + {Credo.Check.Consistency.ExceptionNames}, + {Credo.Check.Consistency.LineEndings}, + {Credo.Check.Consistency.ParameterPatternMatching}, + {Credo.Check.Consistency.SpaceAroundOperators}, + {Credo.Check.Consistency.SpaceInParentheses}, + {Credo.Check.Consistency.TabsOrSpaces}, + + # For some checks, like AliasUsage, you can only customize the priority + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, + if_called_more_often_than: 2, excluded_namespaces: ["Faker"]}, + + # For others you can set parameters + + # If you don't want the `setup` and `test` macro calls in ExUnit tests + # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just + # set the `excluded_macros` parameter to `[:schema, :setup, :test]`. + # + {Credo.Check.Design.DuplicatedCode, excluded_macros: [], exit_status: 0}, + + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, exit_status: 0}, + {Credo.Check.Design.TagFIXME}, + {Credo.Check.Readability.AliasOrder, false}, + {Credo.Check.Readability.FunctionNames}, + {Credo.Check.Readability.LargeNumbers}, + {Credo.Check.Readability.MaxLineLength, max_length: 101}, + {Credo.Check.Readability.ModuleAttributeNames}, + {Credo.Check.Readability.ModuleDoc}, + {Credo.Check.Readability.ModuleNames}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs}, + {Credo.Check.Readability.ParenthesesInCondition}, + {Credo.Check.Readability.PredicateFunctionNames}, + {Credo.Check.Readability.PreferImplicitTry}, + {Credo.Check.Readability.RedundantBlankLines}, + {Credo.Check.Readability.StringSigils}, + {Credo.Check.Readability.TrailingBlankLine}, + {Credo.Check.Readability.TrailingWhiteSpace}, + {Credo.Check.Readability.VariableNames}, + {Credo.Check.Readability.Semicolons}, + {Credo.Check.Readability.SpaceAfterCommas}, + {Credo.Check.Refactor.DoubleBooleanNegation}, + {Credo.Check.Refactor.CondStatements}, + {Credo.Check.Refactor.CyclomaticComplexity}, + {Credo.Check.Refactor.FunctionArity}, + {Credo.Check.Refactor.LongQuoteBlocks}, + {Credo.Check.Refactor.MatchInCondition}, + {Credo.Check.Refactor.NegatedConditionsInUnless}, + {Credo.Check.Refactor.NegatedConditionsWithElse}, + {Credo.Check.Refactor.Nesting}, + {Credo.Check.Refactor.PipeChainStart}, + {Credo.Check.Refactor.UnlessWithElse}, + {Credo.Check.Warning.BoolOperationOnSameValues}, + {Credo.Check.Warning.IExPry}, + {Credo.Check.Warning.IoInspect}, + {Credo.Check.Warning.LazyLogging, false}, + {Credo.Check.Warning.OperationOnSameValues}, + {Credo.Check.Warning.OperationWithConstantResult}, + {Credo.Check.Warning.UnusedEnumOperation}, + {Credo.Check.Warning.UnusedFileOperation}, + {Credo.Check.Warning.UnusedKeywordOperation}, + {Credo.Check.Warning.UnusedListOperation}, + {Credo.Check.Warning.UnusedPathOperation}, + {Credo.Check.Warning.UnusedRegexOperation}, + {Credo.Check.Warning.UnusedStringOperation}, + {Credo.Check.Warning.UnusedTupleOperation}, + {Credo.Check.Warning.RaiseInsideRescue}, + + # Controversial and experimental checks (opt-in, just remove `, false`) + # + {Credo.Check.Refactor.ABCSize, false}, + {Credo.Check.Refactor.AppendSingleItem, false}, + {Credo.Check.Refactor.VariableRebinding, false}, + {Credo.Check.Warning.MapGetUnsafePass}, + {Credo.Check.Consistency.MultiAliasImportRequireUse}, + + # Deprecated checks (these will be deleted after a grace period) + # + {Credo.Check.Readability.Specs, false}, + {Credo.Check.Refactor.MapInto, false} + + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } + ] +} diff --git a/.iex.exs b/.iex.exs index b4703b99b..2ef5de047 100644 --- a/.iex.exs +++ b/.iex.exs @@ -3,3 +3,7 @@ alias Cadet.Repo alias Cadet.Accounts.User alias Cadet.Assessments.{Answer, Assessment, Question, Submission} alias Cadet.Courses.Group +alias Cadet.Accounts.Team +alias Cadet.Accounts.TeamMember + + diff --git a/lib/cadet/accounts/notification.ex b/lib/cadet/accounts/notification.ex index 0b07e46a7..e6e552d1b 100644 --- a/lib/cadet/accounts/notification.ex +++ b/lib/cadet/accounts/notification.ex @@ -1,36 +1,36 @@ -defmodule Cadet.Accounts.Notification do - @moduledoc """ - The Notification entity represents a notification. - It stores information pertaining to the type of notification and who in which course it belongs to. - Each notification can have an assessment id or submission id, with optional question id. - This will be used to pinpoint where the notification will be showed on the frontend. - """ - use Cadet, :model - - alias Cadet.Accounts.{NotificationType, Role, CourseRegistration} - alias Cadet.Assessments.{Assessment, Submission} - - schema "notifications" do - field(:type, NotificationType) - field(:read, :boolean, default: false) - field(:role, Role, virtual: true) - - belongs_to(:course_reg, CourseRegistration) - belongs_to(:assessment, Assessment) - belongs_to(:submission, Submission) - - timestamps() - end - - @required_fields ~w(type read course_reg_id assessment_id)a - @optional_fields ~w(submission_id)a - - def changeset(answer, params) do - answer - |> cast(params, @required_fields ++ @optional_fields) - |> validate_required(@required_fields) - |> foreign_key_constraint(:course_reg_id) - |> foreign_key_constraint(:assessment_id) - |> foreign_key_constraint(:submission_id) - end -end +defmodule Cadet.Accounts.Notification do + @moduledoc """ + The Notification entity represents a notification. + It stores information pertaining to the type of notification and who in which course it belongs to. + Each notification can have an assessment id or submission id, with optional question id. + This will be used to pinpoint where the notification will be showed on the frontend. + """ + use Cadet, :model + + alias Cadet.Accounts.{NotificationType, Role, CourseRegistration} + alias Cadet.Assessments.{Assessment, Submission} + + schema "notifications" do + field(:type, NotificationType) + field(:read, :boolean, default: false) + field(:role, Role, virtual: true) + + belongs_to(:course_reg, CourseRegistration) + belongs_to(:assessment, Assessment) + belongs_to(:submission, Submission) + + timestamps() + end + + @required_fields ~w(type read course_reg_id assessment_id)a + @optional_fields ~w(submission_id)a + + def changeset(answer, params) do + answer + |> cast(params, @required_fields ++ @optional_fields) + |> validate_required(@required_fields) + |> foreign_key_constraint(:course_reg_id) + |> foreign_key_constraint(:assessment_id) + |> foreign_key_constraint(:submission_id) + end +end diff --git a/lib/cadet/assessments/assessment.ex b/lib/cadet/assessments/assessment.ex index d51c63c02..7a49ce1f5 100644 --- a/lib/cadet/assessments/assessment.ex +++ b/lib/cadet/assessments/assessment.ex @@ -1,94 +1,94 @@ -defmodule Cadet.Assessments.Assessment do - @moduledoc """ - The Assessment entity stores metadata of a students' assessment - (mission, sidequest, path, and contest) - """ - use Cadet, :model - use Arc.Ecto.Schema - - alias Cadet.Repo - alias Cadet.Assessments.{AssessmentAccess, Question, SubmissionStatus, Upload} - alias Cadet.Courses.{Course, AssessmentConfig} - - @type t :: %__MODULE__{} - - schema "assessments" do - field(:access, AssessmentAccess, virtual: true, default: :public) - field(:max_xp, :integer, virtual: true) - field(:xp, :integer, virtual: true, default: 0) - field(:user_status, SubmissionStatus, virtual: true) - field(:grading_status, :string, virtual: true) - field(:question_count, :integer, virtual: true) - field(:graded_count, :integer, virtual: true) - field(:title, :string) - field(:is_published, :boolean, default: false) - field(:summary_short, :string) - field(:summary_long, :string) - field(:open_at, :utc_datetime_usec) - field(:close_at, :utc_datetime_usec) - field(:cover_picture, :string) - field(:mission_pdf, Upload.Type) - field(:number, :string) - field(:story, :string) - field(:reading, :string) - field(:password, :string, default: nil) - field(:max_team_size, :integer, default: 0) - - belongs_to(:config, AssessmentConfig) - belongs_to(:course, Course) - - has_many(:questions, Question, on_delete: :delete_all) - timestamps() - end - - @required_fields ~w(title open_at close_at number course_id config_id max_team_size)a - @optional_fields ~w(reading summary_short summary_long - is_published story cover_picture access password)a - @optional_file_fields ~w(mission_pdf)a - - def changeset(assessment, params) do - params = - params - |> convert_date(:open_at) - |> convert_date(:close_at) - - assessment - |> cast_attachments(params, @optional_file_fields) - |> cast(params, @required_fields ++ @optional_fields) - |> validate_required(@required_fields) - |> add_belongs_to_id_from_model([:config, :course], params) - |> foreign_key_constraint(:config_id) - |> foreign_key_constraint(:course_id) - |> unique_constraint([:number, :course_id]) - |> validate_config_course - |> validate_open_close_date - end - - defp validate_config_course(changeset) do - config_id = get_field(changeset, :config_id) - course_id = get_field(changeset, :course_id) - IO.puts("Course ID: #{inspect(course_id)}") - IO.puts("Config ID: #{inspect(config_id)}") - case Repo.get(AssessmentConfig, config_id) do - nil -> - add_error(changeset, :config, "does not exist") - - config -> - if config.course_id == course_id do - changeset - else - add_error(changeset, :config, "does not belong to the same course as this assessment") - end - end - end - - defp validate_open_close_date(changeset) do - validate_change(changeset, :open_at, fn :open_at, open_at -> - if Timex.before?(open_at, get_field(changeset, :close_at)) do - [] - else - [open_at: "Open date must be before close date"] - end - end) - end -end +defmodule Cadet.Assessments.Assessment do + @moduledoc """ + The Assessment entity stores metadata of a students' assessment + (mission, sidequest, path, and contest) + """ + use Cadet, :model + use Arc.Ecto.Schema + + alias Cadet.Repo + alias Cadet.Assessments.{AssessmentAccess, Question, SubmissionStatus, Upload} + alias Cadet.Courses.{Course, AssessmentConfig} + + @type t :: %__MODULE__{} + + schema "assessments" do + field(:access, AssessmentAccess, virtual: true, default: :public) + field(:max_xp, :integer, virtual: true) + field(:xp, :integer, virtual: true, default: 0) + field(:user_status, SubmissionStatus, virtual: true) + field(:grading_status, :string, virtual: true) + field(:question_count, :integer, virtual: true) + field(:graded_count, :integer, virtual: true) + field(:title, :string) + field(:is_published, :boolean, default: false) + field(:summary_short, :string) + field(:summary_long, :string) + field(:open_at, :utc_datetime_usec) + field(:close_at, :utc_datetime_usec) + field(:cover_picture, :string) + field(:mission_pdf, Upload.Type) + field(:number, :string) + field(:story, :string) + field(:reading, :string) + field(:password, :string, default: nil) + field(:max_team_size, :integer, default: 0) + + belongs_to(:config, AssessmentConfig) + belongs_to(:course, Course) + + has_many(:questions, Question, on_delete: :delete_all) + timestamps() + end + + @required_fields ~w(title open_at close_at number course_id config_id max_team_size)a + @optional_fields ~w(reading summary_short summary_long + is_published story cover_picture access password)a + @optional_file_fields ~w(mission_pdf)a + + def changeset(assessment, params) do + params = + params + |> convert_date(:open_at) + |> convert_date(:close_at) + + assessment + |> cast_attachments(params, @optional_file_fields) + |> cast(params, @required_fields ++ @optional_fields) + |> validate_required(@required_fields) + |> add_belongs_to_id_from_model([:config, :course], params) + |> foreign_key_constraint(:config_id) + |> foreign_key_constraint(:course_id) + |> unique_constraint([:number, :course_id]) + |> validate_config_course + |> validate_open_close_date + end + + defp validate_config_course(changeset) do + config_id = get_field(changeset, :config_id) + course_id = get_field(changeset, :course_id) + IO.puts("Course ID: #{inspect(course_id)}") + IO.puts("Config ID: #{inspect(config_id)}") + case Repo.get(AssessmentConfig, config_id) do + nil -> + add_error(changeset, :config, "does not exist") + + config -> + if config.course_id == course_id do + changeset + else + add_error(changeset, :config, "does not belong to the same course as this assessment") + end + end + end + + defp validate_open_close_date(changeset) do + validate_change(changeset, :open_at, fn :open_at, open_at -> + if Timex.before?(open_at, get_field(changeset, :close_at)) do + [] + else + [open_at: "Open date must be before close date"] + end + end) + end +end diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index b0d685119..a16b66bb2 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -1,1673 +1,1673 @@ -defmodule Cadet.Assessments do - @moduledoc """ - Assessments context contains domain logic for assessments management such as - missions, sidequests, paths, etc. - """ - use Cadet, [:context, :display] - import Ecto.Query - - require Logger - - alias Cadet.Accounts.{ - Notification, - Notifications, - User, - CourseRegistration, - CourseRegistrations - } - - alias Cadet.Assessments.{Answer, Assessment, Query, Question, Submission, SubmissionVotes} - alias Cadet.Autograder.GradingJob - alias Cadet.Courses.{Group, AssessmentConfig} - alias Cadet.Jobs.Log - alias Cadet.ProgramAnalysis.Lexer - alias Ecto.Multi - alias Cadet.Incentives.Achievements - - require Decimal - - @open_all_assessment_roles ~w(staff admin)a - - # These roles can save and finalise answers for closed assessments and - # submitted answers - @bypass_closed_roles ~w(staff admin)a - - def delete_assessment(id) do - assessment = Repo.get(Assessment, id) - - Submission - |> where(assessment_id: ^id) - |> delete_submission_assocation(id) - - Question - |> where(assessment_id: ^id) - |> Repo.all() - |> Enum.each(fn q -> - delete_submission_votes_association(q) - end) - - Repo.delete(assessment) - end - - defp delete_submission_votes_association(question) do - SubmissionVotes - |> where(question_id: ^question.id) - |> Repo.delete_all() - end - - defp delete_submission_assocation(submissions, assessment_id) do - submissions - |> Repo.all() - |> Enum.each(fn submission -> - Answer - |> where(submission_id: ^submission.id) - |> Repo.delete_all() - end) - - Notification - |> where(assessment_id: ^assessment_id) - |> Repo.delete_all() - - Repo.delete_all(submissions) - end - - @spec user_max_xp(CourseRegistration.t()) :: integer() - def user_max_xp(%CourseRegistration{id: cr_id}) do - Submission - |> where(status: ^:submitted) - |> where(student_id: ^cr_id) - |> join( - :inner, - [s], - a in subquery(Query.all_assessments_with_max_xp()), - on: s.assessment_id == a.id - ) - |> select([_, a], sum(a.max_xp)) - |> Repo.one() - |> decimal_to_integer() - end - - def assessments_total_xp(%CourseRegistration{id: cr_id}) do - submission_xp = - Submission - |> where(student_id: ^cr_id) - |> join(:inner, [s], a in Answer, on: s.id == a.submission_id) - |> group_by([s], s.id) - |> select([s, a], %{ - # grouping by submission, so s.xp_bonus will be the same, but we need an - # aggregate function - total_xp: sum(a.xp) + sum(a.xp_adjustment) + max(s.xp_bonus) - }) - - total = - submission_xp - |> subquery - |> select([s], %{ - total_xp: sum(s.total_xp) - }) - |> Repo.one() - - # for {key, val} <- total, into: %{}, do: {key, decimal_to_integer(val)} - decimal_to_integer(total.total_xp) - end - - def user_total_xp(course_id, user_id, course_reg_id) do - user_course = CourseRegistrations.get_user_course(user_id, course_id) - - total_achievement_xp = Achievements.achievements_total_xp(course_id, course_reg_id) - total_assessment_xp = assessments_total_xp(user_course) - - total_achievement_xp + total_assessment_xp - end - - defp decimal_to_integer(decimal) do - if Decimal.is_decimal(decimal) do - Decimal.to_integer(decimal) - else - 0 - end - end - - def user_current_story(cr = %CourseRegistration{}) do - {:ok, %{result: story}} = - Multi.new() - |> Multi.run(:unattempted, fn _repo, _ -> - {:ok, get_user_story_by_type(cr, :unattempted)} - end) - |> Multi.run(:result, fn _repo, %{unattempted: unattempted_story} -> - if unattempted_story do - {:ok, %{play_story?: true, story: unattempted_story}} - else - {:ok, %{play_story?: false, story: get_user_story_by_type(cr, :attempted)}} - end - end) - |> Repo.transaction() - - story - end - - @spec get_user_story_by_type(CourseRegistration.t(), :unattempted | :attempted) :: - String.t() | nil - def get_user_story_by_type(%CourseRegistration{id: cr_id}, type) - when is_atom(type) do - filter_and_sort = fn query -> - case type do - :unattempted -> - query - |> where([_, s], is_nil(s.id)) - |> order_by([a], asc: a.open_at) - - :attempted -> - query |> order_by([a], desc: a.close_at) - end - end - - Assessment - |> where(is_published: true) - |> where([a], not is_nil(a.story)) - |> where([a], a.open_at <= from_now(0, "second") and a.close_at >= from_now(0, "second")) - |> join(:left, [a], s in Submission, on: s.assessment_id == a.id and s.student_id == ^cr_id) - |> filter_and_sort.() - |> order_by([a], a.config_id) - |> select([a], a.story) - |> first() - |> Repo.one() - end - - def assessment_with_questions_and_answers( - assessment = %Assessment{password: nil}, - cr = %CourseRegistration{}, - nil - ) do - assessment_with_questions_and_answers(assessment, cr) - end - - def assessment_with_questions_and_answers( - assessment = %Assessment{password: nil}, - cr = %CourseRegistration{}, - _ - ) do - assessment_with_questions_and_answers(assessment, cr) - end - - def assessment_with_questions_and_answers( - assessment = %Assessment{password: password}, - cr = %CourseRegistration{}, - given_password - ) do - cond do - Timex.compare(Timex.now(), assessment.close_at) >= 0 -> - assessment_with_questions_and_answers(assessment, cr) - - match?({:ok, _}, find_submission(cr, assessment)) -> - assessment_with_questions_and_answers(assessment, cr) - - given_password == nil -> - {:error, {:forbidden, "Missing Password."}} - - password == given_password -> - find_or_create_submission(cr, assessment) - assessment_with_questions_and_answers(assessment, cr) - - true -> - {:error, {:forbidden, "Invalid Password."}} - end - end - - def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}, password) - when is_ecto_id(id) do - role = cr.role - - assessment = - if role in @open_all_assessment_roles do - Assessment - |> where(id: ^id) - |> preload(:config) - |> Repo.one() - else - Assessment - |> where(id: ^id) - |> where(is_published: true) - |> preload(:config) - |> Repo.one() - end - - if assessment do - assessment_with_questions_and_answers(assessment, cr, password) - else - {:error, {:bad_request, "Assessment not found"}} - end - end - - def assessment_with_questions_and_answers( - assessment = %Assessment{id: id}, - course_reg = %CourseRegistration{role: role} - ) do - if Timex.compare(Timex.now(), assessment.open_at) >= 0 or role in @open_all_assessment_roles do - answer_query = - Answer - |> join(:inner, [a], s in assoc(a, :submission)) - |> where([_, s], s.student_id == ^course_reg.id) - - questions = - Question - |> where(assessment_id: ^id) - |> join(:left, [q], a in subquery(answer_query), on: q.id == a.question_id) - |> join(:left, [_, a], g in assoc(a, :grader)) - |> join(:left, [_, _, g], u in assoc(g, :user)) - |> select([q, a, g, u], {q, a, g, u}) - |> order_by(:display_order) - |> Repo.all() - |> Enum.map(fn - {q, nil, _, _} -> %{q | answer: %Answer{grader: nil}} - {q, a, nil, _} -> %{q | answer: %Answer{a | grader: nil}} - {q, a, g, u} -> %{q | answer: %Answer{a | grader: %CourseRegistration{g | user: u}}} - end) - |> load_contest_voting_entries(course_reg, assessment) - - assessment = assessment |> Map.put(:questions, questions) - {:ok, assessment} - else - {:error, {:unauthorized, "Assessment not open"}} - end - end - - def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}) do - assessment_with_questions_and_answers(id, cr, nil) - end - - @doc """ - Returns a list of assessments with all fields and an indicator showing whether it has been attempted - by the supplied user - """ - def all_assessments(cr = %CourseRegistration{}) do - submission_aggregates = - Submission - |> join(:left, [s], ans in Answer, on: ans.submission_id == s.id) - |> where([s], s.student_id == ^cr.id) - |> group_by([s], s.assessment_id) - |> select([s, ans], %{ - assessment_id: s.assessment_id, - # s.xp_bonus should be the same across the group, but we need an aggregate function here - xp: fragment("? + ? + ?", sum(ans.xp), sum(ans.xp_adjustment), max(s.xp_bonus)), - graded_count: ans.id |> count() |> filter(not is_nil(ans.grader_id)) - }) - - submission_status = - Submission - |> where([s], s.student_id == ^cr.id) - |> select([s], [:assessment_id, :status]) - - assessments = - cr.course_id - |> Query.all_assessments_with_aggregates() - |> subquery() - |> join( - :left, - [a], - sa in subquery(submission_aggregates), - on: a.id == sa.assessment_id - ) - |> join(:left, [a, _], s in subquery(submission_status), on: a.id == s.assessment_id) - |> select([a, sa, s], %{ - a - | xp: sa.xp, - graded_count: sa.graded_count, - user_status: s.status - }) - |> filter_published_assessments(cr) - |> order_by(:open_at) - |> preload(:config) - |> Repo.all() - - {:ok, assessments} - end - - def filter_published_assessments(assessments, cr) do - role = cr.role - - case role do - :student -> where(assessments, is_published: true) - _ -> assessments - end - end - - def create_assessment(params) do - %Assessment{} - |> Assessment.changeset(params) - |> Repo.insert() - end - - @doc """ - The main function that inserts or updates assessments from the XML Parser - """ - @spec insert_or_update_assessments_and_questions(map(), [map()], boolean()) :: - {:ok, any()} - | {:error, Ecto.Multi.name(), any(), %{optional(Ecto.Multi.name()) => any()}} - def insert_or_update_assessments_and_questions( - assessment_params, - questions_params, - force_update - ) do - assessment_multi = - Multi.insert_or_update( - Multi.new(), - :assessment, - insert_or_update_assessment_changeset(assessment_params, force_update) - ) - - if force_update and invalid_force_update(assessment_multi, questions_params) do - {:error, "Question count is different"} - else - questions_params - |> Enum.with_index(1) - |> Enum.reduce(assessment_multi, fn {question_params, index}, multi -> - Multi.run(multi, "question#{index}", fn _repo, %{assessment: %Assessment{id: id}} -> - question = - Question - |> where([q], q.display_order == ^index and q.assessment_id == ^id) - |> Repo.one() - - # the is_nil(question) check allows for force updating of brand new assessments - if !force_update or is_nil(question) do - {status, new_question} = - question_params - |> Map.put(:display_order, index) - |> build_question_changeset_for_assessment_id(id) - |> Repo.insert() - - if status == :ok and new_question.type == :voting do - insert_voting( - assessment_params.course_id, - question_params.question.contest_number, - new_question.id - ) - else - {status, new_question} - end - else - params = - question_params - |> Map.put_new(:max_xp, 0) - |> Map.put(:display_order, index) - - if question_params.type != Atom.to_string(question.type) do - {:error, - create_invalid_changeset_with_error( - :question, - "Question types should remain the same" - )} - else - question - |> Question.changeset(params) - |> Repo.update() - end - end - end) - end) - |> Repo.transaction() - end - end - - # Function that checks if the force update is invalid. The force update is only invalid - # if the new question count is different from the old question count. - defp invalid_force_update(assessment_multi, questions_params) do - assessment_id = - (assessment_multi.operations - |> List.first() - |> elem(1) - |> elem(1)).data.id - - if assessment_id do - open_date = Repo.get(Assessment, assessment_id).open_at - # check if assessment is already opened - if Timex.compare(open_date, Timex.now()) >= 0 do - false - else - existing_questions_count = - Question - |> where([q], q.assessment_id == ^assessment_id) - |> Repo.all() - |> Enum.count() - - new_questions_count = Enum.count(questions_params) - existing_questions_count != new_questions_count - end - else - false - end - end - - @spec insert_or_update_assessment_changeset(map(), boolean()) :: Ecto.Changeset.t() - defp insert_or_update_assessment_changeset( - params = %{number: number, course_id: course_id}, - force_update - ) do - Assessment - |> where(number: ^number) - |> where(course_id: ^course_id) - |> Repo.one() - |> case do - nil -> - Assessment.changeset(%Assessment{}, params) - - %{id: assessment_id} = assessment -> - answers_exist = - Answer - |> join(:inner, [a], q in assoc(a, :question)) - |> join(:inner, [a, q], asst in assoc(q, :assessment)) - |> where([a, q, asst], asst.id == ^assessment_id) - |> Repo.exists?() - - # Maintain the same open/close date when updating an assessment - params = - params - |> Map.delete(:open_at) - |> Map.delete(:close_at) - |> Map.delete(:is_published) - - cond do - not answers_exist -> - # Delete all realted submission_votes - SubmissionVotes - |> join(:inner, [sv, q], q in assoc(sv, :question)) - |> where([sv, q], q.assessment_id == ^assessment_id) - |> Repo.delete_all() - - # Delete all existing questions - Question - |> where(assessment_id: ^assessment_id) - |> Repo.delete_all() - - Assessment.changeset(assessment, params) - - force_update -> - Assessment.changeset(assessment, params) - - true -> - # if the assessment has submissions, don't edit - create_invalid_changeset_with_error(:assessment, "has submissions") - end - end - end - - @spec build_question_changeset_for_assessment_id(map(), number() | String.t()) :: - Ecto.Changeset.t() - defp build_question_changeset_for_assessment_id(params, assessment_id) - when is_ecto_id(assessment_id) do - params_with_assessment_id = Map.put_new(params, :assessment_id, assessment_id) - - Question.changeset(%Question{}, params_with_assessment_id) - end - - @doc """ - Generates and assigns contest entries for users with given usernames. - """ - def insert_voting( - course_id, - contest_number, - question_id - ) do - contest_assessment = Repo.get_by(Assessment, number: contest_number, course_id: course_id) - - if is_nil(contest_assessment) do - changeset = change(%Assessment{}, %{number: ""}) - - error_changeset = - Ecto.Changeset.add_error( - changeset, - :number, - "invalid contest number" - ) - - {:error, error_changeset} - else - # Returns contest submission ids with answers that contain "return" - contest_submission_ids = - Submission - |> join(:inner, [s], ans in assoc(s, :answers)) - |> join(:inner, [s, ans], cr in assoc(s, :student)) - |> where([s, ans, cr], cr.role == "student") - |> where([s, _], s.assessment_id == ^contest_assessment.id and s.status == "submitted") - |> where( - [_, ans, cr], - fragment( - "?->>'code' like ?", - ans.answer, - "%return%" - ) - ) - |> select([s, _ans], {s.student_id, s.id}) - |> Repo.all() - |> Enum.into(%{}) - - contest_submission_ids_length = Enum.count(contest_submission_ids) - - voter_ids = - CourseRegistration - |> where(role: "student", course_id: ^course_id) - |> select([cr], cr.id) - |> Repo.all() - - votes_per_user = min(contest_submission_ids_length, 10) - - votes_per_submission = - if Enum.empty?(contest_submission_ids) do - 0 - else - trunc(Float.ceil(votes_per_user * length(voter_ids) / contest_submission_ids_length)) - end - - submission_id_list = - contest_submission_ids - |> Enum.map(fn {_, s_id} -> s_id end) - |> Enum.shuffle() - |> List.duplicate(votes_per_submission) - |> List.flatten() - - {_submission_map, submission_votes_changesets} = - voter_ids - |> Enum.reduce({submission_id_list, []}, fn voter_id, acc -> - {submission_list, submission_votes} = acc - - user_contest_submission_id = Map.get(contest_submission_ids, voter_id) - - {votes, rest} = - submission_list - |> Enum.reduce_while({MapSet.new(), submission_list}, fn s_id, acc -> - {user_votes, submissions} = acc - - max_votes = - if votes_per_user == contest_submission_ids_length and - not is_nil(user_contest_submission_id) do - # no. of submssions is less than 10. Unable to find - votes_per_user - 1 - else - votes_per_user - end - - if MapSet.size(user_votes) < max_votes do - if s_id != user_contest_submission_id and not MapSet.member?(user_votes, s_id) do - new_user_votes = MapSet.put(user_votes, s_id) - new_submissions = List.delete(submissions, s_id) - {:cont, {new_user_votes, new_submissions}} - else - {:cont, {user_votes, submissions}} - end - else - {:halt, acc} - end - end) - - votes = MapSet.to_list(votes) - - new_submission_votes = - votes - |> Enum.map(fn s_id -> - %SubmissionVotes{voter_id: voter_id, submission_id: s_id, question_id: question_id} - end) - |> Enum.concat(submission_votes) - - {rest, new_submission_votes} - end) - - submission_votes_changesets - |> Enum.with_index() - |> Enum.reduce(Multi.new(), fn {changeset, index}, multi -> - Multi.insert(multi, Integer.to_string(index), changeset) - end) - |> Repo.transaction() - end - end - - def update_assessment(id, params) when is_ecto_id(id) do - IO.inspect(params) - simple_update( - Assessment, - id, - using: &Assessment.changeset/2, - params: params - ) - end - - def update_question(id, params) when is_ecto_id(id) do - simple_update( - Question, - id, - using: &Question.changeset/2, - params: params - ) - end - - def publish_assessment(id) when is_ecto_id(id) do - update_assessment(id, %{is_published: true}) - end - - def create_question_for_assessment(params, assessment_id) when is_ecto_id(assessment_id) do - assessment = - Assessment - |> where(id: ^assessment_id) - |> join(:left, [a], q in assoc(a, :questions)) - |> preload([_, q], questions: q) - |> Repo.one() - - if assessment do - params_with_assessment_id = Map.put_new(params, :assessment_id, assessment.id) - - %Question{} - |> Question.changeset(params_with_assessment_id) - |> put_display_order(assessment.questions) - |> Repo.insert() - else - {:error, "Assessment not found"} - end - end - - def get_question(id) when is_ecto_id(id) do - Question - |> where(id: ^id) - |> join(:inner, [q], assessment in assoc(q, :assessment)) - |> preload([_, a], assessment: a) - |> Repo.one() - end - - def delete_question(id) when is_ecto_id(id) do - question = Repo.get(Question, id) - Repo.delete(question) - end - - @doc """ - Public internal api to submit new answers for a question. Possible return values are: - `{:ok, nil}` -> success - `{:error, error}` -> failed. `error` is in the format of `{http_response_code, error message}` - - Note: In the event of `find_or_create_submission` failing due to a race condition, error will be: - `{:bad_request, "Missing or invalid parameter(s)"}` - - """ - def answer_question( - question = %Question{}, - cr = %CourseRegistration{id: cr_id}, - raw_answer, - force_submit - ) do - with {:ok, submission} <- find_or_create_submission(cr, question.assessment), - {:status, true} <- {:status, force_submit or submission.status != :submitted}, - {:ok, _answer} <- insert_or_update_answer(submission, question, raw_answer, cr_id) do - update_submission_status_router(submission, question) - - {:ok, nil} - else - {:status, _} -> - {:error, {:forbidden, "Assessment submission already finalised"}} - - {:error, :race_condition} -> - {:error, {:internal_server_error, "Please try again later."}} - - {:error, :invalid_vote} -> - {:error, {:bad_request, "Invalid vote! Vote is not saved."}} - - _ -> - {:error, {:bad_request, "Missing or invalid parameter(s)"}} - end - end - - def get_submission(assessment_id, %CourseRegistration{id: cr_id}) - when is_ecto_id(assessment_id) do - Submission - |> where(assessment_id: ^assessment_id) - |> where(student_id: ^cr_id) - |> join(:inner, [s], a in assoc(s, :assessment)) - |> preload([_, a], assessment: a) - |> Repo.one() - end - - def get_submission_by_id(submission_id) when is_ecto_id(submission_id) do - Submission - |> where(id: ^submission_id) - |> join(:inner, [s], a in assoc(s, :assessment)) - |> preload([_, a], assessment: a) - |> Repo.one() - end - - def finalise_submission(submission = %Submission{}) do - with {:status, :attempted} <- {:status, submission.status}, - {:ok, updated_submission} <- update_submission_status_and_xp_bonus(submission) do - # Couple with update_submission_status_and_xp_bonus to ensure notification is sent - Notifications.write_notification_when_student_submits(submission) - # Send email notification to avenger - %{notification_type: "assessment_submission", submission_id: updated_submission.id} - |> Cadet.Workers.NotificationWorker.new() - |> Oban.insert() - - # Begin autograding job - GradingJob.force_grade_individual_submission(updated_submission) - - {:ok, nil} - else - {:status, :attempting} -> - {:error, {:bad_request, "Some questions have not been attempted"}} - - {:status, :submitted} -> - {:error, {:forbidden, "Assessment has already been submitted"}} - - _ -> - {:error, {:internal_server_error, "Please try again later."}} - end - end - - def unsubmit_submission( - submission_id, - cr = %CourseRegistration{id: course_reg_id, role: role} - ) - when is_ecto_id(submission_id) do - submission = - Submission - |> join(:inner, [s], a in assoc(s, :assessment)) - |> preload([_, a], assessment: a) - |> Repo.get(submission_id) - - # allows staff to unsubmit own assessment - bypass = role in @bypass_closed_roles and submission.student_id == course_reg_id - - with {:submission_found?, true} <- {:submission_found?, is_map(submission)}, - {:is_open?, true} <- {:is_open?, bypass or is_open?(submission.assessment)}, - {:status, :submitted} <- {:status, submission.status}, - {:allowed_to_unsubmit?, true} <- - {:allowed_to_unsubmit?, - role == :admin or bypass or - Cadet.Accounts.Query.avenger_of?(cr, submission.student_id)} do - Multi.new() - |> Multi.run( - :rollback_submission, - fn _repo, _ -> - submission - |> Submission.changeset(%{ - status: :attempted, - xp_bonus: 0, - unsubmitted_by_id: course_reg_id, - unsubmitted_at: Timex.now() - }) - |> Repo.update() - end - ) - |> Multi.run(:rollback_answers, fn _repo, _ -> - Answer - |> join(:inner, [a], q in assoc(a, :question)) - |> join(:inner, [a, _], s in assoc(a, :submission)) - |> preload([_, q, s], question: q, submission: s) - |> where(submission_id: ^submission.id) - |> Repo.all() - |> Enum.reduce_while({:ok, nil}, fn answer, acc -> - case acc do - {:error, _} -> - {:halt, acc} - - {:ok, _} -> - {:cont, - answer - |> Answer.grading_changeset(%{ - xp: 0, - xp_adjustment: 0, - autograding_status: :none, - autograding_results: [] - }) - |> Repo.update()} - end - end) - end) - |> Repo.transaction() - - Cadet.Accounts.Notifications.handle_unsubmit_notifications( - submission.assessment.id, - Repo.get(CourseRegistration, submission.student_id) - ) - - {:ok, nil} - else - {:submission_found?, false} -> - {:error, {:not_found, "Submission not found"}} - - {:is_open?, false} -> - {:error, {:forbidden, "Assessment not open"}} - - {:status, :attempting} -> - {:error, {:bad_request, "Some questions have not been attempted"}} - - {:status, :attempted} -> - {:error, {:bad_request, "Assessment has not been submitted"}} - - {:allowed_to_unsubmit?, false} -> - {:error, {:forbidden, "Only Avenger of student or Admin is permitted to unsubmit"}} - - _ -> - {:error, {:internal_server_error, "Please try again later."}} - end - end - - @spec update_submission_status_and_xp_bonus(Submission.t()) :: - {:ok, Submission.t()} | {:error, Ecto.Changeset.t()} - defp update_submission_status_and_xp_bonus(submission = %Submission{}) do - assessment = submission.assessment - assessment_conifg = Repo.get_by(AssessmentConfig, id: assessment.config_id) - - max_bonus_xp = assessment_conifg.early_submission_xp - early_hours = assessment_conifg.hours_before_early_xp_decay - - xp_bonus = - if Timex.before?(Timex.now(), Timex.shift(assessment.open_at, hours: early_hours)) do - max_bonus_xp - else - # This logic interpolates from max bonus at early hour to 0 bonus at close time - decaying_hours = Timex.diff(assessment.close_at, assessment.open_at, :hours) - early_hours - remaining_hours = Enum.max([0, Timex.diff(assessment.close_at, Timex.now(), :hours)]) - proportion = if(decaying_hours > 0, do: remaining_hours / decaying_hours, else: 1) - bonus_xp = round(max_bonus_xp * proportion) - Enum.max([0, bonus_xp]) - end - - submission - |> Submission.changeset(%{status: :submitted, xp_bonus: xp_bonus}) - |> Repo.update() - end - - defp update_submission_status_router(submission = %Submission{}, question = %Question{}) do - case question.type do - :voting -> update_contest_voting_submission_status(submission, question) - :mcq -> update_submission_status(submission, question.assessment) - :programming -> update_submission_status(submission, question.assessment) - end - end - - defp update_submission_status(submission = %Submission{}, assessment = %Assessment{}) do - model_assoc_count = fn model, assoc, id -> - model - |> where(id: ^id) - |> join(:inner, [m], a in assoc(m, ^assoc)) - |> select([_, a], count(a.id)) - |> Repo.one() - end - - Multi.new() - |> Multi.run(:assessment, fn _repo, _ -> - {:ok, model_assoc_count.(Assessment, :questions, assessment.id)} - end) - |> Multi.run(:submission, fn _repo, _ -> - {:ok, model_assoc_count.(Submission, :answers, submission.id)} - end) - |> Multi.run(:update, fn _repo, %{submission: s_count, assessment: a_count} -> - if s_count == a_count do - submission |> Submission.changeset(%{status: :attempted}) |> Repo.update() - else - {:ok, nil} - end - end) - |> Repo.transaction() - end - - defp update_contest_voting_submission_status(submission = %Submission{}, question = %Question{}) do - has_nil_entries = - SubmissionVotes - |> where(question_id: ^question.id) - |> where(voter_id: ^submission.student_id) - |> where([sv], is_nil(sv.score)) - |> Repo.exists?() - - unless has_nil_entries do - submission |> Submission.changeset(%{status: :attempted}) |> Repo.update() - end - end - - defp load_contest_voting_entries( - questions, - %CourseRegistration{role: role, course_id: course_id, id: voter_id}, - assessment - ) do - Enum.map( - questions, - fn q -> - if q.type == :voting do - submission_votes = all_submission_votes_by_question_id_and_voter_id(q.id, voter_id) - # fetch top 10 contest voting entries with the contest question id - question_id = fetch_associated_contest_question_id(course_id, q) - - leaderboard_results = - if is_nil(question_id) do - [] - else - if leaderboard_open?(assessment, q) or role in @open_all_assessment_roles do - fetch_top_relative_score_answers(question_id, 10) - else - [] - end - end - - # populate entries to vote for and leaderboard data into the question - voting_question = - q.question - |> Map.put(:contest_entries, submission_votes) - |> Map.put( - :contest_leaderboard, - leaderboard_results - ) - - Map.put(q, :question, voting_question) - else - q - end - end - ) - end - - defp all_submission_votes_by_question_id_and_voter_id(question_id, voter_id) do - SubmissionVotes - |> where([v], v.voter_id == ^voter_id and v.question_id == ^question_id) - |> join(:inner, [v], s in assoc(v, :submission)) - |> join(:inner, [v, s], a in assoc(s, :answers)) - |> select([v, s, a], %{submission_id: v.submission_id, answer: a.answer, score: v.score}) - |> Repo.all() - end - - # Finds the contest_question_id associated with the given voting_question id - defp fetch_associated_contest_question_id(course_id, voting_question) do - contest_number = voting_question.question["contest_number"] - - if is_nil(contest_number) do - nil - else - Assessment - |> where(number: ^contest_number, course_id: ^course_id) - |> join(:inner, [a], q in assoc(a, :questions)) - |> order_by([a, q], q.display_order) - |> select([a, q], q.id) - |> Repo.one() - end - end - - defp leaderboard_open?(assessment, voting_question) do - Timex.before?( - Timex.shift(assessment.close_at, hours: voting_question.question["reveal_hours"]), - Timex.now() - ) - end - - @doc """ - Fetches top answers for the given question, based on the contest relative_score - - Used for contest leaderboard fetching - """ - def fetch_top_relative_score_answers(question_id, number_of_answers) do - Answer - |> where(question_id: ^question_id) - |> where( - [a], - fragment( - "?->>'code' like ?", - a.answer, - "%return%" - ) - ) - |> order_by(desc: :relative_score) - |> join(:left, [a], s in assoc(a, :submission)) - |> join(:left, [a, s], student in assoc(s, :student)) - |> join(:inner, [a, s, student], student_user in assoc(student, :user)) - |> where([a, s, student], student.role == "student") - |> select([a, s, student, student_user], %{ - submission_id: a.submission_id, - answer: a.answer, - relative_score: a.relative_score, - student_name: student_user.name - }) - |> limit(^number_of_answers) - |> Repo.all() - end - - @doc """ - Computes rolling leaderboard for contest votes that are still open. - """ - def update_rolling_contest_leaderboards do - # 115 = 2 hours - 5 minutes is default. - if Log.log_execution("update_rolling_contest_leaderboards", Timex.Duration.from_minutes(115)) do - Logger.info("Started update_rolling_contest_leaderboards") - - voting_questions_to_update = fetch_active_voting_questions() - - _ = - voting_questions_to_update - |> Enum.map(fn qn -> compute_relative_score(qn.id) end) - - Logger.info("Successfully update_rolling_contest_leaderboards") - end - end - - def fetch_active_voting_questions do - Question - |> join(:left, [q], a in assoc(q, :assessment)) - |> where([q, a], q.type == "voting") - |> where([q, a], a.is_published) - |> where([q, a], a.open_at <= ^Timex.now() and a.close_at >= ^Timex.now()) - |> Repo.all() - end - - @doc """ - Computes final leaderboard for contest votes that have closed. - """ - def update_final_contest_leaderboards do - # 1435 = 24 hours - 5 minutes - if Log.log_execution("update_final_contest_leaderboards", Timex.Duration.from_minutes(1435)) do - Logger.info("Started update_final_contest_leaderboards") - - voting_questions_to_update = fetch_voting_questions_due_yesterday() - - _ = - voting_questions_to_update - |> Enum.map(fn qn -> compute_relative_score(qn.id) end) - - Logger.info("Successfully update_final_contest_leaderboards") - end - end - - def fetch_voting_questions_due_yesterday do - Question - |> join(:left, [q], a in assoc(q, :assessment)) - |> where([q, a], q.type == "voting") - |> where([q, a], a.is_published) - |> where([q, a], a.open_at <= ^Timex.now()) - |> where( - [q, a], - a.close_at < ^Timex.now() and a.close_at >= ^Timex.shift(Timex.now(), days: -1) - ) - |> Repo.all() - end - - @doc """ - Computes the current relative_score of each voting submission answer - based on current submitted votes. - """ - def compute_relative_score(contest_voting_question_id) do - # query all records from submission votes tied to the question id -> - # map score to user id -> - # store as grade -> - # query grade for contest question id. - eligible_votes = - SubmissionVotes - |> where(question_id: ^contest_voting_question_id) - |> where([sv], not is_nil(sv.score)) - |> join(:inner, [sv], ans in Answer, on: sv.submission_id == ans.submission_id) - |> select( - [sv, ans], - %{ans_id: ans.id, score: sv.score, ans: ans.answer["code"]} - ) - |> Repo.all() - - entry_scores = map_eligible_votes_to_entry_score(eligible_votes) - - entry_scores - |> Enum.map(fn {ans_id, relative_score} -> - %Answer{id: ans_id} - |> Answer.contest_score_update_changeset(%{ - relative_score: relative_score - }) - end) - |> Enum.map(fn changeset -> - op_key = "answer_#{changeset.data.id}" - Multi.update(Multi.new(), op_key, changeset) - end) - |> Enum.reduce(Multi.new(), &Multi.append/2) - |> Repo.transaction() - end - - defp map_eligible_votes_to_entry_score(eligible_votes) do - # converts eligible votes to the {total cumulative score, number of votes, tokens} - entry_vote_data = - Enum.reduce(eligible_votes, %{}, fn %{ans_id: ans_id, score: score, ans: ans}, tracker -> - {prev_score, prev_count, _ans_tokens} = Map.get(tracker, ans_id, {0, 0, 0}) - - Map.put( - tracker, - ans_id, - # assume each voter is assigned 10 entries which will make it fair. - {prev_score + score, prev_count + 1, Lexer.count_tokens(ans)} - ) - end) - - # calculate the score based on formula {ans_id, score} - Enum.map( - entry_vote_data, - fn {ans_id, {sum_of_scores, number_of_voters, tokens}} -> - {ans_id, calculate_formula_score(sum_of_scores, number_of_voters, tokens)} - end - ) - end - - # Calculate the score based on formula - # score(v,t) = v - 2^(t/50) where v is the normalized_voting_score - # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 - defp calculate_formula_score(sum_of_scores, number_of_voters, tokens) do - normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 - normalized_voting_score - :math.pow(2, min(1023.5, tokens / 50)) - end - - @doc """ - Function returning submissions under a grader. This function returns only the - fields that are exposed in the /grading endpoint. The reason we select only - those fields is to reduce the memory usage especially when the number of - submissions is large i.e. > 25000 submissions. - - The input parameters are the user and group_only. group_only is used to check - whether only the groups under the grader should be returned. The parameter is - a boolean which is false by default. - - The return value is {:ok, submissions} if no errors, else it is {:error, - {:unauthorized, "Forbidden."}} - """ - @spec all_submissions_by_grader_for_index(CourseRegistration.t()) :: - {:ok, String.t()} - def all_submissions_by_grader_for_index( - grader = %CourseRegistration{course_id: course_id}, - group_only \\ false, - ungraded_only \\ false - ) do - show_all = not group_only - - group_where = - if show_all, - do: "", - else: - "where s.student_id in (select cr.id from course_registrations cr inner join groups g on cr.group_id = g.id where g.leader_id = $2) or s.student_id = $2" - - ungraded_where = - if ungraded_only, - do: "where s.\"gradedCount\" < assts.\"questionCount\"", - else: "" - - params = if show_all, do: [course_id], else: [course_id, grader.id] - - # We bypass Ecto here and use a raw query to generate JSON directly from - # PostgreSQL, because doing it in Elixir/Erlang is too inefficient. - - case Repo.query( - """ - select json_agg(q)::TEXT from - ( - select - s.id, - s.status, - s."unsubmittedAt", - s.xp, - s."xpAdjustment", - s."xpBonus", - s."gradedCount", - assts.jsn as assessment, - students.jsn as student, - unsubmitters.jsn as "unsubmittedBy" - from - (select - s.id, - s.student_id, - s.assessment_id, - s.status, - s.unsubmitted_at as "unsubmittedAt", - s.unsubmitted_by_id, - sum(ans.xp) as xp, - sum(ans.xp_adjustment) as "xpAdjustment", - s.xp_bonus as "xpBonus", - count(ans.id) filter (where ans.grader_id is not null) as "gradedCount" - from submissions s - left join - answers ans on s.id = ans.submission_id - #{group_where} - group by s.id) s - inner join - (select - a.id, a."questionCount", to_json(a) as jsn - from - (select - a.id, - a.title, - bool_or(ac.is_manually_graded) as "isManuallyGraded", - max(ac.type) as "type", - sum(q.max_xp) as "maxXp", - count(q.id) as "questionCount" - from assessments a - left join - questions q on a.id = q.assessment_id - inner join - assessment_configs ac on ac.id = a.config_id - where a.course_id = $1 - group by a.id) a) assts on assts.id = s.assessment_id - inner join - (select - cr.id, to_json(cr) as jsn - from - (select - cr.id, - u.name as "name", - g.name as "groupName", - g.leader_id as "groupLeaderId" - from course_registrations cr - left join - groups g on g.id = cr.group_id - inner join - users u on u.id = cr.user_id) cr) students on students.id = s.student_id - left join - (select - cr.id, to_json(cr) as jsn - from - (select - cr.id, - u.name - from course_registrations cr - inner join - users u on u.id = cr.user_id) cr) unsubmitters on s.unsubmitted_by_id = unsubmitters.id - #{ungraded_where} - ) q - """, - params - ) do - {:ok, %{rows: [[nil]]}} -> {:ok, "[]"} - {:ok, %{rows: [[json]]}} -> {:ok, json} - end - end - - @spec get_answers_in_submission(integer() | String.t()) :: - {:ok, [Answer.t()]} | {:error, {:bad_request | :unauthorized, String.t()}} - def get_answers_in_submission(id) when is_ecto_id(id) do - answer_query = - Answer - |> where(submission_id: ^id) - |> join(:inner, [a], q in assoc(a, :question)) - |> join(:inner, [_, q], ast in assoc(q, :assessment)) - |> join(:inner, [a, ..., ast], ac in assoc(ast, :config)) - |> join(:left, [a, ...], g in assoc(a, :grader)) - |> join(:left, [a, ..., g], gu in assoc(g, :user)) - |> join(:inner, [a, ...], s in assoc(a, :submission)) - |> join(:inner, [a, ..., s], st in assoc(s, :student)) - |> join(:inner, [a, ..., st], u in assoc(st, :user)) - |> preload([_, q, ast, ac, g, gu, s, st, u], - question: {q, assessment: {ast, config: ac}}, - grader: {g, user: gu}, - submission: {s, student: {st, user: u}} - ) - - answers = - answer_query - |> Repo.all() - |> Enum.sort_by(& &1.question.display_order) - |> Enum.map(fn ans -> - if ans.question.type == :voting do - empty_contest_entries = Map.put(ans.question.question, :contest_entries, []) - empty_contest_leaderboard = Map.put(empty_contest_entries, :contest_leaderboard, []) - question = Map.put(ans.question, :question, empty_contest_leaderboard) - Map.put(ans, :question, question) - else - ans - end - end) - - if answers == [] do - {:error, {:bad_request, "Submission is not found."}} - else - {:ok, answers} - end - end - - defp is_fully_graded?(%Answer{submission_id: submission_id}) do - submission = - Submission - |> Repo.get_by(id: submission_id) - - question_count = - Question - |> where(assessment_id: ^submission.assessment_id) - |> select([q], count(q.id)) - |> Repo.one() - - graded_count = - Answer - |> where([a], submission_id: ^submission_id) - |> where([a], not is_nil(a.grader_id)) - |> select([a], count(a.id)) - |> Repo.one() - - question_count == graded_count - end - - @spec update_grading_info( - %{submission_id: integer() | String.t(), question_id: integer() | String.t()}, - %{}, - CourseRegistration.t() - ) :: - {:ok, nil} - | {:error, {:unauthorized | :bad_request | :internal_server_error, String.t()}} - def update_grading_info( - %{submission_id: submission_id, question_id: question_id}, - attrs, - %CourseRegistration{id: grader_id} - ) - when is_ecto_id(submission_id) and is_ecto_id(question_id) do - attrs = Map.put(attrs, "grader_id", grader_id) - - answer_query = - Answer - |> where(submission_id: ^submission_id) - |> where(question_id: ^question_id) - - answer_query = - answer_query - |> join(:inner, [a], s in assoc(a, :submission)) - |> preload([_, s], submission: s) - - answer = Repo.one(answer_query) - - is_own_submission = grader_id == answer.submission.student_id - - with {:answer_found?, true} <- {:answer_found?, is_map(answer)}, - {:status, true} <- - {:status, answer.submission.status == :submitted or is_own_submission}, - {:valid, changeset = %Ecto.Changeset{valid?: true}} <- - {:valid, Answer.grading_changeset(answer, attrs)}, - {:ok, _} <- Repo.update(changeset) do - if is_fully_graded?(answer) and not is_own_submission do - # Every answer in this submission has been graded manually - Notifications.write_notification_when_graded(submission_id, :graded) - else - {:ok, nil} - end - else - {:answer_found?, false} -> - {:error, {:bad_request, "Answer not found or user not permitted to grade."}} - - {:valid, changeset} -> - {:error, {:bad_request, full_error_messages(changeset)}} - - {:status, _} -> - {:error, {:method_not_allowed, "Submission is not submitted yet."}} - - {:error, _} -> - {:error, {:internal_server_error, "Please try again later."}} - end - end - - def update_grading_info( - _, - _, - _ - ) do - {:error, {:unauthorized, "User is not permitted to grade."}} - end - - @spec force_regrade_submission(integer() | String.t(), CourseRegistration.t()) :: - {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}} - def force_regrade_submission( - submission_id, - _requesting_user = %CourseRegistration{id: grader_id} - ) - when is_ecto_id(submission_id) do - with {:get, sub} when not is_nil(sub) <- {:get, Repo.get(Submission, submission_id)}, - {:status, true} <- {:status, sub.student_id == grader_id or sub.status == :submitted} do - GradingJob.force_grade_individual_submission(sub, true) - {:ok, nil} - else - {:get, nil} -> - {:error, {:not_found, "Submission not found"}} - - {:status, false} -> - {:error, {:bad_request, "Submission not submitted yet"}} - end - end - - def force_regrade_submission(_, _) do - {:error, {:forbidden, "User is not permitted to grade."}} - end - - @spec force_regrade_answer( - integer() | String.t(), - integer() | String.t(), - CourseRegistration.t() - ) :: - {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}} - def force_regrade_answer( - submission_id, - question_id, - _requesting_user = %CourseRegistration{id: grader_id} - ) - when is_ecto_id(submission_id) and is_ecto_id(question_id) do - answer = - Answer - |> where(submission_id: ^submission_id, question_id: ^question_id) - |> preload([:question, :submission]) - |> Repo.one() - - with {:get, answer} when not is_nil(answer) <- {:get, answer}, - {:status, true} <- - {:status, - answer.submission.student_id == grader_id or answer.submission.status == :submitted} do - GradingJob.grade_answer(answer, answer.question, true) - {:ok, nil} - else - {:get, nil} -> - {:error, {:not_found, "Answer not found"}} - - {:status, false} -> - {:error, {:bad_request, "Submission not submitted yet"}} - end - end - - def force_regrade_answer(_, _, _) do - {:error, {:forbidden, "User is not permitted to grade."}} - end - - defp find_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do - submission = - Submission - |> where(student_id: ^cr.id) - |> where(assessment_id: ^assessment.id) - |> Repo.one() - - if submission do - {:ok, submission} - else - {:error, nil} - end - end - - # Checks if an assessment is open and published. - @spec is_open?(Assessment.t()) :: boolean() - def is_open?(%Assessment{open_at: open_at, close_at: close_at, is_published: is_published}) do - Timex.between?(Timex.now(), open_at, close_at, inclusive: :start) and is_published - end - - @spec get_group_grading_summary(integer()) :: - {:ok, [String.t(), ...], []} - def get_group_grading_summary(course_id) do - subs = - Answer - |> join(:left, [ans], s in Submission, on: s.id == ans.submission_id) - |> join(:left, [ans, s], st in CourseRegistration, on: s.student_id == st.id) - |> join(:left, [ans, s, st], a in Assessment, on: a.id == s.assessment_id) - |> join(:inner, [ans, s, st, a], ac in AssessmentConfig, on: ac.id == a.config_id) - |> where( - [ans, s, st, a, ac], - not is_nil(st.group_id) and s.status == ^:submitted and - ac.show_grading_summary and a.course_id == ^course_id - ) - |> group_by([ans, s, st, a, ac], s.id) - |> select([ans, s, st, a, ac], %{ - group_id: max(st.group_id), - config_id: max(ac.id), - config_type: max(ac.type), - num_submitted: count(), - num_ungraded: filter(count(), is_nil(ans.grader_id)) - }) - - raw_data = - subs - |> subquery() - |> join(:left, [t], g in Group, on: t.group_id == g.id) - |> join(:left, [t, g], l in CourseRegistration, on: l.id == g.leader_id) - |> join(:left, [t, g, l], lu in User, on: lu.id == l.user_id) - |> group_by([t, g, l, lu], [t.group_id, t.config_id, t.config_type, g.name, lu.name]) - |> select([t, g, l, lu], %{ - group_name: g.name, - leader_name: lu.name, - config_id: t.config_id, - config_type: t.config_type, - ungraded: filter(count(), t.num_ungraded > 0), - submitted: count() - }) - |> Repo.all() - - showing_configs = - AssessmentConfig - |> where([ac], ac.course_id == ^course_id and ac.show_grading_summary) - |> order_by(:order) - |> group_by([ac], ac.id) - |> select([ac], %{ - id: ac.id, - type: ac.type - }) - |> Repo.all() - - data_by_groups = - raw_data - |> Enum.reduce(%{}, fn raw, acc -> - if Map.has_key?(acc, raw.group_name) do - acc - |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded) - |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted) - else - acc - |> put_in([raw.group_name], %{}) - |> put_in([raw.group_name, "groupName"], raw.group_name) - |> put_in([raw.group_name, "leaderName"], raw.leader_name) - |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded) - |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted) - end - end) - - headings = - showing_configs - |> Enum.reduce([], fn config, acc -> - acc ++ ["submitted" <> config.type, "ungraded" <> config.type] - end) - - default_row_data = - headings - |> Enum.reduce(%{}, fn heading, acc -> - put_in(acc, [heading], 0) - end) - - rows = data_by_groups |> Enum.map(fn {_k, row} -> Map.merge(default_row_data, row) end) - cols = ["groupName", "leaderName"] ++ headings - - {:ok, cols, rows} - end - - defp create_empty_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do - %Submission{} - |> Submission.changeset(%{student: cr, assessment: assessment}) - |> Repo.insert() - |> case do - {:ok, submission} -> {:ok, submission} - {:error, _} -> {:error, :race_condition} - end - end - - defp find_or_create_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do - case find_submission(cr, assessment) do - {:ok, submission} -> {:ok, submission} - {:error, _} -> create_empty_submission(cr, assessment) - end - end - - defp insert_or_update_answer( - submission = %Submission{}, - question = %Question{}, - raw_answer, - course_reg_id - ) do - answer_content = build_answer_content(raw_answer, question.type) - - if question.type == :voting do - insert_or_update_voting_answer(submission.id, course_reg_id, question.id, answer_content) - else - answer_changeset = - %Answer{} - |> Answer.changeset(%{ - answer: answer_content, - question_id: question.id, - submission_id: submission.id, - type: question.type - }) - - Repo.insert( - answer_changeset, - on_conflict: [set: [answer: get_change(answer_changeset, :answer)]], - conflict_target: [:submission_id, :question_id] - ) - end - end - - def insert_or_update_voting_answer(submission_id, course_reg_id, question_id, answer_content) do - set_score_to_nil = - SubmissionVotes - |> where(voter_id: ^course_reg_id, question_id: ^question_id) - - voting_multi = - Multi.new() - |> Multi.update_all(:set_score_to_nil, set_score_to_nil, set: [score: nil]) - - answer_content - |> Enum.with_index(1) - |> Enum.reduce(voting_multi, fn {entry, index}, multi -> - multi - |> Multi.run("update#{index}", fn _repo, _ -> - SubmissionVotes - |> Repo.get_by( - voter_id: course_reg_id, - submission_id: entry.submission_id - ) - |> SubmissionVotes.changeset(%{score: entry.score}) - |> Repo.insert_or_update() - end) - end) - |> Multi.run("insert into answer table", fn _repo, _ -> - Answer - |> Repo.get_by(submission_id: submission_id, question_id: question_id) - |> case do - nil -> - Repo.insert(%Answer{ - answer: %{completed: true}, - submission_id: submission_id, - question_id: question_id, - type: :voting - }) - - _ -> - {:ok, nil} - end - end) - |> Repo.transaction() - |> case do - {:ok, _result} -> {:ok, nil} - {:error, _name, _changeset, _error} -> {:error, :invalid_vote} - end - end - - defp build_answer_content(raw_answer, question_type) do - case question_type do - :mcq -> - %{choice_id: raw_answer} - - :programming -> - %{code: raw_answer} - - :voting -> - raw_answer - |> Enum.map(fn ans -> - for {key, value} <- ans, into: %{}, do: {String.to_existing_atom(key), value} - end) - end - end -end +defmodule Cadet.Assessments do + @moduledoc """ + Assessments context contains domain logic for assessments management such as + missions, sidequests, paths, etc. + """ + use Cadet, [:context, :display] + import Ecto.Query + + require Logger + + alias Cadet.Accounts.{ + Notification, + Notifications, + User, + CourseRegistration, + CourseRegistrations + } + + alias Cadet.Assessments.{Answer, Assessment, Query, Question, Submission, SubmissionVotes} + alias Cadet.Autograder.GradingJob + alias Cadet.Courses.{Group, AssessmentConfig} + alias Cadet.Jobs.Log + alias Cadet.ProgramAnalysis.Lexer + alias Ecto.Multi + alias Cadet.Incentives.Achievements + + require Decimal + + @open_all_assessment_roles ~w(staff admin)a + + # These roles can save and finalise answers for closed assessments and + # submitted answers + @bypass_closed_roles ~w(staff admin)a + + def delete_assessment(id) do + assessment = Repo.get(Assessment, id) + + Submission + |> where(assessment_id: ^id) + |> delete_submission_assocation(id) + + Question + |> where(assessment_id: ^id) + |> Repo.all() + |> Enum.each(fn q -> + delete_submission_votes_association(q) + end) + + Repo.delete(assessment) + end + + defp delete_submission_votes_association(question) do + SubmissionVotes + |> where(question_id: ^question.id) + |> Repo.delete_all() + end + + defp delete_submission_assocation(submissions, assessment_id) do + submissions + |> Repo.all() + |> Enum.each(fn submission -> + Answer + |> where(submission_id: ^submission.id) + |> Repo.delete_all() + end) + + Notification + |> where(assessment_id: ^assessment_id) + |> Repo.delete_all() + + Repo.delete_all(submissions) + end + + @spec user_max_xp(CourseRegistration.t()) :: integer() + def user_max_xp(%CourseRegistration{id: cr_id}) do + Submission + |> where(status: ^:submitted) + |> where(student_id: ^cr_id) + |> join( + :inner, + [s], + a in subquery(Query.all_assessments_with_max_xp()), + on: s.assessment_id == a.id + ) + |> select([_, a], sum(a.max_xp)) + |> Repo.one() + |> decimal_to_integer() + end + + def assessments_total_xp(%CourseRegistration{id: cr_id}) do + submission_xp = + Submission + |> where(student_id: ^cr_id) + |> join(:inner, [s], a in Answer, on: s.id == a.submission_id) + |> group_by([s], s.id) + |> select([s, a], %{ + # grouping by submission, so s.xp_bonus will be the same, but we need an + # aggregate function + total_xp: sum(a.xp) + sum(a.xp_adjustment) + max(s.xp_bonus) + }) + + total = + submission_xp + |> subquery + |> select([s], %{ + total_xp: sum(s.total_xp) + }) + |> Repo.one() + + # for {key, val} <- total, into: %{}, do: {key, decimal_to_integer(val)} + decimal_to_integer(total.total_xp) + end + + def user_total_xp(course_id, user_id, course_reg_id) do + user_course = CourseRegistrations.get_user_course(user_id, course_id) + + total_achievement_xp = Achievements.achievements_total_xp(course_id, course_reg_id) + total_assessment_xp = assessments_total_xp(user_course) + + total_achievement_xp + total_assessment_xp + end + + defp decimal_to_integer(decimal) do + if Decimal.is_decimal(decimal) do + Decimal.to_integer(decimal) + else + 0 + end + end + + def user_current_story(cr = %CourseRegistration{}) do + {:ok, %{result: story}} = + Multi.new() + |> Multi.run(:unattempted, fn _repo, _ -> + {:ok, get_user_story_by_type(cr, :unattempted)} + end) + |> Multi.run(:result, fn _repo, %{unattempted: unattempted_story} -> + if unattempted_story do + {:ok, %{play_story?: true, story: unattempted_story}} + else + {:ok, %{play_story?: false, story: get_user_story_by_type(cr, :attempted)}} + end + end) + |> Repo.transaction() + + story + end + + @spec get_user_story_by_type(CourseRegistration.t(), :unattempted | :attempted) :: + String.t() | nil + def get_user_story_by_type(%CourseRegistration{id: cr_id}, type) + when is_atom(type) do + filter_and_sort = fn query -> + case type do + :unattempted -> + query + |> where([_, s], is_nil(s.id)) + |> order_by([a], asc: a.open_at) + + :attempted -> + query |> order_by([a], desc: a.close_at) + end + end + + Assessment + |> where(is_published: true) + |> where([a], not is_nil(a.story)) + |> where([a], a.open_at <= from_now(0, "second") and a.close_at >= from_now(0, "second")) + |> join(:left, [a], s in Submission, on: s.assessment_id == a.id and s.student_id == ^cr_id) + |> filter_and_sort.() + |> order_by([a], a.config_id) + |> select([a], a.story) + |> first() + |> Repo.one() + end + + def assessment_with_questions_and_answers( + assessment = %Assessment{password: nil}, + cr = %CourseRegistration{}, + nil + ) do + assessment_with_questions_and_answers(assessment, cr) + end + + def assessment_with_questions_and_answers( + assessment = %Assessment{password: nil}, + cr = %CourseRegistration{}, + _ + ) do + assessment_with_questions_and_answers(assessment, cr) + end + + def assessment_with_questions_and_answers( + assessment = %Assessment{password: password}, + cr = %CourseRegistration{}, + given_password + ) do + cond do + Timex.compare(Timex.now(), assessment.close_at) >= 0 -> + assessment_with_questions_and_answers(assessment, cr) + + match?({:ok, _}, find_submission(cr, assessment)) -> + assessment_with_questions_and_answers(assessment, cr) + + given_password == nil -> + {:error, {:forbidden, "Missing Password."}} + + password == given_password -> + find_or_create_submission(cr, assessment) + assessment_with_questions_and_answers(assessment, cr) + + true -> + {:error, {:forbidden, "Invalid Password."}} + end + end + + def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}, password) + when is_ecto_id(id) do + role = cr.role + + assessment = + if role in @open_all_assessment_roles do + Assessment + |> where(id: ^id) + |> preload(:config) + |> Repo.one() + else + Assessment + |> where(id: ^id) + |> where(is_published: true) + |> preload(:config) + |> Repo.one() + end + + if assessment do + assessment_with_questions_and_answers(assessment, cr, password) + else + {:error, {:bad_request, "Assessment not found"}} + end + end + + def assessment_with_questions_and_answers( + assessment = %Assessment{id: id}, + course_reg = %CourseRegistration{role: role} + ) do + if Timex.compare(Timex.now(), assessment.open_at) >= 0 or role in @open_all_assessment_roles do + answer_query = + Answer + |> join(:inner, [a], s in assoc(a, :submission)) + |> where([_, s], s.student_id == ^course_reg.id) + + questions = + Question + |> where(assessment_id: ^id) + |> join(:left, [q], a in subquery(answer_query), on: q.id == a.question_id) + |> join(:left, [_, a], g in assoc(a, :grader)) + |> join(:left, [_, _, g], u in assoc(g, :user)) + |> select([q, a, g, u], {q, a, g, u}) + |> order_by(:display_order) + |> Repo.all() + |> Enum.map(fn + {q, nil, _, _} -> %{q | answer: %Answer{grader: nil}} + {q, a, nil, _} -> %{q | answer: %Answer{a | grader: nil}} + {q, a, g, u} -> %{q | answer: %Answer{a | grader: %CourseRegistration{g | user: u}}} + end) + |> load_contest_voting_entries(course_reg, assessment) + + assessment = assessment |> Map.put(:questions, questions) + {:ok, assessment} + else + {:error, {:unauthorized, "Assessment not open"}} + end + end + + def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}) do + assessment_with_questions_and_answers(id, cr, nil) + end + + @doc """ + Returns a list of assessments with all fields and an indicator showing whether it has been attempted + by the supplied user + """ + def all_assessments(cr = %CourseRegistration{}) do + submission_aggregates = + Submission + |> join(:left, [s], ans in Answer, on: ans.submission_id == s.id) + |> where([s], s.student_id == ^cr.id) + |> group_by([s], s.assessment_id) + |> select([s, ans], %{ + assessment_id: s.assessment_id, + # s.xp_bonus should be the same across the group, but we need an aggregate function here + xp: fragment("? + ? + ?", sum(ans.xp), sum(ans.xp_adjustment), max(s.xp_bonus)), + graded_count: ans.id |> count() |> filter(not is_nil(ans.grader_id)) + }) + + submission_status = + Submission + |> where([s], s.student_id == ^cr.id) + |> select([s], [:assessment_id, :status]) + + assessments = + cr.course_id + |> Query.all_assessments_with_aggregates() + |> subquery() + |> join( + :left, + [a], + sa in subquery(submission_aggregates), + on: a.id == sa.assessment_id + ) + |> join(:left, [a, _], s in subquery(submission_status), on: a.id == s.assessment_id) + |> select([a, sa, s], %{ + a + | xp: sa.xp, + graded_count: sa.graded_count, + user_status: s.status + }) + |> filter_published_assessments(cr) + |> order_by(:open_at) + |> preload(:config) + |> Repo.all() + + {:ok, assessments} + end + + def filter_published_assessments(assessments, cr) do + role = cr.role + + case role do + :student -> where(assessments, is_published: true) + _ -> assessments + end + end + + def create_assessment(params) do + %Assessment{} + |> Assessment.changeset(params) + |> Repo.insert() + end + + @doc """ + The main function that inserts or updates assessments from the XML Parser + """ + @spec insert_or_update_assessments_and_questions(map(), [map()], boolean()) :: + {:ok, any()} + | {:error, Ecto.Multi.name(), any(), %{optional(Ecto.Multi.name()) => any()}} + def insert_or_update_assessments_and_questions( + assessment_params, + questions_params, + force_update + ) do + assessment_multi = + Multi.insert_or_update( + Multi.new(), + :assessment, + insert_or_update_assessment_changeset(assessment_params, force_update) + ) + + if force_update and invalid_force_update(assessment_multi, questions_params) do + {:error, "Question count is different"} + else + questions_params + |> Enum.with_index(1) + |> Enum.reduce(assessment_multi, fn {question_params, index}, multi -> + Multi.run(multi, "question#{index}", fn _repo, %{assessment: %Assessment{id: id}} -> + question = + Question + |> where([q], q.display_order == ^index and q.assessment_id == ^id) + |> Repo.one() + + # the is_nil(question) check allows for force updating of brand new assessments + if !force_update or is_nil(question) do + {status, new_question} = + question_params + |> Map.put(:display_order, index) + |> build_question_changeset_for_assessment_id(id) + |> Repo.insert() + + if status == :ok and new_question.type == :voting do + insert_voting( + assessment_params.course_id, + question_params.question.contest_number, + new_question.id + ) + else + {status, new_question} + end + else + params = + question_params + |> Map.put_new(:max_xp, 0) + |> Map.put(:display_order, index) + + if question_params.type != Atom.to_string(question.type) do + {:error, + create_invalid_changeset_with_error( + :question, + "Question types should remain the same" + )} + else + question + |> Question.changeset(params) + |> Repo.update() + end + end + end) + end) + |> Repo.transaction() + end + end + + # Function that checks if the force update is invalid. The force update is only invalid + # if the new question count is different from the old question count. + defp invalid_force_update(assessment_multi, questions_params) do + assessment_id = + (assessment_multi.operations + |> List.first() + |> elem(1) + |> elem(1)).data.id + + if assessment_id do + open_date = Repo.get(Assessment, assessment_id).open_at + # check if assessment is already opened + if Timex.compare(open_date, Timex.now()) >= 0 do + false + else + existing_questions_count = + Question + |> where([q], q.assessment_id == ^assessment_id) + |> Repo.all() + |> Enum.count() + + new_questions_count = Enum.count(questions_params) + existing_questions_count != new_questions_count + end + else + false + end + end + + @spec insert_or_update_assessment_changeset(map(), boolean()) :: Ecto.Changeset.t() + defp insert_or_update_assessment_changeset( + params = %{number: number, course_id: course_id}, + force_update + ) do + Assessment + |> where(number: ^number) + |> where(course_id: ^course_id) + |> Repo.one() + |> case do + nil -> + Assessment.changeset(%Assessment{}, params) + + %{id: assessment_id} = assessment -> + answers_exist = + Answer + |> join(:inner, [a], q in assoc(a, :question)) + |> join(:inner, [a, q], asst in assoc(q, :assessment)) + |> where([a, q, asst], asst.id == ^assessment_id) + |> Repo.exists?() + + # Maintain the same open/close date when updating an assessment + params = + params + |> Map.delete(:open_at) + |> Map.delete(:close_at) + |> Map.delete(:is_published) + + cond do + not answers_exist -> + # Delete all realted submission_votes + SubmissionVotes + |> join(:inner, [sv, q], q in assoc(sv, :question)) + |> where([sv, q], q.assessment_id == ^assessment_id) + |> Repo.delete_all() + + # Delete all existing questions + Question + |> where(assessment_id: ^assessment_id) + |> Repo.delete_all() + + Assessment.changeset(assessment, params) + + force_update -> + Assessment.changeset(assessment, params) + + true -> + # if the assessment has submissions, don't edit + create_invalid_changeset_with_error(:assessment, "has submissions") + end + end + end + + @spec build_question_changeset_for_assessment_id(map(), number() | String.t()) :: + Ecto.Changeset.t() + defp build_question_changeset_for_assessment_id(params, assessment_id) + when is_ecto_id(assessment_id) do + params_with_assessment_id = Map.put_new(params, :assessment_id, assessment_id) + + Question.changeset(%Question{}, params_with_assessment_id) + end + + @doc """ + Generates and assigns contest entries for users with given usernames. + """ + def insert_voting( + course_id, + contest_number, + question_id + ) do + contest_assessment = Repo.get_by(Assessment, number: contest_number, course_id: course_id) + + if is_nil(contest_assessment) do + changeset = change(%Assessment{}, %{number: ""}) + + error_changeset = + Ecto.Changeset.add_error( + changeset, + :number, + "invalid contest number" + ) + + {:error, error_changeset} + else + # Returns contest submission ids with answers that contain "return" + contest_submission_ids = + Submission + |> join(:inner, [s], ans in assoc(s, :answers)) + |> join(:inner, [s, ans], cr in assoc(s, :student)) + |> where([s, ans, cr], cr.role == "student") + |> where([s, _], s.assessment_id == ^contest_assessment.id and s.status == "submitted") + |> where( + [_, ans, cr], + fragment( + "?->>'code' like ?", + ans.answer, + "%return%" + ) + ) + |> select([s, _ans], {s.student_id, s.id}) + |> Repo.all() + |> Enum.into(%{}) + + contest_submission_ids_length = Enum.count(contest_submission_ids) + + voter_ids = + CourseRegistration + |> where(role: "student", course_id: ^course_id) + |> select([cr], cr.id) + |> Repo.all() + + votes_per_user = min(contest_submission_ids_length, 10) + + votes_per_submission = + if Enum.empty?(contest_submission_ids) do + 0 + else + trunc(Float.ceil(votes_per_user * length(voter_ids) / contest_submission_ids_length)) + end + + submission_id_list = + contest_submission_ids + |> Enum.map(fn {_, s_id} -> s_id end) + |> Enum.shuffle() + |> List.duplicate(votes_per_submission) + |> List.flatten() + + {_submission_map, submission_votes_changesets} = + voter_ids + |> Enum.reduce({submission_id_list, []}, fn voter_id, acc -> + {submission_list, submission_votes} = acc + + user_contest_submission_id = Map.get(contest_submission_ids, voter_id) + + {votes, rest} = + submission_list + |> Enum.reduce_while({MapSet.new(), submission_list}, fn s_id, acc -> + {user_votes, submissions} = acc + + max_votes = + if votes_per_user == contest_submission_ids_length and + not is_nil(user_contest_submission_id) do + # no. of submssions is less than 10. Unable to find + votes_per_user - 1 + else + votes_per_user + end + + if MapSet.size(user_votes) < max_votes do + if s_id != user_contest_submission_id and not MapSet.member?(user_votes, s_id) do + new_user_votes = MapSet.put(user_votes, s_id) + new_submissions = List.delete(submissions, s_id) + {:cont, {new_user_votes, new_submissions}} + else + {:cont, {user_votes, submissions}} + end + else + {:halt, acc} + end + end) + + votes = MapSet.to_list(votes) + + new_submission_votes = + votes + |> Enum.map(fn s_id -> + %SubmissionVotes{voter_id: voter_id, submission_id: s_id, question_id: question_id} + end) + |> Enum.concat(submission_votes) + + {rest, new_submission_votes} + end) + + submission_votes_changesets + |> Enum.with_index() + |> Enum.reduce(Multi.new(), fn {changeset, index}, multi -> + Multi.insert(multi, Integer.to_string(index), changeset) + end) + |> Repo.transaction() + end + end + + def update_assessment(id, params) when is_ecto_id(id) do + IO.inspect(params) + simple_update( + Assessment, + id, + using: &Assessment.changeset/2, + params: params + ) + end + + def update_question(id, params) when is_ecto_id(id) do + simple_update( + Question, + id, + using: &Question.changeset/2, + params: params + ) + end + + def publish_assessment(id) when is_ecto_id(id) do + update_assessment(id, %{is_published: true}) + end + + def create_question_for_assessment(params, assessment_id) when is_ecto_id(assessment_id) do + assessment = + Assessment + |> where(id: ^assessment_id) + |> join(:left, [a], q in assoc(a, :questions)) + |> preload([_, q], questions: q) + |> Repo.one() + + if assessment do + params_with_assessment_id = Map.put_new(params, :assessment_id, assessment.id) + + %Question{} + |> Question.changeset(params_with_assessment_id) + |> put_display_order(assessment.questions) + |> Repo.insert() + else + {:error, "Assessment not found"} + end + end + + def get_question(id) when is_ecto_id(id) do + Question + |> where(id: ^id) + |> join(:inner, [q], assessment in assoc(q, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.one() + end + + def delete_question(id) when is_ecto_id(id) do + question = Repo.get(Question, id) + Repo.delete(question) + end + + @doc """ + Public internal api to submit new answers for a question. Possible return values are: + `{:ok, nil}` -> success + `{:error, error}` -> failed. `error` is in the format of `{http_response_code, error message}` + + Note: In the event of `find_or_create_submission` failing due to a race condition, error will be: + `{:bad_request, "Missing or invalid parameter(s)"}` + + """ + def answer_question( + question = %Question{}, + cr = %CourseRegistration{id: cr_id}, + raw_answer, + force_submit + ) do + with {:ok, submission} <- find_or_create_submission(cr, question.assessment), + {:status, true} <- {:status, force_submit or submission.status != :submitted}, + {:ok, _answer} <- insert_or_update_answer(submission, question, raw_answer, cr_id) do + update_submission_status_router(submission, question) + + {:ok, nil} + else + {:status, _} -> + {:error, {:forbidden, "Assessment submission already finalised"}} + + {:error, :race_condition} -> + {:error, {:internal_server_error, "Please try again later."}} + + {:error, :invalid_vote} -> + {:error, {:bad_request, "Invalid vote! Vote is not saved."}} + + _ -> + {:error, {:bad_request, "Missing or invalid parameter(s)"}} + end + end + + def get_submission(assessment_id, %CourseRegistration{id: cr_id}) + when is_ecto_id(assessment_id) do + Submission + |> where(assessment_id: ^assessment_id) + |> where(student_id: ^cr_id) + |> join(:inner, [s], a in assoc(s, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.one() + end + + def get_submission_by_id(submission_id) when is_ecto_id(submission_id) do + Submission + |> where(id: ^submission_id) + |> join(:inner, [s], a in assoc(s, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.one() + end + + def finalise_submission(submission = %Submission{}) do + with {:status, :attempted} <- {:status, submission.status}, + {:ok, updated_submission} <- update_submission_status_and_xp_bonus(submission) do + # Couple with update_submission_status_and_xp_bonus to ensure notification is sent + Notifications.write_notification_when_student_submits(submission) + # Send email notification to avenger + %{notification_type: "assessment_submission", submission_id: updated_submission.id} + |> Cadet.Workers.NotificationWorker.new() + |> Oban.insert() + + # Begin autograding job + GradingJob.force_grade_individual_submission(updated_submission) + + {:ok, nil} + else + {:status, :attempting} -> + {:error, {:bad_request, "Some questions have not been attempted"}} + + {:status, :submitted} -> + {:error, {:forbidden, "Assessment has already been submitted"}} + + _ -> + {:error, {:internal_server_error, "Please try again later."}} + end + end + + def unsubmit_submission( + submission_id, + cr = %CourseRegistration{id: course_reg_id, role: role} + ) + when is_ecto_id(submission_id) do + submission = + Submission + |> join(:inner, [s], a in assoc(s, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.get(submission_id) + + # allows staff to unsubmit own assessment + bypass = role in @bypass_closed_roles and submission.student_id == course_reg_id + + with {:submission_found?, true} <- {:submission_found?, is_map(submission)}, + {:is_open?, true} <- {:is_open?, bypass or is_open?(submission.assessment)}, + {:status, :submitted} <- {:status, submission.status}, + {:allowed_to_unsubmit?, true} <- + {:allowed_to_unsubmit?, + role == :admin or bypass or + Cadet.Accounts.Query.avenger_of?(cr, submission.student_id)} do + Multi.new() + |> Multi.run( + :rollback_submission, + fn _repo, _ -> + submission + |> Submission.changeset(%{ + status: :attempted, + xp_bonus: 0, + unsubmitted_by_id: course_reg_id, + unsubmitted_at: Timex.now() + }) + |> Repo.update() + end + ) + |> Multi.run(:rollback_answers, fn _repo, _ -> + Answer + |> join(:inner, [a], q in assoc(a, :question)) + |> join(:inner, [a, _], s in assoc(a, :submission)) + |> preload([_, q, s], question: q, submission: s) + |> where(submission_id: ^submission.id) + |> Repo.all() + |> Enum.reduce_while({:ok, nil}, fn answer, acc -> + case acc do + {:error, _} -> + {:halt, acc} + + {:ok, _} -> + {:cont, + answer + |> Answer.grading_changeset(%{ + xp: 0, + xp_adjustment: 0, + autograding_status: :none, + autograding_results: [] + }) + |> Repo.update()} + end + end) + end) + |> Repo.transaction() + + Cadet.Accounts.Notifications.handle_unsubmit_notifications( + submission.assessment.id, + Repo.get(CourseRegistration, submission.student_id) + ) + + {:ok, nil} + else + {:submission_found?, false} -> + {:error, {:not_found, "Submission not found"}} + + {:is_open?, false} -> + {:error, {:forbidden, "Assessment not open"}} + + {:status, :attempting} -> + {:error, {:bad_request, "Some questions have not been attempted"}} + + {:status, :attempted} -> + {:error, {:bad_request, "Assessment has not been submitted"}} + + {:allowed_to_unsubmit?, false} -> + {:error, {:forbidden, "Only Avenger of student or Admin is permitted to unsubmit"}} + + _ -> + {:error, {:internal_server_error, "Please try again later."}} + end + end + + @spec update_submission_status_and_xp_bonus(Submission.t()) :: + {:ok, Submission.t()} | {:error, Ecto.Changeset.t()} + defp update_submission_status_and_xp_bonus(submission = %Submission{}) do + assessment = submission.assessment + assessment_conifg = Repo.get_by(AssessmentConfig, id: assessment.config_id) + + max_bonus_xp = assessment_conifg.early_submission_xp + early_hours = assessment_conifg.hours_before_early_xp_decay + + xp_bonus = + if Timex.before?(Timex.now(), Timex.shift(assessment.open_at, hours: early_hours)) do + max_bonus_xp + else + # This logic interpolates from max bonus at early hour to 0 bonus at close time + decaying_hours = Timex.diff(assessment.close_at, assessment.open_at, :hours) - early_hours + remaining_hours = Enum.max([0, Timex.diff(assessment.close_at, Timex.now(), :hours)]) + proportion = if(decaying_hours > 0, do: remaining_hours / decaying_hours, else: 1) + bonus_xp = round(max_bonus_xp * proportion) + Enum.max([0, bonus_xp]) + end + + submission + |> Submission.changeset(%{status: :submitted, xp_bonus: xp_bonus}) + |> Repo.update() + end + + defp update_submission_status_router(submission = %Submission{}, question = %Question{}) do + case question.type do + :voting -> update_contest_voting_submission_status(submission, question) + :mcq -> update_submission_status(submission, question.assessment) + :programming -> update_submission_status(submission, question.assessment) + end + end + + defp update_submission_status(submission = %Submission{}, assessment = %Assessment{}) do + model_assoc_count = fn model, assoc, id -> + model + |> where(id: ^id) + |> join(:inner, [m], a in assoc(m, ^assoc)) + |> select([_, a], count(a.id)) + |> Repo.one() + end + + Multi.new() + |> Multi.run(:assessment, fn _repo, _ -> + {:ok, model_assoc_count.(Assessment, :questions, assessment.id)} + end) + |> Multi.run(:submission, fn _repo, _ -> + {:ok, model_assoc_count.(Submission, :answers, submission.id)} + end) + |> Multi.run(:update, fn _repo, %{submission: s_count, assessment: a_count} -> + if s_count == a_count do + submission |> Submission.changeset(%{status: :attempted}) |> Repo.update() + else + {:ok, nil} + end + end) + |> Repo.transaction() + end + + defp update_contest_voting_submission_status(submission = %Submission{}, question = %Question{}) do + has_nil_entries = + SubmissionVotes + |> where(question_id: ^question.id) + |> where(voter_id: ^submission.student_id) + |> where([sv], is_nil(sv.score)) + |> Repo.exists?() + + unless has_nil_entries do + submission |> Submission.changeset(%{status: :attempted}) |> Repo.update() + end + end + + defp load_contest_voting_entries( + questions, + %CourseRegistration{role: role, course_id: course_id, id: voter_id}, + assessment + ) do + Enum.map( + questions, + fn q -> + if q.type == :voting do + submission_votes = all_submission_votes_by_question_id_and_voter_id(q.id, voter_id) + # fetch top 10 contest voting entries with the contest question id + question_id = fetch_associated_contest_question_id(course_id, q) + + leaderboard_results = + if is_nil(question_id) do + [] + else + if leaderboard_open?(assessment, q) or role in @open_all_assessment_roles do + fetch_top_relative_score_answers(question_id, 10) + else + [] + end + end + + # populate entries to vote for and leaderboard data into the question + voting_question = + q.question + |> Map.put(:contest_entries, submission_votes) + |> Map.put( + :contest_leaderboard, + leaderboard_results + ) + + Map.put(q, :question, voting_question) + else + q + end + end + ) + end + + defp all_submission_votes_by_question_id_and_voter_id(question_id, voter_id) do + SubmissionVotes + |> where([v], v.voter_id == ^voter_id and v.question_id == ^question_id) + |> join(:inner, [v], s in assoc(v, :submission)) + |> join(:inner, [v, s], a in assoc(s, :answers)) + |> select([v, s, a], %{submission_id: v.submission_id, answer: a.answer, score: v.score}) + |> Repo.all() + end + + # Finds the contest_question_id associated with the given voting_question id + defp fetch_associated_contest_question_id(course_id, voting_question) do + contest_number = voting_question.question["contest_number"] + + if is_nil(contest_number) do + nil + else + Assessment + |> where(number: ^contest_number, course_id: ^course_id) + |> join(:inner, [a], q in assoc(a, :questions)) + |> order_by([a, q], q.display_order) + |> select([a, q], q.id) + |> Repo.one() + end + end + + defp leaderboard_open?(assessment, voting_question) do + Timex.before?( + Timex.shift(assessment.close_at, hours: voting_question.question["reveal_hours"]), + Timex.now() + ) + end + + @doc """ + Fetches top answers for the given question, based on the contest relative_score + + Used for contest leaderboard fetching + """ + def fetch_top_relative_score_answers(question_id, number_of_answers) do + Answer + |> where(question_id: ^question_id) + |> where( + [a], + fragment( + "?->>'code' like ?", + a.answer, + "%return%" + ) + ) + |> order_by(desc: :relative_score) + |> join(:left, [a], s in assoc(a, :submission)) + |> join(:left, [a, s], student in assoc(s, :student)) + |> join(:inner, [a, s, student], student_user in assoc(student, :user)) + |> where([a, s, student], student.role == "student") + |> select([a, s, student, student_user], %{ + submission_id: a.submission_id, + answer: a.answer, + relative_score: a.relative_score, + student_name: student_user.name + }) + |> limit(^number_of_answers) + |> Repo.all() + end + + @doc """ + Computes rolling leaderboard for contest votes that are still open. + """ + def update_rolling_contest_leaderboards do + # 115 = 2 hours - 5 minutes is default. + if Log.log_execution("update_rolling_contest_leaderboards", Timex.Duration.from_minutes(115)) do + Logger.info("Started update_rolling_contest_leaderboards") + + voting_questions_to_update = fetch_active_voting_questions() + + _ = + voting_questions_to_update + |> Enum.map(fn qn -> compute_relative_score(qn.id) end) + + Logger.info("Successfully update_rolling_contest_leaderboards") + end + end + + def fetch_active_voting_questions do + Question + |> join(:left, [q], a in assoc(q, :assessment)) + |> where([q, a], q.type == "voting") + |> where([q, a], a.is_published) + |> where([q, a], a.open_at <= ^Timex.now() and a.close_at >= ^Timex.now()) + |> Repo.all() + end + + @doc """ + Computes final leaderboard for contest votes that have closed. + """ + def update_final_contest_leaderboards do + # 1435 = 24 hours - 5 minutes + if Log.log_execution("update_final_contest_leaderboards", Timex.Duration.from_minutes(1435)) do + Logger.info("Started update_final_contest_leaderboards") + + voting_questions_to_update = fetch_voting_questions_due_yesterday() + + _ = + voting_questions_to_update + |> Enum.map(fn qn -> compute_relative_score(qn.id) end) + + Logger.info("Successfully update_final_contest_leaderboards") + end + end + + def fetch_voting_questions_due_yesterday do + Question + |> join(:left, [q], a in assoc(q, :assessment)) + |> where([q, a], q.type == "voting") + |> where([q, a], a.is_published) + |> where([q, a], a.open_at <= ^Timex.now()) + |> where( + [q, a], + a.close_at < ^Timex.now() and a.close_at >= ^Timex.shift(Timex.now(), days: -1) + ) + |> Repo.all() + end + + @doc """ + Computes the current relative_score of each voting submission answer + based on current submitted votes. + """ + def compute_relative_score(contest_voting_question_id) do + # query all records from submission votes tied to the question id -> + # map score to user id -> + # store as grade -> + # query grade for contest question id. + eligible_votes = + SubmissionVotes + |> where(question_id: ^contest_voting_question_id) + |> where([sv], not is_nil(sv.score)) + |> join(:inner, [sv], ans in Answer, on: sv.submission_id == ans.submission_id) + |> select( + [sv, ans], + %{ans_id: ans.id, score: sv.score, ans: ans.answer["code"]} + ) + |> Repo.all() + + entry_scores = map_eligible_votes_to_entry_score(eligible_votes) + + entry_scores + |> Enum.map(fn {ans_id, relative_score} -> + %Answer{id: ans_id} + |> Answer.contest_score_update_changeset(%{ + relative_score: relative_score + }) + end) + |> Enum.map(fn changeset -> + op_key = "answer_#{changeset.data.id}" + Multi.update(Multi.new(), op_key, changeset) + end) + |> Enum.reduce(Multi.new(), &Multi.append/2) + |> Repo.transaction() + end + + defp map_eligible_votes_to_entry_score(eligible_votes) do + # converts eligible votes to the {total cumulative score, number of votes, tokens} + entry_vote_data = + Enum.reduce(eligible_votes, %{}, fn %{ans_id: ans_id, score: score, ans: ans}, tracker -> + {prev_score, prev_count, _ans_tokens} = Map.get(tracker, ans_id, {0, 0, 0}) + + Map.put( + tracker, + ans_id, + # assume each voter is assigned 10 entries which will make it fair. + {prev_score + score, prev_count + 1, Lexer.count_tokens(ans)} + ) + end) + + # calculate the score based on formula {ans_id, score} + Enum.map( + entry_vote_data, + fn {ans_id, {sum_of_scores, number_of_voters, tokens}} -> + {ans_id, calculate_formula_score(sum_of_scores, number_of_voters, tokens)} + end + ) + end + + # Calculate the score based on formula + # score(v,t) = v - 2^(t/50) where v is the normalized_voting_score + # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 + defp calculate_formula_score(sum_of_scores, number_of_voters, tokens) do + normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 + normalized_voting_score - :math.pow(2, min(1023.5, tokens / 50)) + end + + @doc """ + Function returning submissions under a grader. This function returns only the + fields that are exposed in the /grading endpoint. The reason we select only + those fields is to reduce the memory usage especially when the number of + submissions is large i.e. > 25000 submissions. + + The input parameters are the user and group_only. group_only is used to check + whether only the groups under the grader should be returned. The parameter is + a boolean which is false by default. + + The return value is {:ok, submissions} if no errors, else it is {:error, + {:unauthorized, "Forbidden."}} + """ + @spec all_submissions_by_grader_for_index(CourseRegistration.t()) :: + {:ok, String.t()} + def all_submissions_by_grader_for_index( + grader = %CourseRegistration{course_id: course_id}, + group_only \\ false, + ungraded_only \\ false + ) do + show_all = not group_only + + group_where = + if show_all, + do: "", + else: + "where s.student_id in (select cr.id from course_registrations cr inner join groups g on cr.group_id = g.id where g.leader_id = $2) or s.student_id = $2" + + ungraded_where = + if ungraded_only, + do: "where s.\"gradedCount\" < assts.\"questionCount\"", + else: "" + + params = if show_all, do: [course_id], else: [course_id, grader.id] + + # We bypass Ecto here and use a raw query to generate JSON directly from + # PostgreSQL, because doing it in Elixir/Erlang is too inefficient. + + case Repo.query( + """ + select json_agg(q)::TEXT from + ( + select + s.id, + s.status, + s."unsubmittedAt", + s.xp, + s."xpAdjustment", + s."xpBonus", + s."gradedCount", + assts.jsn as assessment, + students.jsn as student, + unsubmitters.jsn as "unsubmittedBy" + from + (select + s.id, + s.student_id, + s.assessment_id, + s.status, + s.unsubmitted_at as "unsubmittedAt", + s.unsubmitted_by_id, + sum(ans.xp) as xp, + sum(ans.xp_adjustment) as "xpAdjustment", + s.xp_bonus as "xpBonus", + count(ans.id) filter (where ans.grader_id is not null) as "gradedCount" + from submissions s + left join + answers ans on s.id = ans.submission_id + #{group_where} + group by s.id) s + inner join + (select + a.id, a."questionCount", to_json(a) as jsn + from + (select + a.id, + a.title, + bool_or(ac.is_manually_graded) as "isManuallyGraded", + max(ac.type) as "type", + sum(q.max_xp) as "maxXp", + count(q.id) as "questionCount" + from assessments a + left join + questions q on a.id = q.assessment_id + inner join + assessment_configs ac on ac.id = a.config_id + where a.course_id = $1 + group by a.id) a) assts on assts.id = s.assessment_id + inner join + (select + cr.id, to_json(cr) as jsn + from + (select + cr.id, + u.name as "name", + g.name as "groupName", + g.leader_id as "groupLeaderId" + from course_registrations cr + left join + groups g on g.id = cr.group_id + inner join + users u on u.id = cr.user_id) cr) students on students.id = s.student_id + left join + (select + cr.id, to_json(cr) as jsn + from + (select + cr.id, + u.name + from course_registrations cr + inner join + users u on u.id = cr.user_id) cr) unsubmitters on s.unsubmitted_by_id = unsubmitters.id + #{ungraded_where} + ) q + """, + params + ) do + {:ok, %{rows: [[nil]]}} -> {:ok, "[]"} + {:ok, %{rows: [[json]]}} -> {:ok, json} + end + end + + @spec get_answers_in_submission(integer() | String.t()) :: + {:ok, [Answer.t()]} | {:error, {:bad_request | :unauthorized, String.t()}} + def get_answers_in_submission(id) when is_ecto_id(id) do + answer_query = + Answer + |> where(submission_id: ^id) + |> join(:inner, [a], q in assoc(a, :question)) + |> join(:inner, [_, q], ast in assoc(q, :assessment)) + |> join(:inner, [a, ..., ast], ac in assoc(ast, :config)) + |> join(:left, [a, ...], g in assoc(a, :grader)) + |> join(:left, [a, ..., g], gu in assoc(g, :user)) + |> join(:inner, [a, ...], s in assoc(a, :submission)) + |> join(:inner, [a, ..., s], st in assoc(s, :student)) + |> join(:inner, [a, ..., st], u in assoc(st, :user)) + |> preload([_, q, ast, ac, g, gu, s, st, u], + question: {q, assessment: {ast, config: ac}}, + grader: {g, user: gu}, + submission: {s, student: {st, user: u}} + ) + + answers = + answer_query + |> Repo.all() + |> Enum.sort_by(& &1.question.display_order) + |> Enum.map(fn ans -> + if ans.question.type == :voting do + empty_contest_entries = Map.put(ans.question.question, :contest_entries, []) + empty_contest_leaderboard = Map.put(empty_contest_entries, :contest_leaderboard, []) + question = Map.put(ans.question, :question, empty_contest_leaderboard) + Map.put(ans, :question, question) + else + ans + end + end) + + if answers == [] do + {:error, {:bad_request, "Submission is not found."}} + else + {:ok, answers} + end + end + + defp is_fully_graded?(%Answer{submission_id: submission_id}) do + submission = + Submission + |> Repo.get_by(id: submission_id) + + question_count = + Question + |> where(assessment_id: ^submission.assessment_id) + |> select([q], count(q.id)) + |> Repo.one() + + graded_count = + Answer + |> where([a], submission_id: ^submission_id) + |> where([a], not is_nil(a.grader_id)) + |> select([a], count(a.id)) + |> Repo.one() + + question_count == graded_count + end + + @spec update_grading_info( + %{submission_id: integer() | String.t(), question_id: integer() | String.t()}, + %{}, + CourseRegistration.t() + ) :: + {:ok, nil} + | {:error, {:unauthorized | :bad_request | :internal_server_error, String.t()}} + def update_grading_info( + %{submission_id: submission_id, question_id: question_id}, + attrs, + %CourseRegistration{id: grader_id} + ) + when is_ecto_id(submission_id) and is_ecto_id(question_id) do + attrs = Map.put(attrs, "grader_id", grader_id) + + answer_query = + Answer + |> where(submission_id: ^submission_id) + |> where(question_id: ^question_id) + + answer_query = + answer_query + |> join(:inner, [a], s in assoc(a, :submission)) + |> preload([_, s], submission: s) + + answer = Repo.one(answer_query) + + is_own_submission = grader_id == answer.submission.student_id + + with {:answer_found?, true} <- {:answer_found?, is_map(answer)}, + {:status, true} <- + {:status, answer.submission.status == :submitted or is_own_submission}, + {:valid, changeset = %Ecto.Changeset{valid?: true}} <- + {:valid, Answer.grading_changeset(answer, attrs)}, + {:ok, _} <- Repo.update(changeset) do + if is_fully_graded?(answer) and not is_own_submission do + # Every answer in this submission has been graded manually + Notifications.write_notification_when_graded(submission_id, :graded) + else + {:ok, nil} + end + else + {:answer_found?, false} -> + {:error, {:bad_request, "Answer not found or user not permitted to grade."}} + + {:valid, changeset} -> + {:error, {:bad_request, full_error_messages(changeset)}} + + {:status, _} -> + {:error, {:method_not_allowed, "Submission is not submitted yet."}} + + {:error, _} -> + {:error, {:internal_server_error, "Please try again later."}} + end + end + + def update_grading_info( + _, + _, + _ + ) do + {:error, {:unauthorized, "User is not permitted to grade."}} + end + + @spec force_regrade_submission(integer() | String.t(), CourseRegistration.t()) :: + {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}} + def force_regrade_submission( + submission_id, + _requesting_user = %CourseRegistration{id: grader_id} + ) + when is_ecto_id(submission_id) do + with {:get, sub} when not is_nil(sub) <- {:get, Repo.get(Submission, submission_id)}, + {:status, true} <- {:status, sub.student_id == grader_id or sub.status == :submitted} do + GradingJob.force_grade_individual_submission(sub, true) + {:ok, nil} + else + {:get, nil} -> + {:error, {:not_found, "Submission not found"}} + + {:status, false} -> + {:error, {:bad_request, "Submission not submitted yet"}} + end + end + + def force_regrade_submission(_, _) do + {:error, {:forbidden, "User is not permitted to grade."}} + end + + @spec force_regrade_answer( + integer() | String.t(), + integer() | String.t(), + CourseRegistration.t() + ) :: + {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}} + def force_regrade_answer( + submission_id, + question_id, + _requesting_user = %CourseRegistration{id: grader_id} + ) + when is_ecto_id(submission_id) and is_ecto_id(question_id) do + answer = + Answer + |> where(submission_id: ^submission_id, question_id: ^question_id) + |> preload([:question, :submission]) + |> Repo.one() + + with {:get, answer} when not is_nil(answer) <- {:get, answer}, + {:status, true} <- + {:status, + answer.submission.student_id == grader_id or answer.submission.status == :submitted} do + GradingJob.grade_answer(answer, answer.question, true) + {:ok, nil} + else + {:get, nil} -> + {:error, {:not_found, "Answer not found"}} + + {:status, false} -> + {:error, {:bad_request, "Submission not submitted yet"}} + end + end + + def force_regrade_answer(_, _, _) do + {:error, {:forbidden, "User is not permitted to grade."}} + end + + defp find_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do + submission = + Submission + |> where(student_id: ^cr.id) + |> where(assessment_id: ^assessment.id) + |> Repo.one() + + if submission do + {:ok, submission} + else + {:error, nil} + end + end + + # Checks if an assessment is open and published. + @spec is_open?(Assessment.t()) :: boolean() + def is_open?(%Assessment{open_at: open_at, close_at: close_at, is_published: is_published}) do + Timex.between?(Timex.now(), open_at, close_at, inclusive: :start) and is_published + end + + @spec get_group_grading_summary(integer()) :: + {:ok, [String.t(), ...], []} + def get_group_grading_summary(course_id) do + subs = + Answer + |> join(:left, [ans], s in Submission, on: s.id == ans.submission_id) + |> join(:left, [ans, s], st in CourseRegistration, on: s.student_id == st.id) + |> join(:left, [ans, s, st], a in Assessment, on: a.id == s.assessment_id) + |> join(:inner, [ans, s, st, a], ac in AssessmentConfig, on: ac.id == a.config_id) + |> where( + [ans, s, st, a, ac], + not is_nil(st.group_id) and s.status == ^:submitted and + ac.show_grading_summary and a.course_id == ^course_id + ) + |> group_by([ans, s, st, a, ac], s.id) + |> select([ans, s, st, a, ac], %{ + group_id: max(st.group_id), + config_id: max(ac.id), + config_type: max(ac.type), + num_submitted: count(), + num_ungraded: filter(count(), is_nil(ans.grader_id)) + }) + + raw_data = + subs + |> subquery() + |> join(:left, [t], g in Group, on: t.group_id == g.id) + |> join(:left, [t, g], l in CourseRegistration, on: l.id == g.leader_id) + |> join(:left, [t, g, l], lu in User, on: lu.id == l.user_id) + |> group_by([t, g, l, lu], [t.group_id, t.config_id, t.config_type, g.name, lu.name]) + |> select([t, g, l, lu], %{ + group_name: g.name, + leader_name: lu.name, + config_id: t.config_id, + config_type: t.config_type, + ungraded: filter(count(), t.num_ungraded > 0), + submitted: count() + }) + |> Repo.all() + + showing_configs = + AssessmentConfig + |> where([ac], ac.course_id == ^course_id and ac.show_grading_summary) + |> order_by(:order) + |> group_by([ac], ac.id) + |> select([ac], %{ + id: ac.id, + type: ac.type + }) + |> Repo.all() + + data_by_groups = + raw_data + |> Enum.reduce(%{}, fn raw, acc -> + if Map.has_key?(acc, raw.group_name) do + acc + |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded) + |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted) + else + acc + |> put_in([raw.group_name], %{}) + |> put_in([raw.group_name, "groupName"], raw.group_name) + |> put_in([raw.group_name, "leaderName"], raw.leader_name) + |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded) + |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted) + end + end) + + headings = + showing_configs + |> Enum.reduce([], fn config, acc -> + acc ++ ["submitted" <> config.type, "ungraded" <> config.type] + end) + + default_row_data = + headings + |> Enum.reduce(%{}, fn heading, acc -> + put_in(acc, [heading], 0) + end) + + rows = data_by_groups |> Enum.map(fn {_k, row} -> Map.merge(default_row_data, row) end) + cols = ["groupName", "leaderName"] ++ headings + + {:ok, cols, rows} + end + + defp create_empty_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do + %Submission{} + |> Submission.changeset(%{student: cr, assessment: assessment}) + |> Repo.insert() + |> case do + {:ok, submission} -> {:ok, submission} + {:error, _} -> {:error, :race_condition} + end + end + + defp find_or_create_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do + case find_submission(cr, assessment) do + {:ok, submission} -> {:ok, submission} + {:error, _} -> create_empty_submission(cr, assessment) + end + end + + defp insert_or_update_answer( + submission = %Submission{}, + question = %Question{}, + raw_answer, + course_reg_id + ) do + answer_content = build_answer_content(raw_answer, question.type) + + if question.type == :voting do + insert_or_update_voting_answer(submission.id, course_reg_id, question.id, answer_content) + else + answer_changeset = + %Answer{} + |> Answer.changeset(%{ + answer: answer_content, + question_id: question.id, + submission_id: submission.id, + type: question.type + }) + + Repo.insert( + answer_changeset, + on_conflict: [set: [answer: get_change(answer_changeset, :answer)]], + conflict_target: [:submission_id, :question_id] + ) + end + end + + def insert_or_update_voting_answer(submission_id, course_reg_id, question_id, answer_content) do + set_score_to_nil = + SubmissionVotes + |> where(voter_id: ^course_reg_id, question_id: ^question_id) + + voting_multi = + Multi.new() + |> Multi.update_all(:set_score_to_nil, set_score_to_nil, set: [score: nil]) + + answer_content + |> Enum.with_index(1) + |> Enum.reduce(voting_multi, fn {entry, index}, multi -> + multi + |> Multi.run("update#{index}", fn _repo, _ -> + SubmissionVotes + |> Repo.get_by( + voter_id: course_reg_id, + submission_id: entry.submission_id + ) + |> SubmissionVotes.changeset(%{score: entry.score}) + |> Repo.insert_or_update() + end) + end) + |> Multi.run("insert into answer table", fn _repo, _ -> + Answer + |> Repo.get_by(submission_id: submission_id, question_id: question_id) + |> case do + nil -> + Repo.insert(%Answer{ + answer: %{completed: true}, + submission_id: submission_id, + question_id: question_id, + type: :voting + }) + + _ -> + {:ok, nil} + end + end) + |> Repo.transaction() + |> case do + {:ok, _result} -> {:ok, nil} + {:error, _name, _changeset, _error} -> {:error, :invalid_vote} + end + end + + defp build_answer_content(raw_answer, question_type) do + case question_type do + :mcq -> + %{choice_id: raw_answer} + + :programming -> + %{code: raw_answer} + + :voting -> + raw_answer + |> Enum.map(fn ans -> + for {key, value} <- ans, into: %{}, do: {String.to_existing_atom(key), value} + end) + end + end +end diff --git a/lib/cadet_web/admin_controllers/admin_assessments_controller.ex b/lib/cadet_web/admin_controllers/admin_assessments_controller.ex index 72cdad12f..c80e6f0ad 100644 --- a/lib/cadet_web/admin_controllers/admin_assessments_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_assessments_controller.ex @@ -1,223 +1,223 @@ -defmodule CadetWeb.AdminAssessmentsController do - use CadetWeb, :controller - - use PhoenixSwagger - - import Ecto.Query, only: [where: 2] - import Cadet.Updater.XMLParser, only: [parse_xml: 4] - - alias Cadet.{Assessments, Repo} - alias Cadet.Assessments.Assessment - alias Cadet.Accounts.CourseRegistration - - def index(conn, %{"course_reg_id" => course_reg_id}) do - course_reg = Repo.get(CourseRegistration, course_reg_id) - {:ok, assessments} = Assessments.all_assessments(course_reg) - - render(conn, "index.json", assessments: assessments) - end - - def get_assessment(conn, %{"course_reg_id" => course_reg_id, "assessmentid" => assessment_id}) - when is_ecto_id(assessment_id) do - course_reg = Repo.get(CourseRegistration, course_reg_id) - - case Assessments.assessment_with_questions_and_answers(assessment_id, course_reg) do - {:ok, assessment} -> render(conn, "show.json", assessment: assessment) - {:error, {status, message}} -> send_resp(conn, status, message) - end - end - - def create(conn, %{ - "course_id" => course_id, - "assessment" => assessment, - "forceUpdate" => force_update, - "assessmentConfigId" => assessment_config_id - }) do - file = - assessment["file"].path - |> File.read!() - - result = - case force_update do - "true" -> parse_xml(file, course_id, assessment_config_id, true) - "false" -> parse_xml(file, course_id, assessment_config_id, false) - end - - case result do - :ok -> - if force_update == "true" do - text(conn, "Force update OK") - else - text(conn, "OK") - end - - {:ok, warning_message} -> - text(conn, warning_message) - - {:error, {status, message}} -> - conn - |> put_status(status) - |> text(message) - end - end - - def delete(conn, %{"course_id" => course_id, "assessmentid" => assessment_id}) do - with {:same_course, true} <- {:same_course, is_same_course(course_id, assessment_id)}, - {:ok, _} <- Assessments.delete_assessment(assessment_id) do - text(conn, "OK") - else - {:same_course, false} -> - conn - |> put_status(403) - |> text("User not allow to delete assessments from another course") - - {:error, {status, message}} -> - conn - |> put_status(status) - |> text(message) - end - end - - def update(conn, params = %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do - open_at = params |> Map.get("openAt") - close_at = params |> Map.get("closeAt") - is_published = params |> Map.get("isPublished") - max_team_size = params |> Map.get("maxTeamSize") - - updated_assessment = - if is_nil(is_published) do - %{} - else - %{:is_published => is_published} - end - - updated_assessment = - if is_nil(max_team_size) do - updated_assessment - else - Map.put(updated_assessment, :max_team_size, max_team_size) - end - - with {:ok, assessment} <- check_dates(open_at, close_at, updated_assessment), - {:ok, _nil} <- Assessments.update_assessment(assessment_id, assessment) do - text(conn, "OK") - else - {:error, {status, message}} -> - conn - |> put_status(status) - |> text(message) - end - end - - defp check_dates(open_at, close_at, assessment) do - if is_nil(open_at) and is_nil(close_at) do - {:ok, assessment} - else - formatted_open_date = elem(DateTime.from_iso8601(open_at), 1) - formatted_close_date = elem(DateTime.from_iso8601(close_at), 1) - - if Timex.before?(formatted_close_date, formatted_open_date) do - {:error, {:bad_request, "New end date should occur after new opening date"}} - else - assessment = Map.put(assessment, :open_at, formatted_open_date) - assessment = Map.put(assessment, :close_at, formatted_close_date) - IO.inspect("good") - {:ok, assessment} - end - end - end - - defp is_same_course(course_id, assessment_id) do - Assessment - |> where(id: ^assessment_id) - |> where(course_id: ^course_id) - |> Repo.exists?() - end - - swagger_path :index do - get("/admin/users/{courseRegId}/assessments") - - summary("Fetches assessment overviews of a user") - - security([%{JWT: []}]) - - parameters do - courseRegId(:path, :integer, "Course Reg ID", required: true) - end - - response(200, "OK", Schema.array(:AssessmentsList)) - response(401, "Unauthorised") - response(403, "Forbidden") - end - - swagger_path :create do - post("/admin/assessments") - - summary("Creates a new assessment or updates an existing assessment") - - security([%{JWT: []}]) - - consumes("multipart/form-data") - - parameters do - assessment(:formData, :file, "Assessment to create or update", required: true) - forceUpdate(:formData, :boolean, "Force update", required: true) - end - - response(200, "OK") - response(400, "XML parse error") - response(403, "Forbidden") - end - - swagger_path :delete do - PhoenixSwagger.Path.delete("/admin/assessments/{assessmentId}") - - summary("Deletes an assessment") - - security([%{JWT: []}]) - - parameters do - assessmentId(:path, :integer, "Assessment ID", required: true) - end - - response(200, "OK") - response(403, "Forbidden") - end - - swagger_path :update do - post("/admin/assessments/{assessmentId}") - - summary("Updates an assessment") - - security([%{JWT: []}]) - - consumes("application/json") - - parameters do - assessmentId(:path, :integer, "Assessment ID", required: true) - - assessment(:body, Schema.ref(:AdminUpdateAssessmentPayload), "Updated assessment details", - required: true - ) - end - - response(200, "OK") - response(401, "Assessment is already opened") - response(403, "Forbidden") - end - - def swagger_definitions do - %{ - # Schemas for payloads to modify data - AdminUpdateAssessmentPayload: - swagger_schema do - properties do - closeAt(:string, "Open date", required: false) - openAt(:string, "Close date", required: false) - isPublished(:boolean, "Whether the assessment is published", required: false) - maxTeamSize(:number, "Max team size of the assessment", required: false) - end - end - } - end -end +defmodule CadetWeb.AdminAssessmentsController do + use CadetWeb, :controller + + use PhoenixSwagger + + import Ecto.Query, only: [where: 2] + import Cadet.Updater.XMLParser, only: [parse_xml: 4] + + alias Cadet.{Assessments, Repo} + alias Cadet.Assessments.Assessment + alias Cadet.Accounts.CourseRegistration + + def index(conn, %{"course_reg_id" => course_reg_id}) do + course_reg = Repo.get(CourseRegistration, course_reg_id) + {:ok, assessments} = Assessments.all_assessments(course_reg) + + render(conn, "index.json", assessments: assessments) + end + + def get_assessment(conn, %{"course_reg_id" => course_reg_id, "assessmentid" => assessment_id}) + when is_ecto_id(assessment_id) do + course_reg = Repo.get(CourseRegistration, course_reg_id) + + case Assessments.assessment_with_questions_and_answers(assessment_id, course_reg) do + {:ok, assessment} -> render(conn, "show.json", assessment: assessment) + {:error, {status, message}} -> send_resp(conn, status, message) + end + end + + def create(conn, %{ + "course_id" => course_id, + "assessment" => assessment, + "forceUpdate" => force_update, + "assessmentConfigId" => assessment_config_id + }) do + file = + assessment["file"].path + |> File.read!() + + result = + case force_update do + "true" -> parse_xml(file, course_id, assessment_config_id, true) + "false" -> parse_xml(file, course_id, assessment_config_id, false) + end + + case result do + :ok -> + if force_update == "true" do + text(conn, "Force update OK") + else + text(conn, "OK") + end + + {:ok, warning_message} -> + text(conn, warning_message) + + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + end + end + + def delete(conn, %{"course_id" => course_id, "assessmentid" => assessment_id}) do + with {:same_course, true} <- {:same_course, is_same_course(course_id, assessment_id)}, + {:ok, _} <- Assessments.delete_assessment(assessment_id) do + text(conn, "OK") + else + {:same_course, false} -> + conn + |> put_status(403) + |> text("User not allow to delete assessments from another course") + + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + end + end + + def update(conn, params = %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do + open_at = params |> Map.get("openAt") + close_at = params |> Map.get("closeAt") + is_published = params |> Map.get("isPublished") + max_team_size = params |> Map.get("maxTeamSize") + + updated_assessment = + if is_nil(is_published) do + %{} + else + %{:is_published => is_published} + end + + updated_assessment = + if is_nil(max_team_size) do + updated_assessment + else + Map.put(updated_assessment, :max_team_size, max_team_size) + end + + with {:ok, assessment} <- check_dates(open_at, close_at, updated_assessment), + {:ok, _nil} <- Assessments.update_assessment(assessment_id, assessment) do + text(conn, "OK") + else + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + end + end + + defp check_dates(open_at, close_at, assessment) do + if is_nil(open_at) and is_nil(close_at) do + {:ok, assessment} + else + formatted_open_date = elem(DateTime.from_iso8601(open_at), 1) + formatted_close_date = elem(DateTime.from_iso8601(close_at), 1) + + if Timex.before?(formatted_close_date, formatted_open_date) do + {:error, {:bad_request, "New end date should occur after new opening date"}} + else + assessment = Map.put(assessment, :open_at, formatted_open_date) + assessment = Map.put(assessment, :close_at, formatted_close_date) + IO.inspect("good") + {:ok, assessment} + end + end + end + + defp is_same_course(course_id, assessment_id) do + Assessment + |> where(id: ^assessment_id) + |> where(course_id: ^course_id) + |> Repo.exists?() + end + + swagger_path :index do + get("/admin/users/{courseRegId}/assessments") + + summary("Fetches assessment overviews of a user") + + security([%{JWT: []}]) + + parameters do + courseRegId(:path, :integer, "Course Reg ID", required: true) + end + + response(200, "OK", Schema.array(:AssessmentsList)) + response(401, "Unauthorised") + response(403, "Forbidden") + end + + swagger_path :create do + post("/admin/assessments") + + summary("Creates a new assessment or updates an existing assessment") + + security([%{JWT: []}]) + + consumes("multipart/form-data") + + parameters do + assessment(:formData, :file, "Assessment to create or update", required: true) + forceUpdate(:formData, :boolean, "Force update", required: true) + end + + response(200, "OK") + response(400, "XML parse error") + response(403, "Forbidden") + end + + swagger_path :delete do + PhoenixSwagger.Path.delete("/admin/assessments/{assessmentId}") + + summary("Deletes an assessment") + + security([%{JWT: []}]) + + parameters do + assessmentId(:path, :integer, "Assessment ID", required: true) + end + + response(200, "OK") + response(403, "Forbidden") + end + + swagger_path :update do + post("/admin/assessments/{assessmentId}") + + summary("Updates an assessment") + + security([%{JWT: []}]) + + consumes("application/json") + + parameters do + assessmentId(:path, :integer, "Assessment ID", required: true) + + assessment(:body, Schema.ref(:AdminUpdateAssessmentPayload), "Updated assessment details", + required: true + ) + end + + response(200, "OK") + response(401, "Assessment is already opened") + response(403, "Forbidden") + end + + def swagger_definitions do + %{ + # Schemas for payloads to modify data + AdminUpdateAssessmentPayload: + swagger_schema do + properties do + closeAt(:string, "Open date", required: false) + openAt(:string, "Close date", required: false) + isPublished(:boolean, "Whether the assessment is published", required: false) + maxTeamSize(:number, "Max team size of the assessment", required: false) + end + end + } + end +end diff --git a/lib/cadet_web/admin_views/admin_assessments_view.ex b/lib/cadet_web/admin_views/admin_assessments_view.ex index 71043d1ab..477ea1b38 100644 --- a/lib/cadet_web/admin_views/admin_assessments_view.ex +++ b/lib/cadet_web/admin_views/admin_assessments_view.ex @@ -1,64 +1,64 @@ -defmodule CadetWeb.AdminAssessmentsView do - use CadetWeb, :view - use Timex - import CadetWeb.AssessmentsHelpers - - def render("index.json", %{assessments: assessments}) do - render_many(assessments, CadetWeb.AdminAssessmentsView, "overview.json", as: :assessment) - end - - def render("overview.json", %{assessment: assessment}) do - transform_map_for_view(assessment, %{ - id: :id, - courseId: :course_id, - title: :title, - shortSummary: :summary_short, - openAt: &format_datetime(&1.open_at), - closeAt: &format_datetime(&1.close_at), - type: & &1.config.type, - isManuallyGraded: & &1.config.is_manually_graded, - story: :story, - number: :number, - reading: :reading, - status: &(&1.user_status || "not_attempted"), - maxXp: :max_xp, - xp: &(&1.xp || 0), - coverImage: :cover_picture, - private: &password_protected?(&1.password), - isPublished: :is_published, - questionCount: :question_count, - gradedCount: &(&1.graded_count || 0), - maxTeamSize: :max_team_size - }) - end - - def render("show.json", %{assessment: assessment}) do - transform_map_for_view( - assessment, - %{ - id: :id, - courseId: :course_id, - title: :title, - type: & &1.config.type, - story: :story, - number: :number, - reading: :reading, - longSummary: :summary_long, - missionPDF: &Cadet.Assessments.Upload.url({&1.mission_pdf, &1}), - questions: - &Enum.map(&1.questions, fn question -> - map = - build_question_with_answer_and_solution_if_ungraded(%{ - question: question - }) - - map - end) - } - ) - end - - defp password_protected?(nil), do: false - - defp password_protected?(_), do: true -end +defmodule CadetWeb.AdminAssessmentsView do + use CadetWeb, :view + use Timex + import CadetWeb.AssessmentsHelpers + + def render("index.json", %{assessments: assessments}) do + render_many(assessments, CadetWeb.AdminAssessmentsView, "overview.json", as: :assessment) + end + + def render("overview.json", %{assessment: assessment}) do + transform_map_for_view(assessment, %{ + id: :id, + courseId: :course_id, + title: :title, + shortSummary: :summary_short, + openAt: &format_datetime(&1.open_at), + closeAt: &format_datetime(&1.close_at), + type: & &1.config.type, + isManuallyGraded: & &1.config.is_manually_graded, + story: :story, + number: :number, + reading: :reading, + status: &(&1.user_status || "not_attempted"), + maxXp: :max_xp, + xp: &(&1.xp || 0), + coverImage: :cover_picture, + private: &password_protected?(&1.password), + isPublished: :is_published, + questionCount: :question_count, + gradedCount: &(&1.graded_count || 0), + maxTeamSize: :max_team_size + }) + end + + def render("show.json", %{assessment: assessment}) do + transform_map_for_view( + assessment, + %{ + id: :id, + courseId: :course_id, + title: :title, + type: & &1.config.type, + story: :story, + number: :number, + reading: :reading, + longSummary: :summary_long, + missionPDF: &Cadet.Assessments.Upload.url({&1.mission_pdf, &1}), + questions: + &Enum.map(&1.questions, fn question -> + map = + build_question_with_answer_and_solution_if_ungraded(%{ + question: question + }) + + map + end) + } + ) + end + + defp password_protected?(nil), do: false + + defp password_protected?(_), do: true +end diff --git a/lib/cadet_web/controllers/assessments_controller.ex b/lib/cadet_web/controllers/assessments_controller.ex index e8b5d3df3..d5a4ea5e5 100644 --- a/lib/cadet_web/controllers/assessments_controller.ex +++ b/lib/cadet_web/controllers/assessments_controller.ex @@ -1,403 +1,403 @@ -defmodule CadetWeb.AssessmentsController do - use CadetWeb, :controller - - use PhoenixSwagger - - alias Cadet.Assessments - - # These roles can save and finalise answers for closed assessments and - # submitted answers - @bypass_closed_roles ~w(staff admin)a - - def submit(conn, %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do - cr = conn.assigns.course_reg - - with {:submission, submission} when not is_nil(submission) <- - {:submission, Assessments.get_submission(assessment_id, cr)}, - {:is_open?, true} <- - {:is_open?, - cr.role in @bypass_closed_roles or Assessments.is_open?(submission.assessment)}, - {:ok, _nil} <- Assessments.finalise_submission(submission) do - text(conn, "OK") - else - {:submission, nil} -> - conn - |> put_status(:not_found) - |> text("Submission not found") - - {:is_open?, false} -> - conn - |> put_status(:forbidden) - |> text("Assessment not open") - - {:error, {status, message}} -> - conn - |> put_status(status) - |> text(message) - end - end - - def index(conn, _) do - cr = conn.assigns.course_reg - {:ok, assessments} = Assessments.all_assessments(cr) - - render(conn, "index.json", assessments: assessments) - end - - def show(conn, %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do - cr = conn.assigns.course_reg - - case Assessments.assessment_with_questions_and_answers(assessment_id, cr) do - {:ok, assessment} -> render(conn, "show.json", assessment: assessment) - {:error, {status, message}} -> send_resp(conn, status, message) - end - end - - def unlock(conn, %{"assessmentid" => assessment_id, "password" => password}) - when is_ecto_id(assessment_id) do - cr = conn.assigns.course_reg - - case Assessments.assessment_with_questions_and_answers(assessment_id, cr, password) do - {:ok, assessment} -> render(conn, "show.json", assessment: assessment) - {:error, {status, message}} -> send_resp(conn, status, message) - end - end - - swagger_path :submit do - post("/assessments/{assessmentId}/submit") - summary("Finalise submission for an assessment") - security([%{JWT: []}]) - - parameters do - assessmentId(:path, :integer, "Assessment ID", required: true) - end - - response(200, "OK") - - response( - 400, - "Invalid parameters or incomplete submission (submission with unanswered questions)" - ) - - response(403, "User not permitted to answer questions or assessment not open") - response(404, "Submission not found") - end - - swagger_path :index do - get("/assessments") - - summary("Get a list of all assessments") - - security([%{JWT: []}]) - - produces("application/json") - - response(200, "OK", Schema.ref(:AssessmentsList)) - response(401, "Unauthorised") - end - - swagger_path :show do - get("/assessments/{assessmentId}") - - summary("Get information about one particular assessment") - - security([%{JWT: []}]) - - consumes("application/json") - produces("application/json") - - parameters do - assessmentId(:path, :integer, "Assessment ID", required: true) - end - - response(200, "OK", Schema.ref(:Assessment)) - response(400, "Missing parameter(s) or invalid assessmentId") - response(401, "Unauthorised") - end - - swagger_path :unlock do - post("/assessments/{assessmentId}/unlock") - - summary("Unlocks a password-protected assessment and returns its information") - - security([%{JWT: []}]) - - consumes("application/json") - produces("application/json") - - parameters do - assessmentId(:path, :integer, "Assessment ID", required: true) - - password(:body, Schema.ref(:UnlockAssessmentPayload), "Password to unlock assessment", - required: true - ) - end - - response(200, "OK", Schema.ref(:Assessment)) - response(400, "Missing parameter(s) or invalid assessmentId") - response(401, "Unauthorised") - response(403, "Password incorrect") - end - - def swagger_definitions do - %{ - AssessmentsList: - swagger_schema do - description("A list of all assessments") - type(:array) - items(Schema.ref(:AssessmentOverview)) - end, - AssessmentOverview: - swagger_schema do - properties do - id(:integer, "The assessment ID", required: true) - title(:string, "The title of the assessment", required: true) - - config(Schema.ref(:AssessmentConfig), "The assessment config", required: true) - - shortSummary(:string, "Short summary", required: true) - - number( - :string, - "The string identifying the relative position of this assessment", - required: true - ) - - story(:string, "The story that should be shown for this assessment") - reading(:string, "The reading for this assessment") - openAt(:string, "The opening date", format: "date-time", required: true) - closeAt(:string, "The closing date", format: "date-time", required: true) - - status( - Schema.ref(:AssessmentStatus), - "One of 'not_attempted/attempting/attempted/submitted' indicating whether the assessment has been attempted by the current user", - required: true - ) - - maxXp( - :integer, - "The maximum XP for this assessment", - required: true - ) - - xp(:integer, "The XP earned for this assessment", required: true) - - coverImage(:string, "The URL to the cover picture", required: true) - - private(:boolean, "Is this an private assessment?", required: true) - - isPublished(:boolean, "Is the assessment published?", required: true) - - questionCount(:integer, "The number of questions in this assessment", required: true) - - gradedCount( - :integer, - "The number of answers in the submission which have been graded", - required: true - ) - - maxTeamSize(:integer, "The maximum team size allowed", required: true) - end - end, - Assessment: - swagger_schema do - properties do - id(:integer, "The assessment ID", required: true) - title(:string, "The title of the assessment", required: true) - - config(Schema.ref(:AssessmentConfig), "The assessment config", required: true) - - number( - :string, - "The string identifying the relative position of this assessment", - required: true - ) - - story(:string, "The story that should be shown for this assessment") - reading(:string, "The reading for this assessment") - longSummary(:string, "Long summary", required: true) - missionPDF(:string, "The URL to the assessment pdf") - - questions(Schema.ref(:Questions), "The list of questions for this assessment") - end - end, - AssessmentConfig: - swagger_schema do - description("Assessment config") - type(:string) - enum([:mission, :sidequest, :path, :contest, :practical]) - end, - AssessmentStatus: - swagger_schema do - type(:string) - enum([:not_attempted, :attempting, :attempted, :submitted]) - end, - Questions: - swagger_schema do - description("A list of questions") - type(:array) - items(Schema.ref(:Question)) - end, - Question: - swagger_schema do - properties do - id(:integer, "The question ID", required: true) - type(:string, "The question type (mcq/programming)", required: true) - content(:string, "The question content", required: true) - - choices( - Schema.new do - type(:array) - items(Schema.ref(:MCQChoice)) - end, - "MCQ choices if question type is mcq" - ) - - solution(:integer, "Solution to a mcq question if it belongs to path assessment") - - answer( - # Note: this is technically an invalid type in Swagger/OpenAPI 2.0, - # but represents that a string or integer could be returned. - :string_or_integer, - "Previous answer for this question (string/int) depending on question type", - required: true - ) - - library( - Schema.ref(:Library), - "The library used for this question" - ) - - prepend(:string, "Prepend program for programming questions") - solutionTemplate(:string, "Solution template for programming questions") - postpend(:string, "Postpend program for programming questions") - - testcases( - Schema.new do - type(:array) - items(Schema.ref(:Testcase)) - end, - "Testcase programs for programming questions" - ) - - grader(Schema.ref(:GraderInfo)) - - gradedAt(:string, "Last graded at", format: "date-time", required: false) - - xp(:integer, "Final XP given to this question. Only provided for students.") - grade(:integer, "Final grade given to this question. Only provided for students.") - comments(:string, "String of comments given to a student's answer", required: false) - - maxGrade( - :integer, - "The max grade for this question", - required: true - ) - - maxXp( - :integer, - "The max xp for this question", - required: true - ) - - autogradingStatus(Schema.ref(:AutogradingStatus), "The status of the autograder") - - autogradingResults( - Schema.new do - type(:array) - items(Schema.ref(:AutogradingResult)) - end - ) - end - end, - MCQChoice: - swagger_schema do - properties do - content(:string, "The choice content", required: true) - hint(:string, "The hint", required: true) - end - end, - ExternalLibrary: - swagger_schema do - properties do - name(:string, "Name of the external library", required: true) - - symbols( - Schema.new do - type(:array) - - items( - Schema.new do - type(:string) - end - ) - end - ) - end - end, - Library: - swagger_schema do - properties do - chapter(:integer) - - globals( - Schema.new do - type(:array) - - items( - Schema.new do - type(:string) - end - ) - end - ) - - external( - Schema.ref(:ExternalLibrary), - "The external library for this question" - ) - end - end, - Testcase: - swagger_schema do - properties do - answer(:string) - score(:integer) - program(:string) - type(Schema.ref(:TestcaseType), "One of public/opaque/secret") - end - end, - TestcaseType: - swagger_schema do - type(:string) - enum([:public, :opaque, :secret]) - end, - AutogradingResult: - swagger_schema do - properties do - resultType(Schema.ref(:AutogradingResultType), "One of pass/fail/error") - expected(:string) - actual(:string) - end - end, - AutogradingResultType: - swagger_schema do - type(:string) - enum([:pass, :fail, :error]) - end, - AutogradingStatus: - swagger_schema do - type(:string) - enum([:none, :processing, :success, :failed]) - end, - - # Schemas for payloads to modify data - UnlockAssessmentPayload: - swagger_schema do - properties do - password(:string, "Password", required: true) - end - end - } - end -end +defmodule CadetWeb.AssessmentsController do + use CadetWeb, :controller + + use PhoenixSwagger + + alias Cadet.Assessments + + # These roles can save and finalise answers for closed assessments and + # submitted answers + @bypass_closed_roles ~w(staff admin)a + + def submit(conn, %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do + cr = conn.assigns.course_reg + + with {:submission, submission} when not is_nil(submission) <- + {:submission, Assessments.get_submission(assessment_id, cr)}, + {:is_open?, true} <- + {:is_open?, + cr.role in @bypass_closed_roles or Assessments.is_open?(submission.assessment)}, + {:ok, _nil} <- Assessments.finalise_submission(submission) do + text(conn, "OK") + else + {:submission, nil} -> + conn + |> put_status(:not_found) + |> text("Submission not found") + + {:is_open?, false} -> + conn + |> put_status(:forbidden) + |> text("Assessment not open") + + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + end + end + + def index(conn, _) do + cr = conn.assigns.course_reg + {:ok, assessments} = Assessments.all_assessments(cr) + + render(conn, "index.json", assessments: assessments) + end + + def show(conn, %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do + cr = conn.assigns.course_reg + + case Assessments.assessment_with_questions_and_answers(assessment_id, cr) do + {:ok, assessment} -> render(conn, "show.json", assessment: assessment) + {:error, {status, message}} -> send_resp(conn, status, message) + end + end + + def unlock(conn, %{"assessmentid" => assessment_id, "password" => password}) + when is_ecto_id(assessment_id) do + cr = conn.assigns.course_reg + + case Assessments.assessment_with_questions_and_answers(assessment_id, cr, password) do + {:ok, assessment} -> render(conn, "show.json", assessment: assessment) + {:error, {status, message}} -> send_resp(conn, status, message) + end + end + + swagger_path :submit do + post("/assessments/{assessmentId}/submit") + summary("Finalise submission for an assessment") + security([%{JWT: []}]) + + parameters do + assessmentId(:path, :integer, "Assessment ID", required: true) + end + + response(200, "OK") + + response( + 400, + "Invalid parameters or incomplete submission (submission with unanswered questions)" + ) + + response(403, "User not permitted to answer questions or assessment not open") + response(404, "Submission not found") + end + + swagger_path :index do + get("/assessments") + + summary("Get a list of all assessments") + + security([%{JWT: []}]) + + produces("application/json") + + response(200, "OK", Schema.ref(:AssessmentsList)) + response(401, "Unauthorised") + end + + swagger_path :show do + get("/assessments/{assessmentId}") + + summary("Get information about one particular assessment") + + security([%{JWT: []}]) + + consumes("application/json") + produces("application/json") + + parameters do + assessmentId(:path, :integer, "Assessment ID", required: true) + end + + response(200, "OK", Schema.ref(:Assessment)) + response(400, "Missing parameter(s) or invalid assessmentId") + response(401, "Unauthorised") + end + + swagger_path :unlock do + post("/assessments/{assessmentId}/unlock") + + summary("Unlocks a password-protected assessment and returns its information") + + security([%{JWT: []}]) + + consumes("application/json") + produces("application/json") + + parameters do + assessmentId(:path, :integer, "Assessment ID", required: true) + + password(:body, Schema.ref(:UnlockAssessmentPayload), "Password to unlock assessment", + required: true + ) + end + + response(200, "OK", Schema.ref(:Assessment)) + response(400, "Missing parameter(s) or invalid assessmentId") + response(401, "Unauthorised") + response(403, "Password incorrect") + end + + def swagger_definitions do + %{ + AssessmentsList: + swagger_schema do + description("A list of all assessments") + type(:array) + items(Schema.ref(:AssessmentOverview)) + end, + AssessmentOverview: + swagger_schema do + properties do + id(:integer, "The assessment ID", required: true) + title(:string, "The title of the assessment", required: true) + + config(Schema.ref(:AssessmentConfig), "The assessment config", required: true) + + shortSummary(:string, "Short summary", required: true) + + number( + :string, + "The string identifying the relative position of this assessment", + required: true + ) + + story(:string, "The story that should be shown for this assessment") + reading(:string, "The reading for this assessment") + openAt(:string, "The opening date", format: "date-time", required: true) + closeAt(:string, "The closing date", format: "date-time", required: true) + + status( + Schema.ref(:AssessmentStatus), + "One of 'not_attempted/attempting/attempted/submitted' indicating whether the assessment has been attempted by the current user", + required: true + ) + + maxXp( + :integer, + "The maximum XP for this assessment", + required: true + ) + + xp(:integer, "The XP earned for this assessment", required: true) + + coverImage(:string, "The URL to the cover picture", required: true) + + private(:boolean, "Is this an private assessment?", required: true) + + isPublished(:boolean, "Is the assessment published?", required: true) + + questionCount(:integer, "The number of questions in this assessment", required: true) + + gradedCount( + :integer, + "The number of answers in the submission which have been graded", + required: true + ) + + maxTeamSize(:integer, "The maximum team size allowed", required: true) + end + end, + Assessment: + swagger_schema do + properties do + id(:integer, "The assessment ID", required: true) + title(:string, "The title of the assessment", required: true) + + config(Schema.ref(:AssessmentConfig), "The assessment config", required: true) + + number( + :string, + "The string identifying the relative position of this assessment", + required: true + ) + + story(:string, "The story that should be shown for this assessment") + reading(:string, "The reading for this assessment") + longSummary(:string, "Long summary", required: true) + missionPDF(:string, "The URL to the assessment pdf") + + questions(Schema.ref(:Questions), "The list of questions for this assessment") + end + end, + AssessmentConfig: + swagger_schema do + description("Assessment config") + type(:string) + enum([:mission, :sidequest, :path, :contest, :practical]) + end, + AssessmentStatus: + swagger_schema do + type(:string) + enum([:not_attempted, :attempting, :attempted, :submitted]) + end, + Questions: + swagger_schema do + description("A list of questions") + type(:array) + items(Schema.ref(:Question)) + end, + Question: + swagger_schema do + properties do + id(:integer, "The question ID", required: true) + type(:string, "The question type (mcq/programming)", required: true) + content(:string, "The question content", required: true) + + choices( + Schema.new do + type(:array) + items(Schema.ref(:MCQChoice)) + end, + "MCQ choices if question type is mcq" + ) + + solution(:integer, "Solution to a mcq question if it belongs to path assessment") + + answer( + # Note: this is technically an invalid type in Swagger/OpenAPI 2.0, + # but represents that a string or integer could be returned. + :string_or_integer, + "Previous answer for this question (string/int) depending on question type", + required: true + ) + + library( + Schema.ref(:Library), + "The library used for this question" + ) + + prepend(:string, "Prepend program for programming questions") + solutionTemplate(:string, "Solution template for programming questions") + postpend(:string, "Postpend program for programming questions") + + testcases( + Schema.new do + type(:array) + items(Schema.ref(:Testcase)) + end, + "Testcase programs for programming questions" + ) + + grader(Schema.ref(:GraderInfo)) + + gradedAt(:string, "Last graded at", format: "date-time", required: false) + + xp(:integer, "Final XP given to this question. Only provided for students.") + grade(:integer, "Final grade given to this question. Only provided for students.") + comments(:string, "String of comments given to a student's answer", required: false) + + maxGrade( + :integer, + "The max grade for this question", + required: true + ) + + maxXp( + :integer, + "The max xp for this question", + required: true + ) + + autogradingStatus(Schema.ref(:AutogradingStatus), "The status of the autograder") + + autogradingResults( + Schema.new do + type(:array) + items(Schema.ref(:AutogradingResult)) + end + ) + end + end, + MCQChoice: + swagger_schema do + properties do + content(:string, "The choice content", required: true) + hint(:string, "The hint", required: true) + end + end, + ExternalLibrary: + swagger_schema do + properties do + name(:string, "Name of the external library", required: true) + + symbols( + Schema.new do + type(:array) + + items( + Schema.new do + type(:string) + end + ) + end + ) + end + end, + Library: + swagger_schema do + properties do + chapter(:integer) + + globals( + Schema.new do + type(:array) + + items( + Schema.new do + type(:string) + end + ) + end + ) + + external( + Schema.ref(:ExternalLibrary), + "The external library for this question" + ) + end + end, + Testcase: + swagger_schema do + properties do + answer(:string) + score(:integer) + program(:string) + type(Schema.ref(:TestcaseType), "One of public/opaque/secret") + end + end, + TestcaseType: + swagger_schema do + type(:string) + enum([:public, :opaque, :secret]) + end, + AutogradingResult: + swagger_schema do + properties do + resultType(Schema.ref(:AutogradingResultType), "One of pass/fail/error") + expected(:string) + actual(:string) + end + end, + AutogradingResultType: + swagger_schema do + type(:string) + enum([:pass, :fail, :error]) + end, + AutogradingStatus: + swagger_schema do + type(:string) + enum([:none, :processing, :success, :failed]) + end, + + # Schemas for payloads to modify data + UnlockAssessmentPayload: + swagger_schema do + properties do + password(:string, "Password", required: true) + end + end + } + end +end diff --git a/mix.exs b/mix.exs index 361ccc9eb..df2b7cec6 100644 --- a/mix.exs +++ b/mix.exs @@ -1,132 +1,132 @@ -defmodule Cadet.Mixfile do - use Mix.Project - - def project do - [ - app: :cadet, - version: "0.0.1", - elixir: "~> 1.10", - elixirc_paths: elixirc_paths(Mix.env()), - compilers: Mix.compilers() ++ [:phoenix_swagger], - start_permanent: Mix.env() == :prod, - test_coverage: [tool: ExCoveralls], - preferred_cli_env: [ - coveralls: :test, - "coveralls.detail": :test, - "coveralls.post": :test, - "coveralls.html": :test - ], - aliases: aliases(), - deps: deps(), - dialyzer: [ - plt_add_apps: [:mix, :ex_unit], - plt_local_path: "priv/plts", - plt_core_path: "priv/plts" - ], - releases: [ - cadet: [ - steps: [:assemble, :tar] - ] - ] - ] - end - - # Configuration for the OTP application. - # - # Type `mix help compile.app` for more information. - def application do - [ - mod: {Cadet.Application, []}, - extra_applications: [:sentry, :logger, :que, :runtime_tools] - ] - end - - # Specifies which paths to compile per environment. - defp elixirc_paths(:test), do: ["lib", "test/support", "test/factories"] - defp elixirc_paths(:dev), do: ["lib", "test/factories"] - defp elixirc_paths(_), do: ["lib"] - - # Specifies your project dependencies. - # - # Type `mix help deps` for examples and options. - defp deps do - [ - {:arc, "~> 0.11"}, - {:arc_ecto, "~> 0.11"}, - {:corsica, "~> 1.1"}, - {:csv, "~> 2.3"}, - {:ecto_enum, "~> 1.0"}, - {:ex_aws, "~> 2.1", override: true}, - {:ex_aws_lambda, "~> 2.0"}, - {:ex_aws_s3, "~> 2.0"}, - {:ex_aws_secretsmanager, "~> 2.0"}, - {:ex_aws_sts, "~> 2.1"}, - {:ex_json_schema, "~> 0.7.4"}, - {:ex_machina, "~> 2.3"}, - {:guardian, "~> 2.0"}, - {:guardian_db, "~> 2.0"}, - {:hackney, "~> 1.6"}, - {:httpoison, "~> 1.6"}, - {:jason, "~> 1.2"}, - {:openid_connect, "~> 0.2"}, - {:phoenix, "~> 1.5"}, - {:phoenix_view, "~> 2.0"}, - {:phoenix_ecto, "~> 4.0"}, - {:phoenix_swagger, "~> 0.8"}, - {:plug_cowboy, "~> 2.0"}, - {:postgrex, ">= 0.0.0"}, - {:quantum, "~> 3.0"}, - {:que, "~> 0.10"}, - {:recase, "~> 0.7", override: true}, - {:sentry, "~> 8.0"}, - {:sweet_xml, "~> 0.6"}, - {:timex, "~> 3.7"}, - - # notifiations system dependencies - {:phoenix_html, "~> 3.0"}, - {:bamboo, "~> 2.3.0"}, - {:bamboo_ses, "~> 0.3.0"}, - {:bamboo_phoenix, "~> 1.0.0"}, - {:oban, "~> 2.13"}, - - # development dependencies - {:configparser_ex, "~> 4.0", only: [:dev, :test]}, - {:credo, "~> 1.0", only: [:dev, :test], runtime: false}, - {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, - {:distillery, "~> 2.1", runtime: false}, - {:faker, "~> 0.10", only: [:dev, :test]}, - {:git_hooks, "~> 0.4", only: [:dev, :test]}, - - # RC to fix https://github.com/rrrene/inch_ex/pull/68 - {:inch_ex, "~> 2.1-rc", only: [:dev, :test]}, - - # unit testing dependencies - {:bypass, "~> 2.1", only: :test}, - {:excoveralls, "~> 0.8", only: :test}, - {:exvcr, "~> 0.10", only: :test}, - {:mock, "~> 0.3.0", only: :test}, - - # The following are indirect dependencies, but we need to override the - # versions due to conflicts - {:jsx, "~> 3.1", override: true}, - {:xml_builder, "~> 2.1", override: true} - ] - end - - # Aliases are shortcuts or tasks specific to the current project. - # For example, to create, migrate and run the seeds file at once: - # - # $ mix ecto.setup - # - # See the documentation for `Mix` for more info on aliases. - defp aliases do - [ - "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], - "ecto.reset": ["ecto.drop", "ecto.setup"], - test: ["ecto.create --quiet", "ecto.migrate", "test"], - "phx.server": ["cadet.server"], - "phx.digest": ["cadet.digest"], - sentry_recompile: ["deps.compile sentry --force", "compile"] - ] - end -end +defmodule Cadet.Mixfile do + use Mix.Project + + def project do + [ + app: :cadet, + version: "0.0.1", + elixir: "~> 1.10", + elixirc_paths: elixirc_paths(Mix.env()), + compilers: Mix.compilers() ++ [:phoenix_swagger], + start_permanent: Mix.env() == :prod, + test_coverage: [tool: ExCoveralls], + preferred_cli_env: [ + coveralls: :test, + "coveralls.detail": :test, + "coveralls.post": :test, + "coveralls.html": :test + ], + aliases: aliases(), + deps: deps(), + dialyzer: [ + plt_add_apps: [:mix, :ex_unit], + plt_local_path: "priv/plts", + plt_core_path: "priv/plts" + ], + releases: [ + cadet: [ + steps: [:assemble, :tar] + ] + ] + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {Cadet.Application, []}, + extra_applications: [:sentry, :logger, :que, :runtime_tools] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support", "test/factories"] + defp elixirc_paths(:dev), do: ["lib", "test/factories"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:arc, "~> 0.11"}, + {:arc_ecto, "~> 0.11"}, + {:corsica, "~> 1.1"}, + {:csv, "~> 2.3"}, + {:ecto_enum, "~> 1.0"}, + {:ex_aws, "~> 2.1", override: true}, + {:ex_aws_lambda, "~> 2.0"}, + {:ex_aws_s3, "~> 2.0"}, + {:ex_aws_secretsmanager, "~> 2.0"}, + {:ex_aws_sts, "~> 2.1"}, + {:ex_json_schema, "~> 0.7.4"}, + {:ex_machina, "~> 2.3"}, + {:guardian, "~> 2.0"}, + {:guardian_db, "~> 2.0"}, + {:hackney, "~> 1.6"}, + {:httpoison, "~> 1.6"}, + {:jason, "~> 1.2"}, + {:openid_connect, "~> 0.2"}, + {:phoenix, "~> 1.5"}, + {:phoenix_view, "~> 2.0"}, + {:phoenix_ecto, "~> 4.0"}, + {:phoenix_swagger, "~> 0.8"}, + {:plug_cowboy, "~> 2.0"}, + {:postgrex, ">= 0.0.0"}, + {:quantum, "~> 3.0"}, + {:que, "~> 0.10"}, + {:recase, "~> 0.7", override: true}, + {:sentry, "~> 8.0"}, + {:sweet_xml, "~> 0.6"}, + {:timex, "~> 3.7"}, + + # notifiations system dependencies + {:phoenix_html, "~> 3.0"}, + {:bamboo, "~> 2.3.0"}, + {:bamboo_ses, "~> 0.3.0"}, + {:bamboo_phoenix, "~> 1.0.0"}, + {:oban, "~> 2.13"}, + + # development dependencies + {:configparser_ex, "~> 4.0", only: [:dev, :test]}, + {:credo, "~> 1.0", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, + {:distillery, "~> 2.1", runtime: false}, + {:faker, "~> 0.10", only: [:dev, :test]}, + {:git_hooks, "~> 0.4", only: [:dev, :test]}, + + # RC to fix https://github.com/rrrene/inch_ex/pull/68 + {:inch_ex, "~> 2.1-rc", only: [:dev, :test]}, + + # unit testing dependencies + {:bypass, "~> 2.1", only: :test}, + {:excoveralls, "~> 0.8", only: :test}, + {:exvcr, "~> 0.10", only: :test}, + {:mock, "~> 0.3.0", only: :test}, + + # The following are indirect dependencies, but we need to override the + # versions due to conflicts + {:jsx, "~> 3.1", override: true}, + {:xml_builder, "~> 2.1", override: true} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to create, migrate and run the seeds file at once: + # + # $ mix ecto.setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate", "test"], + "phx.server": ["cadet.server"], + "phx.digest": ["cadet.digest"], + sentry_recompile: ["deps.compile sentry --force", "compile"] + ] + end +end diff --git a/mix.lock b/mix.lock index 1eb412fe1..45a64308d 100644 --- a/mix.lock +++ b/mix.lock @@ -1,90 +1,90 @@ -%{ - "arc": {:hex, :arc, "0.11.0", "ac7a0cc03035317b6fef9fe94c97d7d9bd183a3e7ce1606aa0c175cfa8d1ba6d", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:ex_aws_s3, "~> 2.0", [hex: :ex_aws_s3, repo: "hexpm", optional: true]}, {:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.1", [hex: :poison, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "e91a8bd676fca716f6e46275ae81fb96c0bbc7a9d5b96cac511ae190588eddd0"}, - "arc_ecto": {:hex, :arc_ecto, "0.11.3", "52f278330fe3a29472ce5d9682514ca09eaed4b33453cbaedb5241a491464f7d", [:mix], [{:arc, "~> 0.11.0", [hex: :arc, repo: "hexpm", optional: false]}, {:ecto, ">= 2.1.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "24beed35003707434a778caece7d71e46e911d46d1e82e7787345264fc8e96d0"}, - "artificery": {:hex, :artificery, "0.4.3", "0bc4260f988dcb9dda4b23f9fc3c6c8b99a6220a331534fdf5bf2fd0d4333b02", [:mix], [], "hexpm", "12e95333a30e20884e937abdbefa3e7f5e05609c2ba8cf37b33f000b9ffc0504"}, - "bamboo": {:hex, :bamboo, "2.3.0", "d2392a2cabe91edf488553d3c70638b532e8db7b76b84b0a39e3dfe492ffd6fc", [:mix], [{:hackney, ">= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.4 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dd0037e68e108fd04d0e8773921512c940e35d981e097b5793543e3b2f9cd3f6"}, - "bamboo_phoenix": {:hex, :bamboo_phoenix, "1.0.0", "f3cc591ffb163ed0bf935d256f1f4645cd870cf436545601215745fb9cc9953f", [:mix], [{:bamboo, ">= 2.0.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.3.0", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "6db88fbb26019c84a47994bb2bd879c0887c29ce6c559bc6385fd54eb8b37dee"}, - "bamboo_ses": {:hex, :bamboo_ses, "0.3.1", "3c172fc5bf2bbb1f9eec632750496ae1e6468cec4c2f0ac2a6b04351a674e2f2", [:mix], [{:bamboo, "~> 2.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:ex_aws, "~> 2.4.1", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "0.2.2", [hex: :mail, repo: "hexpm", optional: false]}], "hexpm", "7e67997479115501da674627b15322a570b41042fc0031be8a5c80e734354c26"}, - "blankable": {:hex, :blankable, "1.0.0", "89ab564a63c55af117e115144e3b3b57eb53ad43ba0f15553357eb283e0ed425", [:mix], [], "hexpm", "7cf11aac0e44f4eedbee0c15c1d37d94c090cb72a8d9fddf9f7aec30f9278899"}, - "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, - "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, - "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, - "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, - "configparser_ex": {:hex, :configparser_ex, "4.0.0", "17e2b831cfa33a08c56effc610339b2986f0d82a9caa0ed18880a07658292ab6", [:mix], [], "hexpm", "02e6d1a559361a063cba7b75bc3eb2d6ad7e62730c551cc4703541fd11e65e5b"}, - "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, - "corsica": {:hex, :corsica, "1.2.0", "5774cb77fd1d66ab89ffc2f04b2249f8e386bc37790a9f4bf101330ca247c02d", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c71f870555ce7a3eded55bbe937234cc48c546e73ce75745df9f59531687a759"}, - "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, - "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, - "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, - "credo": {:hex, :credo, "1.6.4", "ddd474afb6e8c240313f3a7b0d025cc3213f0d171879429bf8535d7021d9ad78", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "c28f910b61e1ff829bffa056ef7293a8db50e87f2c57a9b5c3f57eee124536b7"}, - "crontab": {:hex, :crontab, "1.1.11", "4028ced51b813a5061f85b689d4391ef0c27550c8ab09aaf139e4295c3d93ea4", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "ecb045f9ac14a3e2990e54368f70cdb6e2f2abafc5bc329d6c31f0c74b653787"}, - "csv": {:hex, :csv, "2.4.1", "50e32749953b6bf9818dbfed81cf1190e38cdf24f95891303108087486c5925e", [:mix], [{:parallel_stream, "~> 1.0.4", [hex: :parallel_stream, repo: "hexpm", optional: false]}], "hexpm", "54508938ac67e27966b10ef49606e3ad5995d665d7fc2688efb3eab1307c9079"}, - "db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"}, - "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, - "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, - "distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm", "bbc7008b0161a6f130d8d903b5b3232351fccc9c31a991f8fcbf2a12ace22995"}, - "ecto": {:hex, :ecto, "3.8.4", "e06b8b87e62b27fea17fd2ff6041572ddd10339fd16cdf58446e402c6c90a74b", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f9244288b8d42db40515463a008cf3f4e0e564bb9c249fe87bf28a6d79fe82d4"}, - "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, - "ecto_sql": {:hex, :ecto_sql, "3.8.3", "a7d22c624202546a39d615ed7a6b784580391e65723f2d24f65941b4dd73d471", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.8.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "348cb17fb9e6daf6f251a87049eafcb57805e2892e5e6a0f5dea0985d367329b"}, - "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, - "ex_aws": {:hex, :ex_aws, "2.4.2", "d2686c34b69287cc8dd7629e70131aec05fef3cd3eae13698c9422933f7bc9ee", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0a2c07bd1541b0bef315f67e050d3cb9f947ab1a281896a8c35e3ee4976889f6"}, - "ex_aws_lambda": {:hex, :ex_aws_lambda, "2.1.0", "f28bffae6dde34ba17ef815ef50a82a1e7f3af26d36fe31dbda3a7657e0de449", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "25608630b8b45fe22b0237696662f88be724bc89f77ee42708a5871511f531a0"}, - "ex_aws_s3": {:hex, :ex_aws_s3, "2.3.3", "61412e524616ea31d3f31675d8bc4c73f277e367dee0ae8245610446f9b778aa", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "0044f0b6f9ce925666021eafd630de64c2b3404d79c85245cc7c8a9a32d7f104"}, - "ex_aws_secretsmanager": {:hex, :ex_aws_secretsmanager, "2.0.0", "deff8c12335f0160882afeb9687e55a97fddcd7d9a82fc3a6fbb270797374773", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "8b2838af536c32263ff797012b29e87bad73ef34f43cfa60ebca8e84576f6d45"}, - "ex_aws_sts": {:hex, :ex_aws_sts, "2.3.0", "ce48c4cba7f1595a7d544458d0202ca313124026dba7b1a0021bbb1baa3d66d0", [:mix], [{:ex_aws, "~> 2.2", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "f14e4c7da3454514bf253b331e9422d25825485c211896ab3b81d2a4bdbf62f5"}, - "ex_json_schema": {:hex, :ex_json_schema, "0.7.4", "09eb5b0c8184e5702bc89625a9d0c05c7a0a845d382e9f6f406a0fc1c9a8cc3f", [:mix], [], "hexpm", "45c67fa840f0d719a2b5578126dc29bcdc1f92499c0f61bcb8a3bcb5935f9684"}, - "ex_machina": {:hex, :ex_machina, "2.7.0", "b792cc3127fd0680fecdb6299235b4727a4944a09ff0fa904cc639272cd92dc7", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "419aa7a39bde11894c87a615c4ecaa52d8f107bbdd81d810465186f783245bf8"}, - "ex_utils": {:hex, :ex_utils, "0.1.7", "2c133e0bcdc49a858cf8dacf893308ebc05bc5fba501dc3d2935e65365ec0bf3", [:mix], [], "hexpm", "66d4fe75285948f2d1e69c2a5ddd651c398c813574f8d36a9eef11dc20356ef6"}, - "exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], [], "hexpm", "1222419f706e01bfa1095aec9acf6421367dcfab798a6f67c54cf784733cd6b5"}, - "excoveralls": {:hex, :excoveralls, "0.16.1", "0bd42ed05c7d2f4d180331a20113ec537be509da31fed5c8f7047ce59ee5a7c5", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dae763468e2008cf7075a64cb1249c97cb4bc71e236c5c2b5e5cdf1cfa2bf138"}, - "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm", "32e95820a97cffea67830e91514a2ad53b888850442d6d395f53a1ac60c82e07"}, - "exvcr": {:hex, :exvcr, "0.13.3", "fcd5f54ea0ebd41db7fe16701f3c67871d1b51c3c104ab88f11135a173d47134", [:mix], [{:exactor, "~> 2.2", [hex: :exactor, repo: "hexpm", optional: false]}, {:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.1", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "db61057447388b7adc4443a55047d11d09acc75eeb5548507c775a8402e02689"}, - "faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"}, - "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, - "gen_stage": {:hex, :gen_stage, "1.1.2", "b1656cd4ba431ed02c5656fe10cb5423820847113a07218da68eae5d6a260c23", [:mix], [], "hexpm", "9e39af23140f704e2b07a3e29d8f05fd21c2aaf4088ff43cb82be4b9e3148d02"}, - "gettext": {:hex, :gettext, "0.19.1", "564953fd21f29358e68b91634799d9d26989f8d039d7512622efb3c3b1c97892", [:mix], [], "hexpm", "10c656c0912b8299adba9b061c06947511e3f109ab0d18b44a866a4498e77222"}, - "git_hooks": {:hex, :git_hooks, "0.7.3", "09489e94d88dfc767662e22aff2b6208bd7cf555a19dd0e1477cca4683ce0701", [:mix], [{:blankable, "~> 1.0.0", [hex: :blankable, repo: "hexpm", optional: false]}, {:recase, "~> 0.7.0", [hex: :recase, repo: "hexpm", optional: false]}], "hexpm", "d6ddedeb4d3a8602bc3f84e087a38f6150a86d9e790628ed8bc70e6d90681659"}, - "guardian": {:hex, :guardian, "2.2.4", "3dafdc19665411c96b2796d184064d691bc08813a132da5119e39302a252b755", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "6f83d4309c16ec2469da8606bb2a9815512cc2fac1595ad34b79940a224eb110"}, - "guardian_db": {:hex, :guardian_db, "2.1.0", "ec95a9d99cdd1e550555d09a7bb4a340d8887aad0697f594590c2fd74be02426", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:guardian, "~> 1.0 or ~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "f8e7d543ac92c395f3a7fd5acbe6829faeade57d688f7562e2f0fca8f94a0d70"}, - "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, - "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "inch_ex": {:hex, :inch_ex, "2.1.0-rc.1", "7642a8902c0d2ed5d9b5754b2fc88fedf630500d630fc03db7caca2e92dedb36", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4ceee988760f9382d1c1d0b93ea5875727f6071693e89a0a3c49c456ef1be75d"}, - "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, - "jose": {:hex, :jose, "1.11.2", "f4c018ccf4fdce22c71e44d471f15f723cb3efab5d909ab2ba202b5bf35557b3", [:mix, :rebar3], [], "hexpm", "98143fbc48d55f3a18daba82d34fe48959d44538e9697c08f34200fa5f0947d2"}, - "jsx": {:hex, :jsx, "3.1.0", "d12516baa0bb23a59bb35dccaf02a1bd08243fcbb9efe24f2d9d056ccff71268", [:rebar3], [], "hexpm", "0c5cc8fdc11b53cc25cf65ac6705ad39e54ecc56d1c22e4adb8f5a53fb9427f3"}, - "mail": {:hex, :mail, "0.2.2", "b1d31beaa2a7b23d7b84b2794f037ef4dfdaba9e66d877142bedbaf0625b9c16", [:mix], [], "hexpm", "1c9d31548a60c44ded1806369e07a7dd4d05737eb47fa3238bbf2436b3da8a32"}, - "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, - "memento": {:hex, :memento, "0.3.2", "38cfc8ff9bcb1adff7cbd0f3b78a762636b86dff764729d1c82d0464c539bdd0", [:mix], [], "hexpm", "25cf691a98a0cb70262f4a7543c04bab24648cb2041d937eb64154a8d6f8012b"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, - "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, - "mock": {:hex, :mock, "0.3.7", "75b3bbf1466d7e486ea2052a73c6e062c6256fb429d6797999ab02fa32f29e03", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "4da49a4609e41fd99b7836945c26f373623ea968cfb6282742bcb94440cf7e5c"}, - "oban": {:hex, :oban, "2.14.1", "99e28a814ca9faa759cd3f88d9adc56eb5dd0b8d4a5dabb8d2e989cb57c86f52", [:mix], [{:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6c368b5face9b1e96ba42a1d39710c5193f4b38b62c8aeb651e37897aa3feecd"}, - "openid_connect": {:hex, :openid_connect, "0.2.2", "c05055363330deab39ffd89e609db6b37752f255a93802006d83b45596189c0b", [:mix], [{:httpoison, "~> 1.2", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "735769b6d592124b58edd0582554ce638524c0214cd783d8903d33357d74cc13"}, - "parallel_stream": {:hex, :parallel_stream, "1.0.6", "b967be2b23f0f6787fab7ed681b4c45a215a81481fb62b01a5b750fa8f30f76c", [:mix], [], "hexpm", "639b2e8749e11b87b9eb42f2ad325d161c170b39b288ac8d04c4f31f8f0823eb"}, - "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, - "phoenix": {:hex, :phoenix, "1.6.10", "7a9e8348c5c62e7fd2f74a1884b88d98251f87186a430048bfbdbab3e3f46736", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "08cf70d42f61dd0ea381805bac3cddef57b7b92ade5acc6f6036aa25ecaca9a2"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, - "phoenix_html": {:hex, :phoenix_html, "3.3.0", "bf451c71ebdaac8d2f40d3b703435e819ccfbb9ff243140ca3bd10c155f134cc", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "272c5c1533499f0132309936c619186480bafcc2246588f99a69ce85095556ef"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, - "phoenix_swagger": {:hex, :phoenix_swagger, "0.8.3", "298d6204802409d3b0b4fc1013873839478707cf3a62532a9e10fec0e26d0e37", [:mix], [{:ex_json_schema, "~> 0.7.1", [hex: :ex_json_schema, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "3bc0fa9f5b679b8a61b90a52b2c67dd932320e9a84a6f91a4af872a0ab367337"}, - "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, - "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, - "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, - "postgrex": {:hex, :postgrex, "0.16.3", "fac79a81a9a234b11c44235a4494d8565303fa4b9147acf57e48978a074971db", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "aeaae1d2d1322da4e5fe90d241b0a564ce03a3add09d7270fb85362166194590"}, - "quantum": {:hex, :quantum, "3.5.0", "8d2c5ba68c55991e8975aca368e3ab844ba01f4b87c4185a7403280e2c99cf34", [:mix], [{:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.14 or ~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.2", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "cab737d1d9779f43cb1d701f46dd05ea58146fd96238d91c9e0da662c1982bb6"}, - "que": {:hex, :que, "0.10.1", "788ed0ec92ed69bdf9cfb29bf41a94ca6355b8d44959bd0669cf706e557ac891", [:mix], [{:ex_utils, "~> 0.1.6", [hex: :ex_utils, repo: "hexpm", optional: false]}, {:memento, "~> 0.3.0", [hex: :memento, repo: "hexpm", optional: false]}], "hexpm", "a737b365253e75dbd24b2d51acc1d851049e87baae08cd0c94e2bc5cd65088d5"}, - "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, - "recase": {:hex, :recase, "0.7.0", "3f2f719f0886c7a3b7fe469058ec539cb7bbe0023604ae3bce920e186305e5ae", [:mix], [], "hexpm", "36f5756a9f552f4a94b54a695870e32f4e72d5fad9c25e61bc4a3151c08a4e0c"}, - "sentry": {:hex, :sentry, "8.0.6", "c8de1bf0523bc120ec37d596c55260901029ecb0994e7075b0973328779ceef7", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.3", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "051a2d0472162f3137787c7c9d6e6e4ef239de9329c8c45b1f1bf1e9379e1883"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, - "sweet_xml": {:hex, :sweet_xml, "0.7.3", "debb256781c75ff6a8c5cbf7981146312b66f044a2898f453709a53e5031b45b", [:mix], [], "hexpm", "e110c867a1b3fe74bfc7dd9893aa851f0eed5518d0d7cad76d7baafd30e4f5ba"}, - "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, - "telemetry_registry": {:hex, :telemetry_registry, "0.3.0", "6768f151ea53fc0fbca70dbff5b20a8d663ee4e0c0b2ae589590e08658e76f1e", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "492e2adbc609f3e79ece7f29fec363a97a2c484ac78a83098535d6564781e917"}, - "timex": {:hex, :timex, "3.7.8", "0e6e8bf7c0aba95f1e13204889b2446e7a5297b1c8e408f15ab58b2c8dc85f81", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8f3b8edc5faab5205d69e5255a1d64a83b190bab7f16baa78aefcb897cf81435"}, - "tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, - "xml_builder": {:hex, :xml_builder, "2.2.0", "cc5f1eeefcfcde6e90a9b77fb6c490a20bc1b856a7010ce6396f6da9719cbbab", [:mix], [], "hexpm", "9d66d52fb917565d358166a4314078d39ef04d552904de96f8e73f68f64a62c9"}, -} +%{ + "arc": {:hex, :arc, "0.11.0", "ac7a0cc03035317b6fef9fe94c97d7d9bd183a3e7ce1606aa0c175cfa8d1ba6d", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:ex_aws_s3, "~> 2.0", [hex: :ex_aws_s3, repo: "hexpm", optional: true]}, {:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.1", [hex: :poison, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "e91a8bd676fca716f6e46275ae81fb96c0bbc7a9d5b96cac511ae190588eddd0"}, + "arc_ecto": {:hex, :arc_ecto, "0.11.3", "52f278330fe3a29472ce5d9682514ca09eaed4b33453cbaedb5241a491464f7d", [:mix], [{:arc, "~> 0.11.0", [hex: :arc, repo: "hexpm", optional: false]}, {:ecto, ">= 2.1.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "24beed35003707434a778caece7d71e46e911d46d1e82e7787345264fc8e96d0"}, + "artificery": {:hex, :artificery, "0.4.3", "0bc4260f988dcb9dda4b23f9fc3c6c8b99a6220a331534fdf5bf2fd0d4333b02", [:mix], [], "hexpm", "12e95333a30e20884e937abdbefa3e7f5e05609c2ba8cf37b33f000b9ffc0504"}, + "bamboo": {:hex, :bamboo, "2.3.0", "d2392a2cabe91edf488553d3c70638b532e8db7b76b84b0a39e3dfe492ffd6fc", [:mix], [{:hackney, ">= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.4 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dd0037e68e108fd04d0e8773921512c940e35d981e097b5793543e3b2f9cd3f6"}, + "bamboo_phoenix": {:hex, :bamboo_phoenix, "1.0.0", "f3cc591ffb163ed0bf935d256f1f4645cd870cf436545601215745fb9cc9953f", [:mix], [{:bamboo, ">= 2.0.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.3.0", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "6db88fbb26019c84a47994bb2bd879c0887c29ce6c559bc6385fd54eb8b37dee"}, + "bamboo_ses": {:hex, :bamboo_ses, "0.3.1", "3c172fc5bf2bbb1f9eec632750496ae1e6468cec4c2f0ac2a6b04351a674e2f2", [:mix], [{:bamboo, "~> 2.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:ex_aws, "~> 2.4.1", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "0.2.2", [hex: :mail, repo: "hexpm", optional: false]}], "hexpm", "7e67997479115501da674627b15322a570b41042fc0031be8a5c80e734354c26"}, + "blankable": {:hex, :blankable, "1.0.0", "89ab564a63c55af117e115144e3b3b57eb53ad43ba0f15553357eb283e0ed425", [:mix], [], "hexpm", "7cf11aac0e44f4eedbee0c15c1d37d94c090cb72a8d9fddf9f7aec30f9278899"}, + "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, + "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, + "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, + "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, + "configparser_ex": {:hex, :configparser_ex, "4.0.0", "17e2b831cfa33a08c56effc610339b2986f0d82a9caa0ed18880a07658292ab6", [:mix], [], "hexpm", "02e6d1a559361a063cba7b75bc3eb2d6ad7e62730c551cc4703541fd11e65e5b"}, + "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, + "corsica": {:hex, :corsica, "1.2.0", "5774cb77fd1d66ab89ffc2f04b2249f8e386bc37790a9f4bf101330ca247c02d", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c71f870555ce7a3eded55bbe937234cc48c546e73ce75745df9f59531687a759"}, + "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, + "credo": {:hex, :credo, "1.6.4", "ddd474afb6e8c240313f3a7b0d025cc3213f0d171879429bf8535d7021d9ad78", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "c28f910b61e1ff829bffa056ef7293a8db50e87f2c57a9b5c3f57eee124536b7"}, + "crontab": {:hex, :crontab, "1.1.11", "4028ced51b813a5061f85b689d4391ef0c27550c8ab09aaf139e4295c3d93ea4", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "ecb045f9ac14a3e2990e54368f70cdb6e2f2abafc5bc329d6c31f0c74b653787"}, + "csv": {:hex, :csv, "2.4.1", "50e32749953b6bf9818dbfed81cf1190e38cdf24f95891303108087486c5925e", [:mix], [{:parallel_stream, "~> 1.0.4", [hex: :parallel_stream, repo: "hexpm", optional: false]}], "hexpm", "54508938ac67e27966b10ef49606e3ad5995d665d7fc2688efb3eab1307c9079"}, + "db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"}, + "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, + "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, + "distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm", "bbc7008b0161a6f130d8d903b5b3232351fccc9c31a991f8fcbf2a12ace22995"}, + "ecto": {:hex, :ecto, "3.8.4", "e06b8b87e62b27fea17fd2ff6041572ddd10339fd16cdf58446e402c6c90a74b", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f9244288b8d42db40515463a008cf3f4e0e564bb9c249fe87bf28a6d79fe82d4"}, + "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, + "ecto_sql": {:hex, :ecto_sql, "3.8.3", "a7d22c624202546a39d615ed7a6b784580391e65723f2d24f65941b4dd73d471", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.8.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "348cb17fb9e6daf6f251a87049eafcb57805e2892e5e6a0f5dea0985d367329b"}, + "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, + "ex_aws": {:hex, :ex_aws, "2.4.2", "d2686c34b69287cc8dd7629e70131aec05fef3cd3eae13698c9422933f7bc9ee", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0a2c07bd1541b0bef315f67e050d3cb9f947ab1a281896a8c35e3ee4976889f6"}, + "ex_aws_lambda": {:hex, :ex_aws_lambda, "2.1.0", "f28bffae6dde34ba17ef815ef50a82a1e7f3af26d36fe31dbda3a7657e0de449", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "25608630b8b45fe22b0237696662f88be724bc89f77ee42708a5871511f531a0"}, + "ex_aws_s3": {:hex, :ex_aws_s3, "2.3.3", "61412e524616ea31d3f31675d8bc4c73f277e367dee0ae8245610446f9b778aa", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "0044f0b6f9ce925666021eafd630de64c2b3404d79c85245cc7c8a9a32d7f104"}, + "ex_aws_secretsmanager": {:hex, :ex_aws_secretsmanager, "2.0.0", "deff8c12335f0160882afeb9687e55a97fddcd7d9a82fc3a6fbb270797374773", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "8b2838af536c32263ff797012b29e87bad73ef34f43cfa60ebca8e84576f6d45"}, + "ex_aws_sts": {:hex, :ex_aws_sts, "2.3.0", "ce48c4cba7f1595a7d544458d0202ca313124026dba7b1a0021bbb1baa3d66d0", [:mix], [{:ex_aws, "~> 2.2", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "f14e4c7da3454514bf253b331e9422d25825485c211896ab3b81d2a4bdbf62f5"}, + "ex_json_schema": {:hex, :ex_json_schema, "0.7.4", "09eb5b0c8184e5702bc89625a9d0c05c7a0a845d382e9f6f406a0fc1c9a8cc3f", [:mix], [], "hexpm", "45c67fa840f0d719a2b5578126dc29bcdc1f92499c0f61bcb8a3bcb5935f9684"}, + "ex_machina": {:hex, :ex_machina, "2.7.0", "b792cc3127fd0680fecdb6299235b4727a4944a09ff0fa904cc639272cd92dc7", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "419aa7a39bde11894c87a615c4ecaa52d8f107bbdd81d810465186f783245bf8"}, + "ex_utils": {:hex, :ex_utils, "0.1.7", "2c133e0bcdc49a858cf8dacf893308ebc05bc5fba501dc3d2935e65365ec0bf3", [:mix], [], "hexpm", "66d4fe75285948f2d1e69c2a5ddd651c398c813574f8d36a9eef11dc20356ef6"}, + "exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], [], "hexpm", "1222419f706e01bfa1095aec9acf6421367dcfab798a6f67c54cf784733cd6b5"}, + "excoveralls": {:hex, :excoveralls, "0.16.1", "0bd42ed05c7d2f4d180331a20113ec537be509da31fed5c8f7047ce59ee5a7c5", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dae763468e2008cf7075a64cb1249c97cb4bc71e236c5c2b5e5cdf1cfa2bf138"}, + "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm", "32e95820a97cffea67830e91514a2ad53b888850442d6d395f53a1ac60c82e07"}, + "exvcr": {:hex, :exvcr, "0.13.3", "fcd5f54ea0ebd41db7fe16701f3c67871d1b51c3c104ab88f11135a173d47134", [:mix], [{:exactor, "~> 2.2", [hex: :exactor, repo: "hexpm", optional: false]}, {:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.1", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "db61057447388b7adc4443a55047d11d09acc75eeb5548507c775a8402e02689"}, + "faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"}, + "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "gen_stage": {:hex, :gen_stage, "1.1.2", "b1656cd4ba431ed02c5656fe10cb5423820847113a07218da68eae5d6a260c23", [:mix], [], "hexpm", "9e39af23140f704e2b07a3e29d8f05fd21c2aaf4088ff43cb82be4b9e3148d02"}, + "gettext": {:hex, :gettext, "0.19.1", "564953fd21f29358e68b91634799d9d26989f8d039d7512622efb3c3b1c97892", [:mix], [], "hexpm", "10c656c0912b8299adba9b061c06947511e3f109ab0d18b44a866a4498e77222"}, + "git_hooks": {:hex, :git_hooks, "0.7.3", "09489e94d88dfc767662e22aff2b6208bd7cf555a19dd0e1477cca4683ce0701", [:mix], [{:blankable, "~> 1.0.0", [hex: :blankable, repo: "hexpm", optional: false]}, {:recase, "~> 0.7.0", [hex: :recase, repo: "hexpm", optional: false]}], "hexpm", "d6ddedeb4d3a8602bc3f84e087a38f6150a86d9e790628ed8bc70e6d90681659"}, + "guardian": {:hex, :guardian, "2.2.4", "3dafdc19665411c96b2796d184064d691bc08813a132da5119e39302a252b755", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "6f83d4309c16ec2469da8606bb2a9815512cc2fac1595ad34b79940a224eb110"}, + "guardian_db": {:hex, :guardian_db, "2.1.0", "ec95a9d99cdd1e550555d09a7bb4a340d8887aad0697f594590c2fd74be02426", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:guardian, "~> 1.0 or ~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "f8e7d543ac92c395f3a7fd5acbe6829faeade57d688f7562e2f0fca8f94a0d70"}, + "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, + "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "inch_ex": {:hex, :inch_ex, "2.1.0-rc.1", "7642a8902c0d2ed5d9b5754b2fc88fedf630500d630fc03db7caca2e92dedb36", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4ceee988760f9382d1c1d0b93ea5875727f6071693e89a0a3c49c456ef1be75d"}, + "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, + "jose": {:hex, :jose, "1.11.2", "f4c018ccf4fdce22c71e44d471f15f723cb3efab5d909ab2ba202b5bf35557b3", [:mix, :rebar3], [], "hexpm", "98143fbc48d55f3a18daba82d34fe48959d44538e9697c08f34200fa5f0947d2"}, + "jsx": {:hex, :jsx, "3.1.0", "d12516baa0bb23a59bb35dccaf02a1bd08243fcbb9efe24f2d9d056ccff71268", [:rebar3], [], "hexpm", "0c5cc8fdc11b53cc25cf65ac6705ad39e54ecc56d1c22e4adb8f5a53fb9427f3"}, + "mail": {:hex, :mail, "0.2.2", "b1d31beaa2a7b23d7b84b2794f037ef4dfdaba9e66d877142bedbaf0625b9c16", [:mix], [], "hexpm", "1c9d31548a60c44ded1806369e07a7dd4d05737eb47fa3238bbf2436b3da8a32"}, + "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, + "memento": {:hex, :memento, "0.3.2", "38cfc8ff9bcb1adff7cbd0f3b78a762636b86dff764729d1c82d0464c539bdd0", [:mix], [], "hexpm", "25cf691a98a0cb70262f4a7543c04bab24648cb2041d937eb64154a8d6f8012b"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "mock": {:hex, :mock, "0.3.7", "75b3bbf1466d7e486ea2052a73c6e062c6256fb429d6797999ab02fa32f29e03", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "4da49a4609e41fd99b7836945c26f373623ea968cfb6282742bcb94440cf7e5c"}, + "oban": {:hex, :oban, "2.14.1", "99e28a814ca9faa759cd3f88d9adc56eb5dd0b8d4a5dabb8d2e989cb57c86f52", [:mix], [{:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6c368b5face9b1e96ba42a1d39710c5193f4b38b62c8aeb651e37897aa3feecd"}, + "openid_connect": {:hex, :openid_connect, "0.2.2", "c05055363330deab39ffd89e609db6b37752f255a93802006d83b45596189c0b", [:mix], [{:httpoison, "~> 1.2", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "735769b6d592124b58edd0582554ce638524c0214cd783d8903d33357d74cc13"}, + "parallel_stream": {:hex, :parallel_stream, "1.0.6", "b967be2b23f0f6787fab7ed681b4c45a215a81481fb62b01a5b750fa8f30f76c", [:mix], [], "hexpm", "639b2e8749e11b87b9eb42f2ad325d161c170b39b288ac8d04c4f31f8f0823eb"}, + "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, + "phoenix": {:hex, :phoenix, "1.6.10", "7a9e8348c5c62e7fd2f74a1884b88d98251f87186a430048bfbdbab3e3f46736", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "08cf70d42f61dd0ea381805bac3cddef57b7b92ade5acc6f6036aa25ecaca9a2"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, + "phoenix_html": {:hex, :phoenix_html, "3.3.0", "bf451c71ebdaac8d2f40d3b703435e819ccfbb9ff243140ca3bd10c155f134cc", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "272c5c1533499f0132309936c619186480bafcc2246588f99a69ce85095556ef"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, + "phoenix_swagger": {:hex, :phoenix_swagger, "0.8.3", "298d6204802409d3b0b4fc1013873839478707cf3a62532a9e10fec0e26d0e37", [:mix], [{:ex_json_schema, "~> 0.7.1", [hex: :ex_json_schema, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "3bc0fa9f5b679b8a61b90a52b2c67dd932320e9a84a6f91a4af872a0ab367337"}, + "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, + "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, + "postgrex": {:hex, :postgrex, "0.16.3", "fac79a81a9a234b11c44235a4494d8565303fa4b9147acf57e48978a074971db", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "aeaae1d2d1322da4e5fe90d241b0a564ce03a3add09d7270fb85362166194590"}, + "quantum": {:hex, :quantum, "3.5.0", "8d2c5ba68c55991e8975aca368e3ab844ba01f4b87c4185a7403280e2c99cf34", [:mix], [{:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.14 or ~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.2", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "cab737d1d9779f43cb1d701f46dd05ea58146fd96238d91c9e0da662c1982bb6"}, + "que": {:hex, :que, "0.10.1", "788ed0ec92ed69bdf9cfb29bf41a94ca6355b8d44959bd0669cf706e557ac891", [:mix], [{:ex_utils, "~> 0.1.6", [hex: :ex_utils, repo: "hexpm", optional: false]}, {:memento, "~> 0.3.0", [hex: :memento, repo: "hexpm", optional: false]}], "hexpm", "a737b365253e75dbd24b2d51acc1d851049e87baae08cd0c94e2bc5cd65088d5"}, + "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "recase": {:hex, :recase, "0.7.0", "3f2f719f0886c7a3b7fe469058ec539cb7bbe0023604ae3bce920e186305e5ae", [:mix], [], "hexpm", "36f5756a9f552f4a94b54a695870e32f4e72d5fad9c25e61bc4a3151c08a4e0c"}, + "sentry": {:hex, :sentry, "8.0.6", "c8de1bf0523bc120ec37d596c55260901029ecb0994e7075b0973328779ceef7", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.3", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "051a2d0472162f3137787c7c9d6e6e4ef239de9329c8c45b1f1bf1e9379e1883"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, + "sweet_xml": {:hex, :sweet_xml, "0.7.3", "debb256781c75ff6a8c5cbf7981146312b66f044a2898f453709a53e5031b45b", [:mix], [], "hexpm", "e110c867a1b3fe74bfc7dd9893aa851f0eed5518d0d7cad76d7baafd30e4f5ba"}, + "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, + "telemetry_registry": {:hex, :telemetry_registry, "0.3.0", "6768f151ea53fc0fbca70dbff5b20a8d663ee4e0c0b2ae589590e08658e76f1e", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "492e2adbc609f3e79ece7f29fec363a97a2c484ac78a83098535d6564781e917"}, + "timex": {:hex, :timex, "3.7.8", "0e6e8bf7c0aba95f1e13204889b2446e7a5297b1c8e408f15ab58b2c8dc85f81", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8f3b8edc5faab5205d69e5255a1d64a83b190bab7f16baa78aefcb897cf81435"}, + "tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "xml_builder": {:hex, :xml_builder, "2.2.0", "cc5f1eeefcfcde6e90a9b77fb6c490a20bc1b856a7010ce6396f6da9719cbbab", [:mix], [], "hexpm", "9d66d52fb917565d358166a4314078d39ef04d552904de96f8e73f68f64a62c9"}, +} diff --git a/priv/repo/migrations/20190510152804_drop_announcements_table.exs b/priv/repo/migrations/20190510152804_drop_announcements_table.exs index 9b8ae9eb9..8a7c2b08d 100644 --- a/priv/repo/migrations/20190510152804_drop_announcements_table.exs +++ b/priv/repo/migrations/20190510152804_drop_announcements_table.exs @@ -1,7 +1,7 @@ -defmodule Cadet.Repo.Migrations.DropAnnouncementsTable do - use Ecto.Migration - - def change do - drop_if_exists(table(:announcements)) - end -end +defmodule Cadet.Repo.Migrations.DropAnnouncementsTable do + use Ecto.Migration + + def change do + drop_if_exists(table(:announcements)) + end +end diff --git a/test/cadet_web/controllers/assessments_controller_test.exs b/test/cadet_web/controllers/assessments_controller_test.exs index 7f9da3827..709fa960e 100644 --- a/test/cadet_web/controllers/assessments_controller_test.exs +++ b/test/cadet_web/controllers/assessments_controller_test.exs @@ -1,1573 +1,1573 @@ -defmodule CadetWeb.AssessmentsControllerTest do - use CadetWeb.ConnCase - use Timex - - import Ecto.Query - import Mock - - alias Cadet.{Assessments, Repo} - alias Cadet.Accounts.{Role, CourseRegistration} - alias Cadet.Assessments.{Assessment, Submission, SubmissionStatus} - alias Cadet.Autograder.GradingJob - alias CadetWeb.AssessmentsController - - @local_name "test/fixtures/local_repo" - - setup do - File.rm_rf!(@local_name) - - on_exit(fn -> - File.rm_rf!(@local_name) - end) - - Cadet.Test.Seeds.assessments() - end - - test "swagger" do - AssessmentsController.swagger_definitions() - AssessmentsController.swagger_path_index(nil) - AssessmentsController.swagger_path_show(nil) - AssessmentsController.swagger_path_unlock(nil) - AssessmentsController.swagger_path_submit(nil) - end - - describe "GET /, unauthenticated" do - test "unauthorized", %{conn: conn, courses: %{course1: course1}} do - conn = get(conn, build_url(course1.id)) - assert response(conn, 401) =~ "Unauthorised" - end - end - - describe "GET /:assessment_id, unauthenticated" do - test "unauthorized", %{conn: conn, courses: %{course1: course1}} do - conn = get(conn, build_url(course1.id, 1)) - assert response(conn, 401) =~ "Unauthorised" - end - end - - # All roles should see almost the same overview - describe "GET /, all roles" do - test "renders assessments overview", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for {_role, course_reg} <- role_crs do - expected = - assessments - |> Map.values() - |> Enum.map(& &1.assessment) - |> Enum.sort(&open_at_asc_comparator/2) - |> Enum.map( - &%{ - "courseId" => &1.course_id, - "id" => &1.id, - "title" => &1.title, - "shortSummary" => &1.summary_short, - "story" => &1.story, - "number" => &1.number, - "reading" => &1.reading, - "openAt" => format_datetime(&1.open_at), - "closeAt" => format_datetime(&1.close_at), - "type" => &1.config.type, - "isManuallyGraded" => &1.config.is_manually_graded, - "coverImage" => &1.cover_picture, - "maxXp" => 4800, - "status" => get_assessment_status(course_reg, &1), - "private" => false, - "isPublished" => &1.is_published, - "gradedCount" => 0, - "questionCount" => 9 - } - ) - - resp = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id)) - |> json_response(200) - |> Enum.map(&Map.delete(&1, "xp")) - - assert expected == resp - end - end - - test "render password protected assessments properly", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessment_configs: configs, - assessments: assessments - } do - for {_role, course_reg} <- role_crs do - mission = assessments[hd(configs).type] - - {:ok, _} = - mission.assessment - |> Assessment.changeset(%{password: "mysupersecretpassword"}) - |> Repo.update() - - resp = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id)) - |> json_response(200) - |> Enum.find(&(&1["type"] == hd(configs).type)) - |> Map.get("private") - - assert resp == true - end - end - end - - describe "GET /, student only" do - test "does not render unpublished assessments", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: %{student: student}, - assessment_configs: configs, - assessments: assessments - } do - mission = assessments[hd(configs).type] - - {:ok, _} = - mission.assessment - |> Assessment.changeset(%{is_published: false}) - |> Repo.update() - - expected = - assessments - |> Map.delete(hd(configs).type) - |> Map.values() - |> Enum.map(fn a -> a.assessment end) - |> Enum.sort(&open_at_asc_comparator/2) - |> Enum.map( - &%{ - "courseId" => &1.course_id, - "id" => &1.id, - "title" => &1.title, - "shortSummary" => &1.summary_short, - "story" => &1.story, - "number" => &1.number, - "reading" => &1.reading, - "openAt" => format_datetime(&1.open_at), - "closeAt" => format_datetime(&1.close_at), - "type" => &1.config.type, - "isManuallyGraded" => &1.config.is_manually_graded, - "coverImage" => &1.cover_picture, - "maxXp" => 4800, - "status" => get_assessment_status(student, &1), - "private" => false, - "isPublished" => &1.is_published, - "gradedCount" => 0, - "questionCount" => 9 - } - ) - - resp = - conn - |> sign_in(student.user) - |> get(build_url(course1.id)) - |> json_response(200) - |> Enum.map(&Map.delete(&1, "xp")) - - assert expected == resp - end - - test "renders student submission status in overview", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: %{student: student}, - assessment_configs: configs, - assessments: assessments - } do - assessment = assessments[hd(configs).type].assessment - [submission | _] = assessments[hd(configs).type].submissions - - for status <- SubmissionStatus.__enum_map__() do - submission - |> Submission.changeset(%{status: status}) - |> Repo.update() - - resp = - conn - |> sign_in(student.user) - |> get(build_url(course1.id)) - |> json_response(200) - |> Enum.find(&(&1["id"] == assessment.id)) - |> Map.get("status") - - assert get_assessment_status(student, assessment) == resp - end - end - - test "renders xp for students", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: %{student: student}, - assessment_configs: configs, - assessments: assessments - } do - assessment = assessments[hd(configs).type].assessment - - resp = - conn - |> sign_in(student.user) - |> get(build_url(course1.id)) - |> json_response(200) - |> Enum.find(&(&1["id"] == assessment.id)) - |> Map.get("xp") - - assert resp == 800 * 3 + 500 * 3 + 100 * 3 - end - end - - describe "GET /, non-students" do - test "renders unpublished assessments", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessment_configs: configs, - assessments: assessments - } do - for role <- ~w(staff admin)a do - course_reg = Map.get(role_crs, role) - mission = assessments[hd(configs).type] - - {:ok, _} = - mission.assessment - |> Assessment.changeset(%{is_published: false}) - |> Repo.update() - - resp = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id)) - |> json_response(200) - |> Enum.map(&Map.delete(&1, "xp")) - - expected = - assessments - |> Map.values() - |> Enum.map(fn a -> a.assessment end) - |> Enum.sort(&open_at_asc_comparator/2) - |> Enum.map( - &%{ - "id" => &1.id, - "courseId" => &1.course_id, - "title" => &1.title, - "shortSummary" => &1.summary_short, - "story" => &1.story, - "number" => &1.number, - "reading" => &1.reading, - "openAt" => format_datetime(&1.open_at), - "closeAt" => format_datetime(&1.close_at), - "type" => &1.config.type, - "isManuallyGraded" => &1.config.is_manually_graded, - "coverImage" => &1.cover_picture, - "maxXp" => 4800, - "status" => get_assessment_status(course_reg, &1), - "private" => false, - "gradedCount" => 0, - "questionCount" => 9, - "isPublished" => - if &1.config.type == hd(configs).type do - false - else - &1.is_published - end - } - ) - - assert expected == resp - end - end - end - - describe "GET /assessment_id, all roles" do - test "it renders assessment details", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for role <- Role.__enum_map__() do - course_reg = Map.get(role_crs, role) - - for {type, %{assessment: assessment}} <- assessments do - expected_assessments = %{ - "courseId" => assessment.course_id, - "id" => assessment.id, - "title" => assessment.title, - "type" => type, - "story" => assessment.story, - "number" => assessment.number, - "reading" => assessment.reading, - "longSummary" => assessment.summary_long, - "missionPDF" => Cadet.Assessments.Upload.url({assessment.mission_pdf, assessment}) - } - - resp_assessments = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.delete("questions") - - assert expected_assessments == resp_assessments - end - end - end - - test "it renders assessment questions", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for role <- Role.__enum_map__() do - course_reg = Map.get(role_crs, role) - - for {_type, - %{ - assessment: assessment, - mcq_questions: mcq_questions, - programming_questions: programming_questions, - voting_questions: voting_questions - }} <- assessments do - # Programming questions should come first due to seeding order - expected_programming_questions = - Enum.map( - programming_questions, - &%{ - "id" => &1.id, - "type" => "#{&1.type}", - "blocking" => &1.blocking, - "content" => &1.question.content, - "solutionTemplate" => &1.question.template, - "prepend" => &1.question.prepend, - "postpend" => &1.question.postpend, - "testcases" => - Enum.map( - &1.question.public, - fn testcase -> - for {k, v} <- testcase, - into: %{"type" => "public"}, - do: {Atom.to_string(k), v} - end - ) ++ - Enum.map( - &1.question.opaque, - fn testcase -> - for {k, v} <- testcase, - into: %{"type" => "opaque"}, - do: {Atom.to_string(k), v} - end - ) - } - ) - - expected_mcq_questions = - Enum.map( - mcq_questions, - &%{ - "id" => &1.id, - "type" => "#{&1.type}", - "blocking" => &1.blocking, - "content" => &1.question.content, - "choices" => - Enum.map( - &1.question.choices, - fn choice -> - %{ - "id" => choice.choice_id, - "content" => choice.content, - "hint" => choice.hint - } - end - ) - } - ) - - expected_voting_questions = - Enum.map( - voting_questions, - &%{ - "id" => &1.id, - "type" => "#{&1.type}", - "blocking" => &1.blocking, - "content" => &1.question.content, - "solutionTemplate" => &1.question.template, - "prepend" => &1.question.prepend - } - ) - - contests_submissions = - Enum.map(0..2, fn _ -> Enum.map(0..2, fn _ -> insert(:submission) end) end) - - contests_answers = - Enum.map(contests_submissions, fn contest_submissions -> - Enum.map(contest_submissions, fn submission -> - insert(:answer, %{ - submission: submission, - answer: %{code: "return 2;"}, - question: build(:programming_question) - }) - end) - end) - - voting_questions - |> Enum.zip(contests_submissions) - |> Enum.map(fn {question, contest_submissions} -> - Enum.map(contest_submissions, fn submission -> - insert(:submission_vote, %{ - voter: course_reg, - submission: submission, - question: question - }) - end) - end) - - contests_entries = - Enum.map(contests_answers, fn contest_answers -> - Enum.map(contest_answers, fn answer -> - %{ - "submission_id" => answer.submission.id, - "answer" => %{"code" => answer.answer.code}, - "score" => nil - } - end) - end) - - expected_voting_questions = - expected_voting_questions - |> Enum.zip(contests_entries) - |> Enum.map(fn {question, contest_entries} -> - question = Map.put(question, "contestEntries", contest_entries) - Map.put(question, "contestLeaderboard", []) - end) - - expected_questions = - expected_programming_questions ++ expected_mcq_questions ++ expected_voting_questions - - resp_questions = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.map(&Map.delete(&1, "answer")) - |> Enum.map(&Map.delete(&1, "solution")) - |> Enum.map(&Map.delete(&1, "library")) - |> Enum.map(&Map.delete(&1, "xp")) - |> Enum.map(&Map.delete(&1, "maxXp")) - |> Enum.map(&Map.delete(&1, "grader")) - |> Enum.map(&Map.delete(&1, "gradedAt")) - |> Enum.map(&Map.delete(&1, "autogradingResults")) - |> Enum.map(&Map.delete(&1, "autogradingStatus")) - |> Enum.map(&Map.delete(&1, "comments")) - - assert expected_questions == resp_questions - end - end - end - - test "renders open leaderboard for all roles", %{ - conn: conn, - course_regs: course_regs, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - voting_assessment = assessments["practical"].assessment - - voting_assessment - |> Assessment.changeset(%{ - open_at: Timex.shift(Timex.now(), days: -30), - close_at: Timex.shift(Timex.now(), days: -20) - }) - |> Repo.update() - - voting_question = assessments["practical"].voting_questions |> List.first() - contest_assessment_number = voting_question.question.contest_number - - contest_assessment = Repo.get_by(Assessment, number: contest_assessment_number) - - # insert contest question - contest_question = - insert(:programming_question, %{ - display_order: 1, - assessment: contest_assessment, - max_xp: 1000 - }) - - # insert contest submissions and answers - contest_submissions = - for student <- Enum.take(course_regs.students, 5) do - insert(:submission, %{assessment: contest_assessment, student: student}) - end - - contest_answers = - for {submission, score} <- Enum.with_index(contest_submissions, 1) do - insert(:answer, %{ - xp: 1000, - question: contest_question, - submission: submission, - answer: build(:programming_answer), - relative_score: score / 1 - }) - end - - expected_leaderboard = - for answer <- contest_answers do - %{ - "answer" => %{"code" => answer.answer.code}, - "final_score" => answer.relative_score, - "student_name" => answer.submission.student.user.name, - "submission_id" => answer.submission.id - } - end - |> Enum.sort_by(& &1["final_score"], &>=/2) - - for role <- Role.__enum_map__() do - course_reg = Map.get(role_crs, role) - - resp_leaderboard = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, voting_question.assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.find(&(&1["id"] == voting_question.id)) - |> Map.get("contestLeaderboard") - - assert resp_leaderboard == expected_leaderboard - end - end - - test "renders close leaderboard for staff and admin", %{ - conn: conn, - course_regs: course_regs, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - voting_assessment = assessments["practical"].assessment - - voting_assessment - |> Assessment.changeset(%{ - close_at: Timex.shift(Timex.now(), days: 20) - }) - |> Repo.update() - - voting_question = assessments["practical"].voting_questions |> List.first() - contest_assessment_number = voting_question.question.contest_number - - contest_assessment = Repo.get_by(Assessment, number: contest_assessment_number) - - # insert contest question - contest_question = - insert(:programming_question, %{ - display_order: 1, - assessment: contest_assessment, - max_xp: 1000 - }) - - # insert contest submissions and answers - contest_submissions = - for student <- Enum.take(course_regs.students, 5) do - insert(:submission, %{assessment: contest_assessment, student: student}) - end - - contest_answers = - for {submission, score} <- Enum.with_index(contest_submissions, 1) do - insert(:answer, %{ - xp: 1000, - question: contest_question, - submission: submission, - answer: build(:programming_answer), - relative_score: score / 1 - }) - end - - expected_leaderboard = - for answer <- contest_answers do - %{ - "answer" => %{"code" => answer.answer.code}, - "final_score" => answer.relative_score, - "student_name" => answer.submission.student.user.name, - "submission_id" => answer.submission.id - } - end - |> Enum.sort_by(& &1["final_score"], &>=/2) - - for role <- [:admin, :staff] do - course_reg = Map.get(role_crs, role) - - resp_leaderboard = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, voting_question.assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.find(&(&1["id"] == voting_question.id)) - |> Map.get("contestLeaderboard") - - assert resp_leaderboard == expected_leaderboard - end - end - - test "does not render close leaderboard for students", %{ - conn: conn, - course_regs: course_regs, - courses: %{course1: course1}, - role_crs: %{student: course_reg}, - assessments: assessments - } do - voting_assessment = assessments["practical"].assessment - - voting_assessment - |> Assessment.changeset(%{ - close_at: Timex.shift(Timex.now(), days: 20) - }) - |> Repo.update() - - voting_question = assessments["practical"].voting_questions |> List.first() - contest_assessment_number = voting_question.question.contest_number - - contest_assessment = Repo.get_by(Assessment, number: contest_assessment_number) - - # insert contest question - contest_question = - insert(:programming_question, %{ - display_order: 1, - assessment: contest_assessment, - max_xp: 1000 - }) - - # insert contest submissions and answers - contest_submissions = - for student <- Enum.take(course_regs.students, 5) do - insert(:submission, %{assessment: contest_assessment, student: student}) - end - - _contest_answers = - for {submission, score} <- Enum.with_index(contest_submissions, 1) do - insert(:answer, %{ - xp: 1000, - question: contest_question, - submission: submission, - answer: build(:programming_answer), - relative_score: score / 1 - }) - end - - expected_leaderboard = [] - - resp_leaderboard = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, voting_question.assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.find(&(&1["id"] == voting_question.id)) - |> Map.get("contestLeaderboard") - - assert resp_leaderboard == expected_leaderboard - end - - test "it renders assessment question libraries", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for role <- Role.__enum_map__() do - course_reg = Map.get(role_crs, role) - - for {_type, - %{ - assessment: assessment, - mcq_questions: mcq_questions, - programming_questions: programming_questions, - voting_question: voting_questions - }} <- assessments do - # Programming questions should come first due to seeding order - - expected_libraries = - (programming_questions ++ mcq_questions ++ voting_questions) - |> Enum.map(&Map.get(&1, :library)) - |> Enum.map( - &%{ - "chapter" => &1.chapter, - "globals" => &1.globals, - "external" => %{ - "name" => "#{&1.external.name}", - "symbols" => &1.external.symbols - } - } - ) - - resp_libraries = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.map(&Map.get(&1, "library")) - - assert resp_libraries == expected_libraries - end - end - end - - test "it renders solutions for ungraded assessments (path)", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for role <- Role.__enum_map__() do - course_reg = Map.get(role_crs, role) - - %{ - assessment: assessment, - mcq_questions: mcq_questions, - programming_questions: programming_questions, - voting_questions: voting_questions - } = assessments["path"] - - # This is the case cuz the seed set "path" to build_soultion = true - - # Seeds set solution as 0 - expected_mcq_solutions = Enum.map(mcq_questions, fn _ -> %{"solution" => 0} end) - - expected_programming_solutions = - Enum.map(programming_questions, &%{"solution" => &1.question.solution}) - - # No solution in a voting question - expected_voting_solutions = Enum.map(voting_questions, fn _ -> %{"solution" => nil} end) - - expected_solutions = - Enum.sort( - expected_mcq_solutions ++ expected_programming_solutions ++ expected_voting_solutions - ) - - resp_solutions = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.map(&Map.take(&1, ["solution"])) - |> Enum.sort() - - assert expected_solutions == resp_solutions - end - end - - test "it renders xp, grade for students", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for role <- Role.__enum_map__() do - course_reg = Map.get(role_crs, role) - - for {_type, - %{ - assessment: assessment, - mcq_answers: [mcq_answers | _], - programming_answers: [programming_answers | _], - voting_answers: [voting_answers | _] - }} <- assessments do - expected = - if role == :student do - Enum.map( - programming_answers ++ mcq_answers ++ voting_answers, - &%{ - "xp" => &1.xp + &1.xp_adjustment - } - ) - else - fn -> %{"xp" => 0} end - |> Stream.repeatedly() - |> Enum.take( - length(programming_answers) + length(mcq_answers) + length(voting_answers) - ) - end - - resp = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.map(&Map.take(&1, ~w(xp))) - - assert expected == resp - end - end - end - - test "it does not render solutions for ungraded assessments (path)", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for role <- Role.__enum_map__() do - course_reg = Map.get(role_crs, role) - - for {_type, - %{ - assessment: assessment - }} <- Map.delete(assessments, "path") do - resp_solutions = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.map(&Map.get(&1, ["solution"])) - - assert Enum.uniq(resp_solutions) == [nil] - end - end - end - end - - describe "GET /assessment_id, student" do - test "it renders previously submitted answers", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: %{student: student}, - assessments: assessments - } do - for {_type, - %{ - assessment: assessment, - mcq_answers: [mcq_answers | _], - programming_answers: [programming_answers | _], - voting_answers: [voting_answers | _] - }} <- assessments do - # Programming questions should come first due to seeding order - expected_programming_answers = - Enum.map(programming_answers, &%{"answer" => &1.answer.code}) - - expected_mcq_answers = Enum.map(mcq_answers, &%{"answer" => &1.answer.choice_id}) - - # Answers are not rendered for voting questions - expected_voting_answers = Enum.map(voting_answers, fn _ -> %{"answer" => nil} end) - - expected_answers = - expected_programming_answers ++ expected_mcq_answers ++ expected_voting_answers - - resp_answers = - conn - |> sign_in(student.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.map(&Map.take(&1, ["answer"])) - - assert expected_answers == resp_answers - end - end - - test "it does not permit access to not yet open assessments", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: %{student: student}, - assessments: %{"mission" => mission} - } do - mission.assessment - |> Assessment.changeset(%{ - open_at: Timex.shift(Timex.now(), days: 5), - close_at: Timex.shift(Timex.now(), days: 10) - }) - |> Repo.update!() - - conn = - conn - |> sign_in(student.user) - |> get(build_url(course1.id, mission.assessment.id)) - - assert response(conn, 401) == "Assessment not open" - end - - test "it does not permit access to unpublished assessments", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: %{student: student}, - assessments: %{"mission" => mission} - } do - {:ok, _} = - mission.assessment - |> Assessment.changeset(%{is_published: false}) - |> Repo.update() - - conn = - conn - |> sign_in(student.user) - |> get(build_url(course1.id, mission.assessment.id)) - - assert response(conn, 400) == "Assessment not found" - end - end - - describe "GET /assessment_id, non-students" do - test "it renders empty answers", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - for role <- ~w(staff admin)a do - course_reg = Map.get(role_crs, role) - - for {_type, %{assessment: assessment}} <- assessments do - resp_answers = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, assessment.id)) - |> json_response(200) - |> Map.get("questions", []) - |> Enum.map(&Map.get(&1, ["answer"])) - - assert Enum.uniq(resp_answers) == [nil] - end - end - end - - test "it permits access to not yet open assessments", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: %{"mission" => mission} - } do - for role <- ~w(staff admin)a do - course_reg = Map.get(role_crs, role) - - mission.assessment - |> Assessment.changeset(%{ - open_at: Timex.shift(Timex.now(), days: 5), - close_at: Timex.shift(Timex.now(), days: 10) - }) - |> Repo.update!() - - resp = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, mission.assessment.id)) - |> json_response(200) - - assert resp["id"] == mission.assessment.id - end - end - - test "it permits access to unpublished assessments", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: %{"mission" => mission} - } do - for role <- ~w(staff admin)a do - course_reg = Map.get(role_crs, role) - - {:ok, _} = - mission.assessment - |> Assessment.changeset(%{is_published: false}) - |> Repo.update() - - resp = - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id, mission.assessment.id)) - |> json_response(200) - - assert resp["id"] == mission.assessment.id - end - end - end - - describe "GET /assessment_id/submit unauthenticated" do - test "is not permitted", %{ - conn: conn, - courses: %{course1: course1}, - assessments: %{"mission" => %{assessment: assessment}} - } do - conn = post(conn, build_url_submit(course1.id, assessment.id)) - assert response(conn, 401) == "Unauthorised" - end - end - - describe "GET /assessment_id/submit students" do - for role <- ~w(student staff admin)a do - @tag role: role - test "is successful for attempted assessments for #{role}", %{ - conn: conn, - courses: %{course1: course1}, - assessments: %{"mission" => %{assessment: assessment}}, - role_crs: role_crs, - role: role - } do - with_mock GradingJob, - force_grade_individual_submission: fn _ -> nil end do - group = - if(role == :student, - do: insert(:group, %{course: course1, leader: role_crs.staff}), - else: nil - ) - - course_reg = insert(:course_registration, %{role: role, group: group, course: course1}) - - submission = - insert(:submission, %{student: course_reg, assessment: assessment, status: :attempted}) - - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - - assert response(conn, 200) == "OK" - - # Preloading is necessary because Mock does an exact match, including metadata - submission_db = Submission |> Repo.get(submission.id) |> Repo.preload(:assessment) - - assert submission_db.status == :submitted - - assert_called(GradingJob.force_grade_individual_submission(submission_db)) - end - end - end - - test "submission of answer within early hours(seeded 48) of opening grants full XP bonus", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs - } do - with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do - assessment_config = - insert( - :assessment_config, - early_submission_xp: 100, - hours_before_early_xp_decay: 48, - course: course1 - ) - - assessment = - insert( - :assessment, - open_at: Timex.shift(Timex.now(), hours: -40), - close_at: Timex.shift(Timex.now(), days: 7), - is_published: true, - config: assessment_config, - course: course1 - ) - - question = insert(:programming_question, assessment: assessment) - - group = insert(:group, leader: role_crs.staff) - - course_reg = - insert(:course_registration, %{role: :student, group: group, course: course1}) - - submission = - insert(:submission, assessment: assessment, student: course_reg, status: :attempted) - - insert( - :answer, - submission: submission, - question: question, - answer: %{code: "f => f(f);"} - ) - - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - |> response(200) - - submission_db = Repo.get(Submission, submission.id) - - assert submission_db.status == :submitted - assert submission_db.xp_bonus == 100 - end - end - - test "submission of answer after early hours before deadline get decaying XP bonus", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs - } do - with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do - for hours_after <- 48..148 do - assessment_config = - insert( - :assessment_config, - early_submission_xp: 100, - hours_before_early_xp_decay: 48, - course: course1 - ) - - assessment = - insert( - :assessment, - open_at: Timex.shift(Timex.now(), hours: -hours_after), - close_at: Timex.shift(Timex.now(), hours: 100), - is_published: true, - config: assessment_config, - course: course1 - ) - - question = insert(:programming_question, assessment: assessment) - - group = insert(:group, leader: role_crs.staff) - - course_reg = - insert(:course_registration, %{role: :student, group: group, course: course1}) - - submission = - insert(:submission, assessment: assessment, student: course_reg, status: :attempted) - - insert( - :answer, - submission: submission, - question: question, - answer: %{code: "f => f(f);"} - ) - - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - |> response(200) - - submission_db = Repo.get(Submission, submission.id) - - proportion = - Timex.diff(assessment.close_at, Timex.now(), :hours) / (100 + hours_after - 48) - - assert submission_db.status == :submitted - assert submission_db.xp_bonus == round(proportion * 100) - end - end - end - - test "submission of answer at the last hour yield 0 XP bonus", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs - } do - with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do - for hours_after <- 48..148 do - assessment_config = - insert( - :assessment_config, - early_submission_xp: 100, - hours_before_early_xp_decay: 48, - course: course1 - ) - - assessment = - insert( - :assessment, - open_at: Timex.shift(Timex.now(), hours: -hours_after), - close_at: Timex.shift(Timex.now(), hours: 1), - is_published: true, - config: assessment_config, - course: course1 - ) - - question = insert(:programming_question, assessment: assessment) - - group = insert(:group, leader: role_crs.staff) - - course_reg = - insert(:course_registration, %{role: :student, group: group, course: course1}) - - submission = - insert(:submission, assessment: assessment, student: course_reg, status: :attempted) - - insert( - :answer, - submission: submission, - question: question, - answer: %{code: "f => f(f);"} - ) - - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - |> response(200) - - submission_db = Repo.get(Submission, submission.id) - - assert submission_db.status == :submitted - assert submission_db.xp_bonus == 0 - end - end - end - - test "give 0 bonus for configs with 0 max", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs - } do - with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do - for hours_after <- 0..148 do - assessment_config = - insert( - :assessment_config, - early_submission_xp: 0, - hours_before_early_xp_decay: 48, - course: course1 - ) - - assessment = - insert( - :assessment, - open_at: Timex.shift(Timex.now(), hours: -hours_after), - close_at: Timex.shift(Timex.now(), days: 7), - is_published: true, - config: assessment_config, - course: course1 - ) - - question = insert(:programming_question, assessment: assessment) - - group = insert(:group, leader: role_crs.staff) - - course_reg = - insert(:course_registration, %{role: :student, group: group, course: course1}) - - submission = - insert(:submission, assessment: assessment, student: course_reg, status: :attempted) - - insert( - :answer, - submission: submission, - question: question, - answer: %{code: "f => f(f);"} - ) - - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - |> response(200) - - submission_db = Repo.get(Submission, submission.id) - - assert submission_db.status == :submitted - assert submission_db.xp_bonus == 0 - end - end - end - - # This also covers unpublished and assessments that are not open yet since they cannot be - # answered. - test "is not permitted for unattempted assessments", %{ - conn: conn, - courses: %{course1: course1}, - assessments: %{"mission" => %{assessment: assessment}} - } do - course_reg = insert(:course_registration, %{role: :student, course: course1}) - - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - - assert response(conn, 404) == "Submission not found" - end - - test "is not permitted for incomplete assessments", %{ - conn: conn, - courses: %{course1: course1}, - assessments: %{"mission" => %{assessment: assessment}} - } do - course_reg = insert(:course_registration, %{role: :student, course: course1}) - insert(:submission, %{student: course_reg, assessment: assessment, status: :attempting}) - - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - - assert response(conn, 400) == "Some questions have not been attempted" - end - - test "is not permitted for already submitted assessments", %{ - conn: conn, - courses: %{course1: course1}, - assessments: %{"mission" => %{assessment: assessment}} - } do - course_reg = insert(:course_registration, %{role: :student, course: course1}) - insert(:submission, %{student: course_reg, assessment: assessment, status: :submitted}) - - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, assessment.id)) - - assert response(conn, 403) == "Assessment has already been submitted" - end - - test "is not permitted for closed assessments", %{conn: conn, courses: %{course1: course1}} do - course_reg = insert(:course_registration, %{role: :student, course: course1}) - - # Only check for after-closing because submission shouldn't exist if unpublished or - # before opening and would fall under "Submission not found" - after_close_at_assessment = - insert(:assessment, %{ - open_at: Timex.shift(Timex.now(), days: -10), - close_at: Timex.shift(Timex.now(), days: -5), - course: course1 - }) - - insert(:submission, %{ - student: course_reg, - assessment: after_close_at_assessment, - status: :attempted - }) - - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_submit(course1.id, after_close_at_assessment.id)) - - assert response(conn, 403) == "Assessment not open" - end - - test "not found if not in same course", %{ - conn: conn, - courses: %{course2: course2}, - role_crs: %{student: student}, - assessments: %{"mission" => %{assessment: assessment}} - } do - # user is in both course, but assessment belongs to a course and no submission will be found - conn = - conn - |> sign_in(student.user) - |> post(build_url_submit(course2.id, assessment.id)) - - assert response(conn, 404) == "Submission not found" - end - - test "forbidden if not in course", %{ - conn: conn, - courses: %{course2: course2}, - course_regs: %{students: students}, - assessments: %{"mission" => %{assessment: assessment}} - } do - # user is not in the course - student2 = hd(tl(students)) - - conn = - conn - |> sign_in(student2.user) - |> post(build_url_submit(course2.id, assessment.id)) - - assert response(conn, 403) == "Forbidden" - end - end - - test "graded count is updated when assessment is graded", %{ - conn: conn, - courses: %{course1: course1}, - assessment_configs: [config | _], - role_crs: %{staff: avenger} - } do - assessment = - insert( - :assessment, - open_at: Timex.shift(Timex.now(), hours: -2), - close_at: Timex.shift(Timex.now(), days: 7), - is_published: true, - config: config, - course: course1 - ) - - [question_one, question_two] = insert_list(2, :programming_question, assessment: assessment) - - course_reg = insert(:course_registration, role: :student, course: course1) - - submission = - insert(:submission, assessment: assessment, student: course_reg, status: :submitted) - - Enum.each( - [question_one, question_two], - &insert(:answer, submission: submission, question: &1, answer: %{code: "f => f(f);"}) - ) - - get_graded_count = fn -> - conn - |> sign_in(course_reg.user) - |> get(build_url(course1.id)) - |> json_response(200) - |> Enum.find(&(&1["id"] == assessment.id)) - |> Map.get("gradedCount") - end - - grade_question = fn question -> - Assessments.update_grading_info( - %{submission_id: submission.id, question_id: question.id}, - %{"xp_adjustment" => 0}, - avenger - ) - end - - assert get_graded_count.() == 0 - - grade_question.(question_one) - - assert get_graded_count.() == 1 - - grade_question.(question_two) - - assert get_graded_count.() == 2 - end - - describe "Password protected assessments render properly" do - setup %{courses: %{course1: course1}, assessment_configs: configs} do - assessment = - insert(:assessment, %{config: Enum.at(configs, 4), course: course1, is_published: true}) - - assessment - |> Assessment.changeset(%{ - password: "mysupersecretpassword", - open_at: Timex.shift(Timex.now(), days: -2), - close_at: Timex.shift(Timex.now(), days: +1) - }) - |> Repo.update!() - - {:ok, protected_assessment: assessment} - end - - test "returns 403 when trying to access a password protected assessment without a password", - %{ - conn: conn, - courses: %{course1: course1}, - protected_assessment: protected_assessment, - role_crs: role_crs - } do - for {_role, course_reg} <- role_crs do - conn = - conn |> sign_in(course_reg.user) |> get(build_url(course1.id, protected_assessment.id)) - - assert response(conn, 403) == "Missing Password." - end - end - - test "returns 403 when password is wrong/invalid", %{ - conn: conn, - courses: %{course1: course1}, - protected_assessment: protected_assessment, - role_crs: role_crs - } do - for {_role, course_reg} <- role_crs do - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_unlock(course1.id, protected_assessment.id), %{:password => "wrong"}) - - assert response(conn, 403) == "Invalid Password." - end - end - - test "allow role_crs with preexisting submission to access private assessment without a password", - %{ - conn: conn, - courses: %{course1: course1}, - protected_assessment: protected_assessment, - role_crs: %{student: student} - } do - insert(:submission, %{assessment: protected_assessment, student: student}) - conn = conn |> sign_in(student.user) |> get(build_url(course1.id, protected_assessment.id)) - assert response(conn, 200) - end - - test "ignore password when assessment is not password protected", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: role_crs, - assessments: assessments - } do - assessment = assessments["mission"].assessment - - for {_role, course_reg} <- role_crs do - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_unlock(course1.id, assessment.id), %{:password => "wrong"}) - |> json_response(200) - - assert conn["id"] == assessment.id - end - end - - test "render assessment when password is correct", %{ - conn: conn, - courses: %{course1: course1}, - protected_assessment: protected_assessment, - role_crs: role_crs - } do - for {_role, course_reg} <- role_crs do - conn = - conn - |> sign_in(course_reg.user) - |> post(build_url_unlock(course1.id, protected_assessment.id), %{ - :password => "mysupersecretpassword" - }) - |> json_response(200) - - assert conn["id"] == protected_assessment.id - end - end - - test "permit global access to private assessment after closed", %{ - conn: conn, - courses: %{course1: course1}, - role_crs: %{student: student}, - assessments: %{"mission" => mission} - } do - mission.assessment - |> Assessment.changeset(%{ - open_at: Timex.shift(Timex.now(), days: -2), - close_at: Timex.shift(Timex.now(), days: -1) - }) - |> Repo.update!() - - conn = - conn - |> sign_in(student.user) - |> get(build_url(course1.id, mission.assessment.id)) - - assert response(conn, 200) - end - end - - defp build_url(course_id), do: "/v2/courses/#{course_id}/assessments/" - - defp build_url(course_id, assessment_id), - do: "/v2/courses/#{course_id}/assessments/#{assessment_id}" - - defp build_url_submit(course_id, assessment_id), - do: "/v2/courses/#{course_id}/assessments/#{assessment_id}/submit" - - defp build_url_unlock(course_id, assessment_id), - do: "/v2/courses/#{course_id}/assessments/#{assessment_id}/unlock" - - defp open_at_asc_comparator(x, y), do: Timex.before?(x.open_at, y.open_at) - - defp get_assessment_status(course_reg = %CourseRegistration{}, assessment = %Assessment{}) do - submission = - Submission - |> where(student_id: ^course_reg.id) - |> where(assessment_id: ^assessment.id) - |> Repo.one() - - (submission && submission.status |> Atom.to_string()) || "not_attempted" - end -end +defmodule CadetWeb.AssessmentsControllerTest do + use CadetWeb.ConnCase + use Timex + + import Ecto.Query + import Mock + + alias Cadet.{Assessments, Repo} + alias Cadet.Accounts.{Role, CourseRegistration} + alias Cadet.Assessments.{Assessment, Submission, SubmissionStatus} + alias Cadet.Autograder.GradingJob + alias CadetWeb.AssessmentsController + + @local_name "test/fixtures/local_repo" + + setup do + File.rm_rf!(@local_name) + + on_exit(fn -> + File.rm_rf!(@local_name) + end) + + Cadet.Test.Seeds.assessments() + end + + test "swagger" do + AssessmentsController.swagger_definitions() + AssessmentsController.swagger_path_index(nil) + AssessmentsController.swagger_path_show(nil) + AssessmentsController.swagger_path_unlock(nil) + AssessmentsController.swagger_path_submit(nil) + end + + describe "GET /, unauthenticated" do + test "unauthorized", %{conn: conn, courses: %{course1: course1}} do + conn = get(conn, build_url(course1.id)) + assert response(conn, 401) =~ "Unauthorised" + end + end + + describe "GET /:assessment_id, unauthenticated" do + test "unauthorized", %{conn: conn, courses: %{course1: course1}} do + conn = get(conn, build_url(course1.id, 1)) + assert response(conn, 401) =~ "Unauthorised" + end + end + + # All roles should see almost the same overview + describe "GET /, all roles" do + test "renders assessments overview", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for {_role, course_reg} <- role_crs do + expected = + assessments + |> Map.values() + |> Enum.map(& &1.assessment) + |> Enum.sort(&open_at_asc_comparator/2) + |> Enum.map( + &%{ + "courseId" => &1.course_id, + "id" => &1.id, + "title" => &1.title, + "shortSummary" => &1.summary_short, + "story" => &1.story, + "number" => &1.number, + "reading" => &1.reading, + "openAt" => format_datetime(&1.open_at), + "closeAt" => format_datetime(&1.close_at), + "type" => &1.config.type, + "isManuallyGraded" => &1.config.is_manually_graded, + "coverImage" => &1.cover_picture, + "maxXp" => 4800, + "status" => get_assessment_status(course_reg, &1), + "private" => false, + "isPublished" => &1.is_published, + "gradedCount" => 0, + "questionCount" => 9 + } + ) + + resp = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id)) + |> json_response(200) + |> Enum.map(&Map.delete(&1, "xp")) + + assert expected == resp + end + end + + test "render password protected assessments properly", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessment_configs: configs, + assessments: assessments + } do + for {_role, course_reg} <- role_crs do + mission = assessments[hd(configs).type] + + {:ok, _} = + mission.assessment + |> Assessment.changeset(%{password: "mysupersecretpassword"}) + |> Repo.update() + + resp = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id)) + |> json_response(200) + |> Enum.find(&(&1["type"] == hd(configs).type)) + |> Map.get("private") + + assert resp == true + end + end + end + + describe "GET /, student only" do + test "does not render unpublished assessments", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessment_configs: configs, + assessments: assessments + } do + mission = assessments[hd(configs).type] + + {:ok, _} = + mission.assessment + |> Assessment.changeset(%{is_published: false}) + |> Repo.update() + + expected = + assessments + |> Map.delete(hd(configs).type) + |> Map.values() + |> Enum.map(fn a -> a.assessment end) + |> Enum.sort(&open_at_asc_comparator/2) + |> Enum.map( + &%{ + "courseId" => &1.course_id, + "id" => &1.id, + "title" => &1.title, + "shortSummary" => &1.summary_short, + "story" => &1.story, + "number" => &1.number, + "reading" => &1.reading, + "openAt" => format_datetime(&1.open_at), + "closeAt" => format_datetime(&1.close_at), + "type" => &1.config.type, + "isManuallyGraded" => &1.config.is_manually_graded, + "coverImage" => &1.cover_picture, + "maxXp" => 4800, + "status" => get_assessment_status(student, &1), + "private" => false, + "isPublished" => &1.is_published, + "gradedCount" => 0, + "questionCount" => 9 + } + ) + + resp = + conn + |> sign_in(student.user) + |> get(build_url(course1.id)) + |> json_response(200) + |> Enum.map(&Map.delete(&1, "xp")) + + assert expected == resp + end + + test "renders student submission status in overview", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessment_configs: configs, + assessments: assessments + } do + assessment = assessments[hd(configs).type].assessment + [submission | _] = assessments[hd(configs).type].submissions + + for status <- SubmissionStatus.__enum_map__() do + submission + |> Submission.changeset(%{status: status}) + |> Repo.update() + + resp = + conn + |> sign_in(student.user) + |> get(build_url(course1.id)) + |> json_response(200) + |> Enum.find(&(&1["id"] == assessment.id)) + |> Map.get("status") + + assert get_assessment_status(student, assessment) == resp + end + end + + test "renders xp for students", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessment_configs: configs, + assessments: assessments + } do + assessment = assessments[hd(configs).type].assessment + + resp = + conn + |> sign_in(student.user) + |> get(build_url(course1.id)) + |> json_response(200) + |> Enum.find(&(&1["id"] == assessment.id)) + |> Map.get("xp") + + assert resp == 800 * 3 + 500 * 3 + 100 * 3 + end + end + + describe "GET /, non-students" do + test "renders unpublished assessments", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessment_configs: configs, + assessments: assessments + } do + for role <- ~w(staff admin)a do + course_reg = Map.get(role_crs, role) + mission = assessments[hd(configs).type] + + {:ok, _} = + mission.assessment + |> Assessment.changeset(%{is_published: false}) + |> Repo.update() + + resp = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id)) + |> json_response(200) + |> Enum.map(&Map.delete(&1, "xp")) + + expected = + assessments + |> Map.values() + |> Enum.map(fn a -> a.assessment end) + |> Enum.sort(&open_at_asc_comparator/2) + |> Enum.map( + &%{ + "id" => &1.id, + "courseId" => &1.course_id, + "title" => &1.title, + "shortSummary" => &1.summary_short, + "story" => &1.story, + "number" => &1.number, + "reading" => &1.reading, + "openAt" => format_datetime(&1.open_at), + "closeAt" => format_datetime(&1.close_at), + "type" => &1.config.type, + "isManuallyGraded" => &1.config.is_manually_graded, + "coverImage" => &1.cover_picture, + "maxXp" => 4800, + "status" => get_assessment_status(course_reg, &1), + "private" => false, + "gradedCount" => 0, + "questionCount" => 9, + "isPublished" => + if &1.config.type == hd(configs).type do + false + else + &1.is_published + end + } + ) + + assert expected == resp + end + end + end + + describe "GET /assessment_id, all roles" do + test "it renders assessment details", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for role <- Role.__enum_map__() do + course_reg = Map.get(role_crs, role) + + for {type, %{assessment: assessment}} <- assessments do + expected_assessments = %{ + "courseId" => assessment.course_id, + "id" => assessment.id, + "title" => assessment.title, + "type" => type, + "story" => assessment.story, + "number" => assessment.number, + "reading" => assessment.reading, + "longSummary" => assessment.summary_long, + "missionPDF" => Cadet.Assessments.Upload.url({assessment.mission_pdf, assessment}) + } + + resp_assessments = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.delete("questions") + + assert expected_assessments == resp_assessments + end + end + end + + test "it renders assessment questions", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for role <- Role.__enum_map__() do + course_reg = Map.get(role_crs, role) + + for {_type, + %{ + assessment: assessment, + mcq_questions: mcq_questions, + programming_questions: programming_questions, + voting_questions: voting_questions + }} <- assessments do + # Programming questions should come first due to seeding order + expected_programming_questions = + Enum.map( + programming_questions, + &%{ + "id" => &1.id, + "type" => "#{&1.type}", + "blocking" => &1.blocking, + "content" => &1.question.content, + "solutionTemplate" => &1.question.template, + "prepend" => &1.question.prepend, + "postpend" => &1.question.postpend, + "testcases" => + Enum.map( + &1.question.public, + fn testcase -> + for {k, v} <- testcase, + into: %{"type" => "public"}, + do: {Atom.to_string(k), v} + end + ) ++ + Enum.map( + &1.question.opaque, + fn testcase -> + for {k, v} <- testcase, + into: %{"type" => "opaque"}, + do: {Atom.to_string(k), v} + end + ) + } + ) + + expected_mcq_questions = + Enum.map( + mcq_questions, + &%{ + "id" => &1.id, + "type" => "#{&1.type}", + "blocking" => &1.blocking, + "content" => &1.question.content, + "choices" => + Enum.map( + &1.question.choices, + fn choice -> + %{ + "id" => choice.choice_id, + "content" => choice.content, + "hint" => choice.hint + } + end + ) + } + ) + + expected_voting_questions = + Enum.map( + voting_questions, + &%{ + "id" => &1.id, + "type" => "#{&1.type}", + "blocking" => &1.blocking, + "content" => &1.question.content, + "solutionTemplate" => &1.question.template, + "prepend" => &1.question.prepend + } + ) + + contests_submissions = + Enum.map(0..2, fn _ -> Enum.map(0..2, fn _ -> insert(:submission) end) end) + + contests_answers = + Enum.map(contests_submissions, fn contest_submissions -> + Enum.map(contest_submissions, fn submission -> + insert(:answer, %{ + submission: submission, + answer: %{code: "return 2;"}, + question: build(:programming_question) + }) + end) + end) + + voting_questions + |> Enum.zip(contests_submissions) + |> Enum.map(fn {question, contest_submissions} -> + Enum.map(contest_submissions, fn submission -> + insert(:submission_vote, %{ + voter: course_reg, + submission: submission, + question: question + }) + end) + end) + + contests_entries = + Enum.map(contests_answers, fn contest_answers -> + Enum.map(contest_answers, fn answer -> + %{ + "submission_id" => answer.submission.id, + "answer" => %{"code" => answer.answer.code}, + "score" => nil + } + end) + end) + + expected_voting_questions = + expected_voting_questions + |> Enum.zip(contests_entries) + |> Enum.map(fn {question, contest_entries} -> + question = Map.put(question, "contestEntries", contest_entries) + Map.put(question, "contestLeaderboard", []) + end) + + expected_questions = + expected_programming_questions ++ expected_mcq_questions ++ expected_voting_questions + + resp_questions = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.map(&Map.delete(&1, "answer")) + |> Enum.map(&Map.delete(&1, "solution")) + |> Enum.map(&Map.delete(&1, "library")) + |> Enum.map(&Map.delete(&1, "xp")) + |> Enum.map(&Map.delete(&1, "maxXp")) + |> Enum.map(&Map.delete(&1, "grader")) + |> Enum.map(&Map.delete(&1, "gradedAt")) + |> Enum.map(&Map.delete(&1, "autogradingResults")) + |> Enum.map(&Map.delete(&1, "autogradingStatus")) + |> Enum.map(&Map.delete(&1, "comments")) + + assert expected_questions == resp_questions + end + end + end + + test "renders open leaderboard for all roles", %{ + conn: conn, + course_regs: course_regs, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + voting_assessment = assessments["practical"].assessment + + voting_assessment + |> Assessment.changeset(%{ + open_at: Timex.shift(Timex.now(), days: -30), + close_at: Timex.shift(Timex.now(), days: -20) + }) + |> Repo.update() + + voting_question = assessments["practical"].voting_questions |> List.first() + contest_assessment_number = voting_question.question.contest_number + + contest_assessment = Repo.get_by(Assessment, number: contest_assessment_number) + + # insert contest question + contest_question = + insert(:programming_question, %{ + display_order: 1, + assessment: contest_assessment, + max_xp: 1000 + }) + + # insert contest submissions and answers + contest_submissions = + for student <- Enum.take(course_regs.students, 5) do + insert(:submission, %{assessment: contest_assessment, student: student}) + end + + contest_answers = + for {submission, score} <- Enum.with_index(contest_submissions, 1) do + insert(:answer, %{ + xp: 1000, + question: contest_question, + submission: submission, + answer: build(:programming_answer), + relative_score: score / 1 + }) + end + + expected_leaderboard = + for answer <- contest_answers do + %{ + "answer" => %{"code" => answer.answer.code}, + "final_score" => answer.relative_score, + "student_name" => answer.submission.student.user.name, + "submission_id" => answer.submission.id + } + end + |> Enum.sort_by(& &1["final_score"], &>=/2) + + for role <- Role.__enum_map__() do + course_reg = Map.get(role_crs, role) + + resp_leaderboard = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, voting_question.assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.find(&(&1["id"] == voting_question.id)) + |> Map.get("contestLeaderboard") + + assert resp_leaderboard == expected_leaderboard + end + end + + test "renders close leaderboard for staff and admin", %{ + conn: conn, + course_regs: course_regs, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + voting_assessment = assessments["practical"].assessment + + voting_assessment + |> Assessment.changeset(%{ + close_at: Timex.shift(Timex.now(), days: 20) + }) + |> Repo.update() + + voting_question = assessments["practical"].voting_questions |> List.first() + contest_assessment_number = voting_question.question.contest_number + + contest_assessment = Repo.get_by(Assessment, number: contest_assessment_number) + + # insert contest question + contest_question = + insert(:programming_question, %{ + display_order: 1, + assessment: contest_assessment, + max_xp: 1000 + }) + + # insert contest submissions and answers + contest_submissions = + for student <- Enum.take(course_regs.students, 5) do + insert(:submission, %{assessment: contest_assessment, student: student}) + end + + contest_answers = + for {submission, score} <- Enum.with_index(contest_submissions, 1) do + insert(:answer, %{ + xp: 1000, + question: contest_question, + submission: submission, + answer: build(:programming_answer), + relative_score: score / 1 + }) + end + + expected_leaderboard = + for answer <- contest_answers do + %{ + "answer" => %{"code" => answer.answer.code}, + "final_score" => answer.relative_score, + "student_name" => answer.submission.student.user.name, + "submission_id" => answer.submission.id + } + end + |> Enum.sort_by(& &1["final_score"], &>=/2) + + for role <- [:admin, :staff] do + course_reg = Map.get(role_crs, role) + + resp_leaderboard = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, voting_question.assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.find(&(&1["id"] == voting_question.id)) + |> Map.get("contestLeaderboard") + + assert resp_leaderboard == expected_leaderboard + end + end + + test "does not render close leaderboard for students", %{ + conn: conn, + course_regs: course_regs, + courses: %{course1: course1}, + role_crs: %{student: course_reg}, + assessments: assessments + } do + voting_assessment = assessments["practical"].assessment + + voting_assessment + |> Assessment.changeset(%{ + close_at: Timex.shift(Timex.now(), days: 20) + }) + |> Repo.update() + + voting_question = assessments["practical"].voting_questions |> List.first() + contest_assessment_number = voting_question.question.contest_number + + contest_assessment = Repo.get_by(Assessment, number: contest_assessment_number) + + # insert contest question + contest_question = + insert(:programming_question, %{ + display_order: 1, + assessment: contest_assessment, + max_xp: 1000 + }) + + # insert contest submissions and answers + contest_submissions = + for student <- Enum.take(course_regs.students, 5) do + insert(:submission, %{assessment: contest_assessment, student: student}) + end + + _contest_answers = + for {submission, score} <- Enum.with_index(contest_submissions, 1) do + insert(:answer, %{ + xp: 1000, + question: contest_question, + submission: submission, + answer: build(:programming_answer), + relative_score: score / 1 + }) + end + + expected_leaderboard = [] + + resp_leaderboard = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, voting_question.assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.find(&(&1["id"] == voting_question.id)) + |> Map.get("contestLeaderboard") + + assert resp_leaderboard == expected_leaderboard + end + + test "it renders assessment question libraries", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for role <- Role.__enum_map__() do + course_reg = Map.get(role_crs, role) + + for {_type, + %{ + assessment: assessment, + mcq_questions: mcq_questions, + programming_questions: programming_questions, + voting_question: voting_questions + }} <- assessments do + # Programming questions should come first due to seeding order + + expected_libraries = + (programming_questions ++ mcq_questions ++ voting_questions) + |> Enum.map(&Map.get(&1, :library)) + |> Enum.map( + &%{ + "chapter" => &1.chapter, + "globals" => &1.globals, + "external" => %{ + "name" => "#{&1.external.name}", + "symbols" => &1.external.symbols + } + } + ) + + resp_libraries = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.map(&Map.get(&1, "library")) + + assert resp_libraries == expected_libraries + end + end + end + + test "it renders solutions for ungraded assessments (path)", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for role <- Role.__enum_map__() do + course_reg = Map.get(role_crs, role) + + %{ + assessment: assessment, + mcq_questions: mcq_questions, + programming_questions: programming_questions, + voting_questions: voting_questions + } = assessments["path"] + + # This is the case cuz the seed set "path" to build_soultion = true + + # Seeds set solution as 0 + expected_mcq_solutions = Enum.map(mcq_questions, fn _ -> %{"solution" => 0} end) + + expected_programming_solutions = + Enum.map(programming_questions, &%{"solution" => &1.question.solution}) + + # No solution in a voting question + expected_voting_solutions = Enum.map(voting_questions, fn _ -> %{"solution" => nil} end) + + expected_solutions = + Enum.sort( + expected_mcq_solutions ++ expected_programming_solutions ++ expected_voting_solutions + ) + + resp_solutions = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.map(&Map.take(&1, ["solution"])) + |> Enum.sort() + + assert expected_solutions == resp_solutions + end + end + + test "it renders xp, grade for students", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for role <- Role.__enum_map__() do + course_reg = Map.get(role_crs, role) + + for {_type, + %{ + assessment: assessment, + mcq_answers: [mcq_answers | _], + programming_answers: [programming_answers | _], + voting_answers: [voting_answers | _] + }} <- assessments do + expected = + if role == :student do + Enum.map( + programming_answers ++ mcq_answers ++ voting_answers, + &%{ + "xp" => &1.xp + &1.xp_adjustment + } + ) + else + fn -> %{"xp" => 0} end + |> Stream.repeatedly() + |> Enum.take( + length(programming_answers) + length(mcq_answers) + length(voting_answers) + ) + end + + resp = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.map(&Map.take(&1, ~w(xp))) + + assert expected == resp + end + end + end + + test "it does not render solutions for ungraded assessments (path)", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for role <- Role.__enum_map__() do + course_reg = Map.get(role_crs, role) + + for {_type, + %{ + assessment: assessment + }} <- Map.delete(assessments, "path") do + resp_solutions = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.map(&Map.get(&1, ["solution"])) + + assert Enum.uniq(resp_solutions) == [nil] + end + end + end + end + + describe "GET /assessment_id, student" do + test "it renders previously submitted answers", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessments: assessments + } do + for {_type, + %{ + assessment: assessment, + mcq_answers: [mcq_answers | _], + programming_answers: [programming_answers | _], + voting_answers: [voting_answers | _] + }} <- assessments do + # Programming questions should come first due to seeding order + expected_programming_answers = + Enum.map(programming_answers, &%{"answer" => &1.answer.code}) + + expected_mcq_answers = Enum.map(mcq_answers, &%{"answer" => &1.answer.choice_id}) + + # Answers are not rendered for voting questions + expected_voting_answers = Enum.map(voting_answers, fn _ -> %{"answer" => nil} end) + + expected_answers = + expected_programming_answers ++ expected_mcq_answers ++ expected_voting_answers + + resp_answers = + conn + |> sign_in(student.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.map(&Map.take(&1, ["answer"])) + + assert expected_answers == resp_answers + end + end + + test "it does not permit access to not yet open assessments", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessments: %{"mission" => mission} + } do + mission.assessment + |> Assessment.changeset(%{ + open_at: Timex.shift(Timex.now(), days: 5), + close_at: Timex.shift(Timex.now(), days: 10) + }) + |> Repo.update!() + + conn = + conn + |> sign_in(student.user) + |> get(build_url(course1.id, mission.assessment.id)) + + assert response(conn, 401) == "Assessment not open" + end + + test "it does not permit access to unpublished assessments", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessments: %{"mission" => mission} + } do + {:ok, _} = + mission.assessment + |> Assessment.changeset(%{is_published: false}) + |> Repo.update() + + conn = + conn + |> sign_in(student.user) + |> get(build_url(course1.id, mission.assessment.id)) + + assert response(conn, 400) == "Assessment not found" + end + end + + describe "GET /assessment_id, non-students" do + test "it renders empty answers", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for role <- ~w(staff admin)a do + course_reg = Map.get(role_crs, role) + + for {_type, %{assessment: assessment}} <- assessments do + resp_answers = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) + |> json_response(200) + |> Map.get("questions", []) + |> Enum.map(&Map.get(&1, ["answer"])) + + assert Enum.uniq(resp_answers) == [nil] + end + end + end + + test "it permits access to not yet open assessments", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: %{"mission" => mission} + } do + for role <- ~w(staff admin)a do + course_reg = Map.get(role_crs, role) + + mission.assessment + |> Assessment.changeset(%{ + open_at: Timex.shift(Timex.now(), days: 5), + close_at: Timex.shift(Timex.now(), days: 10) + }) + |> Repo.update!() + + resp = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, mission.assessment.id)) + |> json_response(200) + + assert resp["id"] == mission.assessment.id + end + end + + test "it permits access to unpublished assessments", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: %{"mission" => mission} + } do + for role <- ~w(staff admin)a do + course_reg = Map.get(role_crs, role) + + {:ok, _} = + mission.assessment + |> Assessment.changeset(%{is_published: false}) + |> Repo.update() + + resp = + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id, mission.assessment.id)) + |> json_response(200) + + assert resp["id"] == mission.assessment.id + end + end + end + + describe "GET /assessment_id/submit unauthenticated" do + test "is not permitted", %{ + conn: conn, + courses: %{course1: course1}, + assessments: %{"mission" => %{assessment: assessment}} + } do + conn = post(conn, build_url_submit(course1.id, assessment.id)) + assert response(conn, 401) == "Unauthorised" + end + end + + describe "GET /assessment_id/submit students" do + for role <- ~w(student staff admin)a do + @tag role: role + test "is successful for attempted assessments for #{role}", %{ + conn: conn, + courses: %{course1: course1}, + assessments: %{"mission" => %{assessment: assessment}}, + role_crs: role_crs, + role: role + } do + with_mock GradingJob, + force_grade_individual_submission: fn _ -> nil end do + group = + if(role == :student, + do: insert(:group, %{course: course1, leader: role_crs.staff}), + else: nil + ) + + course_reg = insert(:course_registration, %{role: role, group: group, course: course1}) + + submission = + insert(:submission, %{student: course_reg, assessment: assessment, status: :attempted}) + + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + + assert response(conn, 200) == "OK" + + # Preloading is necessary because Mock does an exact match, including metadata + submission_db = Submission |> Repo.get(submission.id) |> Repo.preload(:assessment) + + assert submission_db.status == :submitted + + assert_called(GradingJob.force_grade_individual_submission(submission_db)) + end + end + end + + test "submission of answer within early hours(seeded 48) of opening grants full XP bonus", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs + } do + with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do + assessment_config = + insert( + :assessment_config, + early_submission_xp: 100, + hours_before_early_xp_decay: 48, + course: course1 + ) + + assessment = + insert( + :assessment, + open_at: Timex.shift(Timex.now(), hours: -40), + close_at: Timex.shift(Timex.now(), days: 7), + is_published: true, + config: assessment_config, + course: course1 + ) + + question = insert(:programming_question, assessment: assessment) + + group = insert(:group, leader: role_crs.staff) + + course_reg = + insert(:course_registration, %{role: :student, group: group, course: course1}) + + submission = + insert(:submission, assessment: assessment, student: course_reg, status: :attempted) + + insert( + :answer, + submission: submission, + question: question, + answer: %{code: "f => f(f);"} + ) + + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + |> response(200) + + submission_db = Repo.get(Submission, submission.id) + + assert submission_db.status == :submitted + assert submission_db.xp_bonus == 100 + end + end + + test "submission of answer after early hours before deadline get decaying XP bonus", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs + } do + with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do + for hours_after <- 48..148 do + assessment_config = + insert( + :assessment_config, + early_submission_xp: 100, + hours_before_early_xp_decay: 48, + course: course1 + ) + + assessment = + insert( + :assessment, + open_at: Timex.shift(Timex.now(), hours: -hours_after), + close_at: Timex.shift(Timex.now(), hours: 100), + is_published: true, + config: assessment_config, + course: course1 + ) + + question = insert(:programming_question, assessment: assessment) + + group = insert(:group, leader: role_crs.staff) + + course_reg = + insert(:course_registration, %{role: :student, group: group, course: course1}) + + submission = + insert(:submission, assessment: assessment, student: course_reg, status: :attempted) + + insert( + :answer, + submission: submission, + question: question, + answer: %{code: "f => f(f);"} + ) + + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + |> response(200) + + submission_db = Repo.get(Submission, submission.id) + + proportion = + Timex.diff(assessment.close_at, Timex.now(), :hours) / (100 + hours_after - 48) + + assert submission_db.status == :submitted + assert submission_db.xp_bonus == round(proportion * 100) + end + end + end + + test "submission of answer at the last hour yield 0 XP bonus", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs + } do + with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do + for hours_after <- 48..148 do + assessment_config = + insert( + :assessment_config, + early_submission_xp: 100, + hours_before_early_xp_decay: 48, + course: course1 + ) + + assessment = + insert( + :assessment, + open_at: Timex.shift(Timex.now(), hours: -hours_after), + close_at: Timex.shift(Timex.now(), hours: 1), + is_published: true, + config: assessment_config, + course: course1 + ) + + question = insert(:programming_question, assessment: assessment) + + group = insert(:group, leader: role_crs.staff) + + course_reg = + insert(:course_registration, %{role: :student, group: group, course: course1}) + + submission = + insert(:submission, assessment: assessment, student: course_reg, status: :attempted) + + insert( + :answer, + submission: submission, + question: question, + answer: %{code: "f => f(f);"} + ) + + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + |> response(200) + + submission_db = Repo.get(Submission, submission.id) + + assert submission_db.status == :submitted + assert submission_db.xp_bonus == 0 + end + end + end + + test "give 0 bonus for configs with 0 max", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs + } do + with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do + for hours_after <- 0..148 do + assessment_config = + insert( + :assessment_config, + early_submission_xp: 0, + hours_before_early_xp_decay: 48, + course: course1 + ) + + assessment = + insert( + :assessment, + open_at: Timex.shift(Timex.now(), hours: -hours_after), + close_at: Timex.shift(Timex.now(), days: 7), + is_published: true, + config: assessment_config, + course: course1 + ) + + question = insert(:programming_question, assessment: assessment) + + group = insert(:group, leader: role_crs.staff) + + course_reg = + insert(:course_registration, %{role: :student, group: group, course: course1}) + + submission = + insert(:submission, assessment: assessment, student: course_reg, status: :attempted) + + insert( + :answer, + submission: submission, + question: question, + answer: %{code: "f => f(f);"} + ) + + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + |> response(200) + + submission_db = Repo.get(Submission, submission.id) + + assert submission_db.status == :submitted + assert submission_db.xp_bonus == 0 + end + end + end + + # This also covers unpublished and assessments that are not open yet since they cannot be + # answered. + test "is not permitted for unattempted assessments", %{ + conn: conn, + courses: %{course1: course1}, + assessments: %{"mission" => %{assessment: assessment}} + } do + course_reg = insert(:course_registration, %{role: :student, course: course1}) + + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + + assert response(conn, 404) == "Submission not found" + end + + test "is not permitted for incomplete assessments", %{ + conn: conn, + courses: %{course1: course1}, + assessments: %{"mission" => %{assessment: assessment}} + } do + course_reg = insert(:course_registration, %{role: :student, course: course1}) + insert(:submission, %{student: course_reg, assessment: assessment, status: :attempting}) + + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + + assert response(conn, 400) == "Some questions have not been attempted" + end + + test "is not permitted for already submitted assessments", %{ + conn: conn, + courses: %{course1: course1}, + assessments: %{"mission" => %{assessment: assessment}} + } do + course_reg = insert(:course_registration, %{role: :student, course: course1}) + insert(:submission, %{student: course_reg, assessment: assessment, status: :submitted}) + + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + + assert response(conn, 403) == "Assessment has already been submitted" + end + + test "is not permitted for closed assessments", %{conn: conn, courses: %{course1: course1}} do + course_reg = insert(:course_registration, %{role: :student, course: course1}) + + # Only check for after-closing because submission shouldn't exist if unpublished or + # before opening and would fall under "Submission not found" + after_close_at_assessment = + insert(:assessment, %{ + open_at: Timex.shift(Timex.now(), days: -10), + close_at: Timex.shift(Timex.now(), days: -5), + course: course1 + }) + + insert(:submission, %{ + student: course_reg, + assessment: after_close_at_assessment, + status: :attempted + }) + + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, after_close_at_assessment.id)) + + assert response(conn, 403) == "Assessment not open" + end + + test "not found if not in same course", %{ + conn: conn, + courses: %{course2: course2}, + role_crs: %{student: student}, + assessments: %{"mission" => %{assessment: assessment}} + } do + # user is in both course, but assessment belongs to a course and no submission will be found + conn = + conn + |> sign_in(student.user) + |> post(build_url_submit(course2.id, assessment.id)) + + assert response(conn, 404) == "Submission not found" + end + + test "forbidden if not in course", %{ + conn: conn, + courses: %{course2: course2}, + course_regs: %{students: students}, + assessments: %{"mission" => %{assessment: assessment}} + } do + # user is not in the course + student2 = hd(tl(students)) + + conn = + conn + |> sign_in(student2.user) + |> post(build_url_submit(course2.id, assessment.id)) + + assert response(conn, 403) == "Forbidden" + end + end + + test "graded count is updated when assessment is graded", %{ + conn: conn, + courses: %{course1: course1}, + assessment_configs: [config | _], + role_crs: %{staff: avenger} + } do + assessment = + insert( + :assessment, + open_at: Timex.shift(Timex.now(), hours: -2), + close_at: Timex.shift(Timex.now(), days: 7), + is_published: true, + config: config, + course: course1 + ) + + [question_one, question_two] = insert_list(2, :programming_question, assessment: assessment) + + course_reg = insert(:course_registration, role: :student, course: course1) + + submission = + insert(:submission, assessment: assessment, student: course_reg, status: :submitted) + + Enum.each( + [question_one, question_two], + &insert(:answer, submission: submission, question: &1, answer: %{code: "f => f(f);"}) + ) + + get_graded_count = fn -> + conn + |> sign_in(course_reg.user) + |> get(build_url(course1.id)) + |> json_response(200) + |> Enum.find(&(&1["id"] == assessment.id)) + |> Map.get("gradedCount") + end + + grade_question = fn question -> + Assessments.update_grading_info( + %{submission_id: submission.id, question_id: question.id}, + %{"xp_adjustment" => 0}, + avenger + ) + end + + assert get_graded_count.() == 0 + + grade_question.(question_one) + + assert get_graded_count.() == 1 + + grade_question.(question_two) + + assert get_graded_count.() == 2 + end + + describe "Password protected assessments render properly" do + setup %{courses: %{course1: course1}, assessment_configs: configs} do + assessment = + insert(:assessment, %{config: Enum.at(configs, 4), course: course1, is_published: true}) + + assessment + |> Assessment.changeset(%{ + password: "mysupersecretpassword", + open_at: Timex.shift(Timex.now(), days: -2), + close_at: Timex.shift(Timex.now(), days: +1) + }) + |> Repo.update!() + + {:ok, protected_assessment: assessment} + end + + test "returns 403 when trying to access a password protected assessment without a password", + %{ + conn: conn, + courses: %{course1: course1}, + protected_assessment: protected_assessment, + role_crs: role_crs + } do + for {_role, course_reg} <- role_crs do + conn = + conn |> sign_in(course_reg.user) |> get(build_url(course1.id, protected_assessment.id)) + + assert response(conn, 403) == "Missing Password." + end + end + + test "returns 403 when password is wrong/invalid", %{ + conn: conn, + courses: %{course1: course1}, + protected_assessment: protected_assessment, + role_crs: role_crs + } do + for {_role, course_reg} <- role_crs do + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_unlock(course1.id, protected_assessment.id), %{:password => "wrong"}) + + assert response(conn, 403) == "Invalid Password." + end + end + + test "allow role_crs with preexisting submission to access private assessment without a password", + %{ + conn: conn, + courses: %{course1: course1}, + protected_assessment: protected_assessment, + role_crs: %{student: student} + } do + insert(:submission, %{assessment: protected_assessment, student: student}) + conn = conn |> sign_in(student.user) |> get(build_url(course1.id, protected_assessment.id)) + assert response(conn, 200) + end + + test "ignore password when assessment is not password protected", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + assessment = assessments["mission"].assessment + + for {_role, course_reg} <- role_crs do + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_unlock(course1.id, assessment.id), %{:password => "wrong"}) + |> json_response(200) + + assert conn["id"] == assessment.id + end + end + + test "render assessment when password is correct", %{ + conn: conn, + courses: %{course1: course1}, + protected_assessment: protected_assessment, + role_crs: role_crs + } do + for {_role, course_reg} <- role_crs do + conn = + conn + |> sign_in(course_reg.user) + |> post(build_url_unlock(course1.id, protected_assessment.id), %{ + :password => "mysupersecretpassword" + }) + |> json_response(200) + + assert conn["id"] == protected_assessment.id + end + end + + test "permit global access to private assessment after closed", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessments: %{"mission" => mission} + } do + mission.assessment + |> Assessment.changeset(%{ + open_at: Timex.shift(Timex.now(), days: -2), + close_at: Timex.shift(Timex.now(), days: -1) + }) + |> Repo.update!() + + conn = + conn + |> sign_in(student.user) + |> get(build_url(course1.id, mission.assessment.id)) + + assert response(conn, 200) + end + end + + defp build_url(course_id), do: "/v2/courses/#{course_id}/assessments/" + + defp build_url(course_id, assessment_id), + do: "/v2/courses/#{course_id}/assessments/#{assessment_id}" + + defp build_url_submit(course_id, assessment_id), + do: "/v2/courses/#{course_id}/assessments/#{assessment_id}/submit" + + defp build_url_unlock(course_id, assessment_id), + do: "/v2/courses/#{course_id}/assessments/#{assessment_id}/unlock" + + defp open_at_asc_comparator(x, y), do: Timex.before?(x.open_at, y.open_at) + + defp get_assessment_status(course_reg = %CourseRegistration{}, assessment = %Assessment{}) do + submission = + Submission + |> where(student_id: ^course_reg.id) + |> where(assessment_id: ^assessment.id) + |> Repo.one() + + (submission && submission.status |> Atom.to_string()) || "not_attempted" + end +end From 8e1dfd8a9406c5846b38cdda19870b446352ed15 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Sat, 22 Jul 2023 04:20:44 +0800 Subject: [PATCH 012/128] Add API call to retrieve team formation students --- .../admin_controllers/admin_user_controller.ex | 8 ++++++++ lib/cadet_web/admin_views/admin_user_view.ex | 12 ++++++++++++ lib/cadet_web/router.ex | 1 + 3 files changed, 21 insertions(+) diff --git a/lib/cadet_web/admin_controllers/admin_user_controller.ex b/lib/cadet_web/admin_controllers/admin_user_controller.ex index b57e42a74..e6c99a700 100644 --- a/lib/cadet_web/admin_controllers/admin_user_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_user_controller.ex @@ -28,6 +28,14 @@ defmodule CadetWeb.AdminUserController do json(conn, %{totalXp: total_xp}) end + @add_users_role ~w(admin)a + def get_students(conn, filter) do + users = + filter |> try_keywordise_string_keys() |> Accounts.get_users_by(conn.assigns.course_reg) + + render(conn, "get_students.json", users: users) + end + @add_users_role ~w(admin)a def upsert_users_and_groups(conn, %{ "course_id" => course_id, diff --git a/lib/cadet_web/admin_views/admin_user_view.ex b/lib/cadet_web/admin_views/admin_user_view.ex index 062412e2d..24d6e30fc 100644 --- a/lib/cadet_web/admin_views/admin_user_view.ex +++ b/lib/cadet_web/admin_views/admin_user_view.ex @@ -5,6 +5,10 @@ defmodule CadetWeb.AdminUserView do render_many(users, CadetWeb.AdminUserView, "cr.json", as: :cr) end + def render("get_students.json", %{users: users}) do + render_many(users, CadetWeb.AdminUserView, "students.json", as: :students) + end + def render("cr.json", %{cr: cr}) do %{ courseRegId: cr.id, @@ -20,4 +24,12 @@ defmodule CadetWeb.AdminUserView do end } end + + def render("students.json", %{students: students}) do + %{ + userId: students.id, + name: students.user.name, + username: students.user.username, + } + end end diff --git a/lib/cadet_web/router.ex b/lib/cadet_web/router.ex index 3785044d3..a1c06727d 100644 --- a/lib/cadet_web/router.ex +++ b/lib/cadet_web/router.ex @@ -128,6 +128,7 @@ defmodule CadetWeb.Router do ) get("/users", AdminUserController, :index) + get("/users/teamformation", AdminUserController, :get_students) put("/users", AdminUserController, :upsert_users_and_groups) get("/users/:course_reg_id/assessments", AdminAssessmentsController, :index) From 5c74db83c060c564fa13a0561bd203c66b1948bd Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Sat, 22 Jul 2023 22:24:09 +0800 Subject: [PATCH 013/128] Add Validation for Create Team --- lib/cadet/accounts/teams.ex | 54 ++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index 5678a20e5..e733cea2f 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -6,39 +6,51 @@ defmodule Cadet.Accounts.Teams do import Ecto.Changeset import Ecto.Query alias Cadet.Repo - alias Cadet.Accounts.{Team, TeamMember} + alias Cadet.Accounts.{Team, TeamMember, CourseRegistration} alias Cadet.Assessments.Assessment def create_team(attrs) do assessment_id = attrs["assessment_id"] - student_ids = attrs["student_ids"] + teams = attrs["student_ids"] - %Team{} - |> cast(attrs, [:assessment_id]) - |> validate_required([:assessment_id]) - |> foreign_key_constraint(:assessment_id) - |> Repo.transaction(fn -> - with {:ok, team} <- Repo.insert(Team.changeset(%Team{}, attrs)), - :ok <- create_team_members(team, student_ids) do - {:ok, team} + Enum.reduce_while(teams, {:ok, nil}, fn team_attrs, {:ok, _} -> + if student_already_in_team?(team_attrs, assessment_id) do + {:halt, {:error, {:conflict, "Team with the same members already exists for this assessment!"}}} else - error -> - error + {:ok, team} = %Team{} + |> cast(attrs, [:assessment_id]) + |> validate_required([:assessment_id]) + |> foreign_key_constraint(:assessment_id) + |> Repo.insert() + team_id = team.id + Enum.each(team_attrs, fn student -> + student_id = Map.get(student, "userId") + attributes = %{student_id: student_id, team_id: team_id} + %TeamMember{} + |> cast(attributes, [:student_id, :team_id]) + |> Repo.insert() + end) + {:cont, {:ok, team}} end end) end - defp create_team_members(team, student_ids) do - team_member_changesets = - Enum.map(student_ids, fn student_id -> - %TeamMember{} - |> Ecto.build_assoc(:team) - |> Ecto.Changeset.change(team_id: team.id, student_id: student_id) - end) + defp student_already_in_team?(team_attrs, assessment_id) do + student_ids = Enum.map(team_attrs, &Map.get(&1, "userId")) - Repo.insert_all(team_member_changesets) + # Check if any of the students in team_attrs are already in a team for the same assessment + query = + from tm in TeamMember, + join: t in assoc(tm, :team), + where: tm.student_id in ^student_ids and t.assessment_id == ^assessment_id, + select: tm.student_id + + existing_student_ids = Repo.all(query) + + Enum.any?(student_ids, fn student_id -> Enum.member?(existing_student_ids, student_id) end) end + def update_team(%Team{} = team, attrs) do assessment_id = attrs["assessment_id"] student_ids = attrs["student_ids"] @@ -81,8 +93,6 @@ defmodule Cadet.Accounts.Teams do def delete_team(%Team{} = team) do team - |> Ecto.Changeset.delete_assoc(:submission) - |> Ecto.Changeset.delete_assoc(:team_members) |> Repo.delete() end From 925cb87f2c7f5bfd9e676cda77a8c3ee6a8e4897 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Sat, 22 Jul 2023 22:24:46 +0800 Subject: [PATCH 014/128] Update response for Create and Delete Team API --- .../admin_teams_controller.ex | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/lib/cadet_web/admin_controllers/admin_teams_controller.ex b/lib/cadet_web/admin_controllers/admin_teams_controller.ex index df508e92a..75bcbf546 100644 --- a/lib/cadet_web/admin_controllers/admin_teams_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_teams_controller.ex @@ -39,11 +39,13 @@ defmodule CadetWeb.AdminTeamsController do case Teams.create_team(team_params) do {:ok, team} -> conn - |> put_flash(:info, "Team created successfully.") - |> render("create.json", team: team) + |> put_status(:created) + |> text("Teams created successfully.") - {:error, changeset} -> - render(conn, "create.json", changeset: changeset) + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) end end @@ -55,11 +57,13 @@ defmodule CadetWeb.AdminTeamsController do case Teams.update_team(team, team_params) do {:ok, updated_team} -> conn - |> put_flash(:info, "Team updated successfully.") - |> redirect(to: Routes.admin_teams_path(conn, :show, updated_team)) + |> put_status(:ok) + |> text("Teams updated successfully.") - {:error, changeset} -> - render(conn, "edit.json", team: team, changeset: changeset) + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) end end @@ -73,10 +77,15 @@ defmodule CadetWeb.AdminTeamsController do end end - def delete(conn, %{"id" => id}) do - team = Teams |> Repo.get!(id) - {:ok, _} = Teams.delete_team(team) - text(conn, "Team deleted successfully.") + def delete(conn, %{"teamId" => id}) do + team = Repo.get!(Team, id) + + case Teams.delete_team(team) do + {:ok, _} -> + text(conn, "Team deleted successfully.") + {:error, _changeset} -> + text(conn, "Error deleting the team.") + end end swagger_path :index do From 1b47bc10e67da2a3d628ea99fa3ebae284213e4b Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Sun, 23 Jul 2023 02:02:08 +0800 Subject: [PATCH 015/128] Add Update Teams API call --- lib/cadet/accounts/teams.ex | 97 +++++++++++-------- .../admin_teams_controller.ex | 34 +++---- 2 files changed, 71 insertions(+), 60 deletions(-) diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index e733cea2f..61da65102 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -14,7 +14,8 @@ defmodule Cadet.Accounts.Teams do teams = attrs["student_ids"] Enum.reduce_while(teams, {:ok, nil}, fn team_attrs, {:ok, _} -> - if student_already_in_team?(team_attrs, assessment_id) do + student_ids = Enum.map(team_attrs, &Map.get(&1, "userId")) + if student_already_in_team?(student_ids, assessment_id) do {:halt, {:error, {:conflict, "Team with the same members already exists for this assessment!"}}} else {:ok, team} = %Team{} @@ -35,9 +36,7 @@ defmodule Cadet.Accounts.Teams do end) end - defp student_already_in_team?(team_attrs, assessment_id) do - student_ids = Enum.map(team_attrs, &Map.get(&1, "userId")) - + defp student_already_in_team?(student_ids, assessment_id) do # Check if any of the students in team_attrs are already in a team for the same assessment query = from tm in TeamMember, @@ -51,64 +50,76 @@ defmodule Cadet.Accounts.Teams do end - def update_team(%Team{} = team, attrs) do - assessment_id = attrs["assessment_id"] - student_ids = attrs["student_ids"] - - team_id = team.id # Introduce a variable for team.id - - team - |> cast(attrs, [:assessment_id]) - |> validate_required([:assessment_id]) - |> foreign_key_constraint(:assessment_id) - |> Ecto.Changeset.change() - |> Repo.update() - |> case do - {:ok, updated_team} -> - update_team_members(updated_team, student_ids, team_id) # Pass team_id here - {:ok, updated_team} - - error -> - error + def update_team(%Team{} = team, new_assessment_id, student_ids) do + old_assessment_id = team.assessment_id + team_id = team.id + new_student_ids = Enum.map(hd(student_ids), fn student -> Map.get(student, "userId") end) + if student_already_in_team?(new_student_ids, new_assessment_id) do + {:error, {:conflict, "One or more students are already in another team for the same assessment!"}} + else + attrs = %{assessment_id: new_assessment_id} + + team + |> cast(attrs, [:assessment_id]) + |> validate_required([:assessment_id]) + |> foreign_key_constraint(:assessment_id) + |> Ecto.Changeset.change() + |> Repo.update() + |> case do + {:ok, updated_team} -> + if old_assessment_id != new_assessment_id do + delete_associated_submission(team_id, old_assessment_id) + end + + update_team_members(updated_team, student_ids, team_id) + {:ok, updated_team} + + error -> + error + end end end - defp update_team_members(team, student_ids, team_id) do # Add team_id parameter here + defp update_team_members(team, student_ids, team_id) do current_student_ids = team.team_members |> Enum.map(&(&1.student_id)) + new_student_ids = Enum.map(hd(student_ids), fn student -> Map.get(student, "userId") end) - student_ids_to_add = Enum.difference(student_ids, current_student_ids) - student_ids_to_remove = Enum.difference(current_student_ids, student_ids) + student_ids_to_add = Enum.filter(new_student_ids, fn elem -> not Enum.member?(current_student_ids, elem) end) + student_ids_to_remove = Enum.filter(current_student_ids, fn elem -> not Enum.member?(new_student_ids, elem) end) Enum.each(student_ids_to_add, fn student_id -> %TeamMember{} - |> Ecto.Changeset.change(team_id: team_id, student_id: student_id) # Use team_id here + |> Ecto.Changeset.change(%{team_id: team_id, student_id: student_id}) # Change here |> Repo.insert() end) Enum.each(student_ids_to_remove, fn student_id -> - from(tm in TeamMember, where: tm.team_id == ^team_id and tm.student_id == ^student_id) # Remove ^ for student_id + from(tm in TeamMember, where: tm.team_id == ^team_id and tm.student_id == ^student_id) |> Repo.delete_all() end) end + defp delete_associated_submission(team_id, old_assessment_id) do + end + def delete_team(%Team{} = team) do team |> Repo.delete() end - def bulk_upload_teams(teams_params) do - teams = Jason.decode!(teams_params) - Enum.map(teams, fn team -> - case get_by_assessment_id(team["assessment_id"]) do - nil -> create_team(team) - existing_team -> update_team(existing_team, team) - end - end) - end - - def get_by_assessment_id(assessment_id) do - from(Team) - |> where(assessment_id: ^assessment_id) - |> Repo.one() - end + # def bulk_upload_teams(teams_params) do + # teams = Jason.decode!(teams_params) + # Enum.map(teams, fn team -> + # case get_by_assessment_id(team["assessment_id"]) do + # nil -> create_team(team) + # existing_team -> update_team(existing_team, team) + # end + # end) + # end + + # def get_by_assessment_id(assessment_id) do + # from(Team) + # |> where(assessment_id: ^assessment_id) + # |> Repo.one() + # end end diff --git a/lib/cadet_web/admin_controllers/admin_teams_controller.ex b/lib/cadet_web/admin_controllers/admin_teams_controller.ex index 75bcbf546..365171e4e 100644 --- a/lib/cadet_web/admin_controllers/admin_teams_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_teams_controller.ex @@ -49,33 +49,33 @@ defmodule CadetWeb.AdminTeamsController do end end - def update(conn, %{"id" => id, "team" => team_params}) do - team = Teams - |> Repo.get!(id) - |> Repo.preload([:assessment, team_members: [:student]]) + def update(conn, %{"teamId" => teamId, "assessmentId" => assessmentId, "student_ids" => student_ids}) do + team = Team + |> Repo.get!(teamId) + |> Repo.preload([assessment: [:config], team_members: [student: [:user]]]) - case Teams.update_team(team, team_params) do + case Teams.update_team(team, assessmentId, student_ids) do {:ok, updated_team} -> conn |> put_status(:ok) |> text("Teams updated successfully.") - {:error, {status, message}} -> - conn - |> put_status(status) - |> text(message) + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) end end - def bulk_upload(conn, %{"teams" => teams_params}) do - case Teams.bulk_upload_teams(teams_params) do - {:ok, _teams} -> - text(conn, "Teams uploaded successfully.") + # def bulk_upload(conn, %{"teams" => teams_params}) do + # case Teams.bulk_upload_teams(teams_params) do + # {:ok, _teams} -> + # text(conn, "Teams uploaded successfully.") - {:error, changesets} -> - render(conn, "bulk_upload.json", changesets: changesets) - end - end + # {:error, changesets} -> + # render(conn, "bulk_upload.json", changesets: changesets) + # end + # end def delete(conn, %{"teamId" => id}) do team = Repo.get!(Team, id) From 50744dce6609e0447a67c840452e85a13e6f34f8 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Sun, 23 Jul 2023 05:19:46 +0800 Subject: [PATCH 016/128] Modify helper function --- lib/cadet/accounts/teams.ex | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index 61da65102..7c1266b22 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -15,7 +15,7 @@ defmodule Cadet.Accounts.Teams do Enum.reduce_while(teams, {:ok, nil}, fn team_attrs, {:ok, _} -> student_ids = Enum.map(team_attrs, &Map.get(&1, "userId")) - if student_already_in_team?(student_ids, assessment_id) do + if student_already_in_team?(-1, student_ids, assessment_id) do {:halt, {:error, {:conflict, "Team with the same members already exists for this assessment!"}}} else {:ok, team} = %Team{} @@ -36,12 +36,11 @@ defmodule Cadet.Accounts.Teams do end) end - defp student_already_in_team?(student_ids, assessment_id) do - # Check if any of the students in team_attrs are already in a team for the same assessment + defp student_already_in_team?(team_id, student_ids, assessment_id) do query = from tm in TeamMember, join: t in assoc(tm, :team), - where: tm.student_id in ^student_ids and t.assessment_id == ^assessment_id, + where: tm.student_id in ^student_ids and t.assessment_id == ^assessment_id and t.id != ^team_id, select: tm.student_id existing_student_ids = Repo.all(query) @@ -54,7 +53,7 @@ defmodule Cadet.Accounts.Teams do old_assessment_id = team.assessment_id team_id = team.id new_student_ids = Enum.map(hd(student_ids), fn student -> Map.get(student, "userId") end) - if student_already_in_team?(new_student_ids, new_assessment_id) do + if student_already_in_team?(team_id, new_student_ids, new_assessment_id) do {:error, {:conflict, "One or more students are already in another team for the same assessment!"}} else attrs = %{assessment_id: new_assessment_id} @@ -106,20 +105,4 @@ defmodule Cadet.Accounts.Teams do team |> Repo.delete() end - - # def bulk_upload_teams(teams_params) do - # teams = Jason.decode!(teams_params) - # Enum.map(teams, fn team -> - # case get_by_assessment_id(team["assessment_id"]) do - # nil -> create_team(team) - # existing_team -> update_team(existing_team, team) - # end - # end) - # end - - # def get_by_assessment_id(assessment_id) do - # from(Team) - # |> where(assessment_id: ^assessment_id) - # |> Repo.one() - # end end From b930226b709574001fea3417146dd031439b9659 Mon Sep 17 00:00:00 2001 From: LuYiting0913 Date: Sun, 23 Jul 2023 13:56:29 +0800 Subject: [PATCH 017/128] Update seeds --- priv/repo/seeds.exs | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 161ff03da..241f4ea24 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -68,13 +68,13 @@ if Cadet.Env.env() == :dev do # Assessments for i <- 1..5 do - config = insert(:assessment_config, %{type: "Mission#{i}", order: i, course: course1}) + config = insert(:assessment_config, %{type: "Missions", order: i, course: course1}) assessment1 = insert(:assessment, %{is_published: true, config: config, course: course1}) config2 = insert(:assessment_config, %{type: "Homework#{i}", order: i, course: course2}) assessment2 = insert(:assessment, %{is_published: true, config: config2, course: course2}) - config3 = insert(:assessment_config, %{type: "Path#{i}", order: i, course: course1}) + config3 = insert(:assessment_config, %{type: "Paths", order: i, course: course1}) assessment3 = insert(:assessment, %{is_published: true, config: config3, course: course1}) programming_questions = @@ -126,18 +126,19 @@ if Cadet.Env.env() == :dev do team1a = insert(:team, %{assessment: assessment1}) team1b = insert(:team, %{assessment: assessment1}) - team1a = insert(:team, %{assessment: assessment1}) - team1b = insert(:team, %{assessment: assessment1}) - IO.inspect(team1a) - IO.inspect(team1b) - # Team members - member1 = insert(:team_member, %{student: student1d_cr, team: team1a}) - member2 = insert(:team_member, %{student: student1e_cr, team: team1a}) - IO.inspect(member1) - IO.inspect(member2) + team3a = insert(:team, %{assessment: assessment3}) + team3b = insert(:team, %{assessment: assessment3}) - member3 = insert(:team_member, %{student: student1b_cr, team: team1b}) - member4 = insert(:team_member, %{student: student1c_cr, team: team1b}) + # Team members + member1a = insert(:team_member, %{student: student1d_cr, team: team1a}) + member1b = insert(:team_member, %{student: student1e_cr, team: team1a}) + member1c = insert(:team_member, %{student: student1b_cr, team: team1b}) + member1d = insert(:team_member, %{student: student1c_cr, team: team1b}) + + member2a = insert(:team_member, %{student: student1d_cr, team: team3a}) + member2b = insert(:team_member, %{student: student1e_cr, team: team3a}) + member2c = insert(:team_member, %{student: student1b_cr, team: team3b}) + member2d = insert(:team_member, %{student: student1c_cr, team: team3b}) # # Notifications # for submission <- submissions do From 11881713916f39adb052a8cbe78da532591dea6d Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Tue, 25 Jul 2023 00:58:30 +0800 Subject: [PATCH 018/128] Modify TeamMember migration --- .../migrations/20230711033554_create_team_members_table.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/priv/repo/migrations/20230711033554_create_team_members_table.exs b/priv/repo/migrations/20230711033554_create_team_members_table.exs index 9942ea9f2..cab248c63 100644 --- a/priv/repo/migrations/20230711033554_create_team_members_table.exs +++ b/priv/repo/migrations/20230711033554_create_team_members_table.exs @@ -4,7 +4,7 @@ defmodule Cadet.Repo.Migrations.CreateTeamMembersTable do def change do create table(:team_members) do add(:team_id, references(:teams), null: false) - add(:student_id, references(:users), null: false) + add(:student_id, references(:course_registrations), null: false) timestamps() end end From 55dc75d345123976f672ba1c58fba3d9b037a947 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Wed, 26 Jul 2023 18:54:42 +0800 Subject: [PATCH 019/128] Update AlterSubmissionsTable migration --- .../migrations/20230711033707_alter_submissions_table.exs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/priv/repo/migrations/20230711033707_alter_submissions_table.exs b/priv/repo/migrations/20230711033707_alter_submissions_table.exs index 86493fe61..7ca8db90a 100644 --- a/priv/repo/migrations/20230711033707_alter_submissions_table.exs +++ b/priv/repo/migrations/20230711033707_alter_submissions_table.exs @@ -2,11 +2,10 @@ defmodule Cadet.Repo.Migrations.AlterSubmissionsTable do use Ecto.Migration def up do - # Drop the existing constraint execute("ALTER TABLE submissions DROP CONSTRAINT IF EXISTS submissions_student_id_fkey;") alter table(:submissions) do - modify(:student_id, references(:users), null: true) + modify(:student_id, references(:course_registrations), null: true) add(:team_id, references(:teams), null: true) end @@ -20,7 +19,7 @@ defmodule Cadet.Repo.Migrations.AlterSubmissionsTable do execute("ALTER TABLE submissions DROP CONSTRAINT xor_constraint;") alter table(:submissions) do - modify(:student_id, references(:users), null: false) + modify(:student_id, references(:course_registrations), null: false) drop(:team_id) end end From 7252d4dffed610051403fc7e32695c105d791ec0 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Fri, 4 Aug 2023 22:34:55 +0800 Subject: [PATCH 020/128] Add API to retrieve TeamFormationOverview for students --- lib/cadet_web/controllers/team_controller.ex | 53 ++++++++++++++++++++ lib/cadet_web/router.ex | 2 + lib/cadet_web/views/team_view.ex | 14 ++++++ 3 files changed, 69 insertions(+) create mode 100644 lib/cadet_web/controllers/team_controller.ex create mode 100644 lib/cadet_web/views/team_view.ex diff --git a/lib/cadet_web/controllers/team_controller.ex b/lib/cadet_web/controllers/team_controller.ex new file mode 100644 index 000000000..cab4313ea --- /dev/null +++ b/lib/cadet_web/controllers/team_controller.ex @@ -0,0 +1,53 @@ +defmodule CadetWeb.TeamController do + use CadetWeb, :controller + use PhoenixSwagger + + import Ecto.Query + + alias Cadet.Repo + alias Cadet.Accounts.{Teams, Team} + alias CadetWeb.Router.Helpers, as: Routes + + def index(conn, %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do + cr = conn.assigns.course_reg + + query = + from(t in Team, + where: t.assessment_id == ^assessment_id, + join: tm in assoc(t, :team_members), + where: tm.student_id == ^cr.id, + limit: 1 + ) + team = query + |> Repo.one() + |> Repo.preload([assessment: [:config], team_members: [student: [:user]]]) + + if team == nil do + conn + |> put_status(:ok) + |> text("Team is not found!") + else + teamFormationOverview = team_to_team_formation_overview(team) + + conn + |> put_status(:ok) + |> put_resp_content_type("application/json") + |> render("index.json", teamFormationOverview: teamFormationOverview) + end + end + + defp team_to_team_formation_overview(team) do + assessment = team.assessment + + teamFormationOverview = %{ + teamId: team.id, + assessmentId: assessment.id, + assessmentName: assessment.title, + assessmentType: assessment.config.type, + studentIds: team.team_members |> Enum.map(&(&1.student.user.id)), + studentNames: team.team_members |> Enum.map(&(&1.student.user.name)) + } + + teamFormationOverview + end +end diff --git a/lib/cadet_web/router.ex b/lib/cadet_web/router.ex index a1c06727d..a4a6f1081 100644 --- a/lib/cadet_web/router.ex +++ b/lib/cadet_web/router.ex @@ -90,6 +90,8 @@ defmodule CadetWeb.Router do put("/user/research_agreement", UserController, :update_research_agreement) get("/config", CoursesController, :index) + + get("/team/:assessmentid", TeamController, :index) end # Authenticated Pages diff --git a/lib/cadet_web/views/team_view.ex b/lib/cadet_web/views/team_view.ex new file mode 100644 index 000000000..b89b02237 --- /dev/null +++ b/lib/cadet_web/views/team_view.ex @@ -0,0 +1,14 @@ +defmodule CadetWeb.TeamView do + use CadetWeb, :view + + def render("index.json", %{teamFormationOverview: teamFormationOverview}) do + %{ + teamId: teamFormationOverview.teamId, + assessmentId: teamFormationOverview.assessmentId, + assessmentName: teamFormationOverview.assessmentName, + assessmentType: teamFormationOverview.assessmentType, + studentIds: teamFormationOverview.studentIds, + studentNames: teamFormationOverview.studentNames + } + end +end From 223c686b2e4e3c0298b248e08384f792c6e05390 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Fri, 4 Aug 2023 22:35:46 +0800 Subject: [PATCH 021/128] Add Delete Team --- lib/cadet/accounts/teams.ex | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index 7c1266b22..2e6b2bdd2 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -7,7 +7,7 @@ defmodule Cadet.Accounts.Teams do import Ecto.Query alias Cadet.Repo alias Cadet.Accounts.{Team, TeamMember, CourseRegistration} - alias Cadet.Assessments.Assessment + alias Cadet.Assessments.{Answer, Assessment, Submission} def create_team(attrs) do assessment_id = attrs["assessment_id"] @@ -102,6 +102,14 @@ defmodule Cadet.Accounts.Teams do end def delete_team(%Team{} = team) do + Submission + |> where(team_id: ^team.id) + |> Repo.all() + |> Enum.each(fn x -> + Answer + |> where(submission_id: ^x.id) + |> Repo.delete_all() + end) team |> Repo.delete() end From 2d3551247f2e445b139f7962014a1af044f6d183 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Fri, 4 Aug 2023 22:37:24 +0800 Subject: [PATCH 022/128] Refactor SQL chunk to fetch both Team and Individual submissions --- lib/cadet/assessments/assessments.ex | 94 ++++++++++++++++------------ 1 file changed, 55 insertions(+), 39 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index a16b66bb2..d9bd54d3c 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -1187,10 +1187,9 @@ defmodule Cadet.Assessments do # PostgreSQL, because doing it in Elixir/Erlang is too inefficient. case Repo.query( - """ - select json_agg(q)::TEXT from - ( - select + """ + SELECT json_agg(q)::TEXT FROM ( + SELECT s.id, s.status, s."unsubmittedAt", @@ -1199,12 +1198,16 @@ defmodule Cadet.Assessments do s."xpBonus", s."gradedCount", assts.jsn as assessment, - students.jsn as student, + CASE + WHEN s.student_id IS NOT NULL THEN students.jsn + ELSE to_json(team) + END AS participant, unsubmitters.jsn as "unsubmittedBy" - from - (select + FROM ( + SELECT s.id, s.student_id, + s.team_id, s.assessment_id, s.status, s.unsubmitted_at as "unsubmittedAt", @@ -1212,54 +1215,67 @@ defmodule Cadet.Assessments do sum(ans.xp) as xp, sum(ans.xp_adjustment) as "xpAdjustment", s.xp_bonus as "xpBonus", - count(ans.id) filter (where ans.grader_id is not null) as "gradedCount" - from submissions s - left join - answers ans on s.id = ans.submission_id + count(ans.id) FILTER (WHERE ans.grader_id IS NOT NULL) as "gradedCount" + FROM submissions s + LEFT JOIN answers ans ON s.id = ans.submission_id #{group_where} - group by s.id) s - inner join - (select + GROUP BY s.id + ) s + INNER JOIN ( + SELECT a.id, a."questionCount", to_json(a) as jsn - from - (select + FROM ( + SELECT a.id, a.title, bool_or(ac.is_manually_graded) as "isManuallyGraded", max(ac.type) as "type", sum(q.max_xp) as "maxXp", count(q.id) as "questionCount" - from assessments a - left join - questions q on a.id = q.assessment_id - inner join - assessment_configs ac on ac.id = a.config_id - where a.course_id = $1 - group by a.id) a) assts on assts.id = s.assessment_id - inner join - (select + FROM assessments a + LEFT JOIN questions q ON a.id = q.assessment_id + INNER JOIN assessment_configs ac ON ac.id = a.config_id + WHERE a.course_id = $1 + GROUP BY a.id + ) a + ) assts ON assts.id = s.assessment_id + LEFT JOIN ( + SELECT cr.id, to_json(cr) as jsn - from - (select + FROM ( + SELECT cr.id, u.name as "name", g.name as "groupName", g.leader_id as "groupLeaderId" - from course_registrations cr - left join - groups g on g.id = cr.group_id - inner join - users u on u.id = cr.user_id) cr) students on students.id = s.student_id - left join - (select + FROM course_registrations cr + LEFT JOIN groups g ON g.id = cr.group_id + INNER JOIN users u ON u.id = cr.user_id + ) cr + ) students ON students.id = s.student_id + LEFT JOIN ( + SELECT + t.id, + to_json(t) as jsn, + array_agg(cr.id) as student_ids, + array_agg(u.name) as student_names + FROM teams t + LEFT JOIN team_members tm ON t.id = tm.team_id + LEFT JOIN course_registrations cr ON cr.id = tm.student_id + LEFT JOIN users u ON u.id = cr.user_id + GROUP BY t.id + ) team ON team.id = s.team_id + LEFT JOIN ( + SELECT cr.id, to_json(cr) as jsn - from - (select + FROM ( + SELECT cr.id, u.name - from course_registrations cr - inner join - users u on u.id = cr.user_id) cr) unsubmitters on s.unsubmitted_by_id = unsubmitters.id + FROM course_registrations cr + INNER JOIN users u ON u.id = cr.user_id + ) cr + ) unsubmitters ON s.unsubmitted_by_id = unsubmitters.id #{ungraded_where} ) q """, From f1443a8886d4fc3ba829a708380b2a2784d75c77 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Fri, 4 Aug 2023 22:40:05 +0800 Subject: [PATCH 023/128] Add retrieve Team Submission --- lib/cadet/assessments/assessments.ex | 73 +++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 12 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index d9bd54d3c..48e1a7ebc 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -281,10 +281,20 @@ defmodule Cadet.Assessments do by the supplied user """ def all_assessments(cr = %CourseRegistration{}) do + query = + from(t in Team, + join: tm in assoc(t, :team_members), + where: tm.student_id == ^cr.id, + ) + teams = Repo.all(query) + submission_aggregates = Submission |> join(:left, [s], ans in Answer, on: ans.submission_id == s.id) - |> where([s], s.student_id == ^cr.id) + |> where( + [s], + s.student_id == ^cr.id or s.team_id in ^Enum.map(teams, &(&1.id)) + ) |> group_by([s], s.assessment_id) |> select([s, ans], %{ assessment_id: s.assessment_id, @@ -295,7 +305,10 @@ defmodule Cadet.Assessments do submission_status = Submission - |> where([s], s.student_id == ^cr.id) + |> where( + [s], + s.student_id == ^cr.id or s.team_id in ^Enum.map(teams, &(&1.id)) + ) |> select([s], [:assessment_id, :status]) assessments = @@ -714,12 +727,30 @@ defmodule Cadet.Assessments do def get_submission(assessment_id, %CourseRegistration{id: cr_id}) when is_ecto_id(assessment_id) do - Submission - |> where(assessment_id: ^assessment_id) - |> where(student_id: ^cr_id) - |> join(:inner, [s], a in assoc(s, :assessment)) - |> preload([_, a], assessment: a) - |> Repo.one() + query = + from(t in Team, + where: t.assessment_id == ^assessment_id, + join: tm in assoc(t, :team_members), + where: tm.student_id == ^cr_id, + limit: 1 + ) + team = Repo.one(query) + case team do + %Team{} -> + Submission + |> where(assessment_id: ^assessment_id) + |> where(team_id: ^team.id) + |> join(:inner, [s], a in assoc(s, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.one() + _ -> + Submission + |> where(assessment_id: ^assessment_id) + |> where(student_id: ^cr_id) + |> join(:inner, [s], a in assoc(s, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.one() + end end def get_submission_by_id(submission_id) when is_ecto_id(submission_id) do @@ -1475,11 +1506,29 @@ defmodule Cadet.Assessments do end defp find_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do + query = + from(t in Team, + where: t.assessment_id == ^assessment.id, + join: tm in assoc(t, :team_members), + where: tm.student_id == ^cr.id, + limit: 1 + ) + team = Repo.one(query) + submission = - Submission - |> where(student_id: ^cr.id) - |> where(assessment_id: ^assessment.id) - |> Repo.one() + case team do + %Team{} -> + Submission + |> where(team_id: ^team.id) + |> where(assessment_id: ^assessment.id) + |> Repo.one() + + _ -> + Submission + |> where(student_id: ^cr.id) + |> where(assessment_id: ^assessment.id) + |> Repo.one() + end if submission do {:ok, submission} From e1c7da2287c40fcae5ae5dc20303734ddf1320a5 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Fri, 4 Aug 2023 22:41:27 +0800 Subject: [PATCH 024/128] Add create empty submission for Team submission --- lib/cadet/assessments/assessments.ex | 32 ++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 48e1a7ebc..226006f66 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -1630,12 +1630,32 @@ defmodule Cadet.Assessments do end defp create_empty_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do - %Submission{} - |> Submission.changeset(%{student: cr, assessment: assessment}) - |> Repo.insert() - |> case do - {:ok, submission} -> {:ok, submission} - {:error, _} -> {:error, :race_condition} + query = + from(t in Team, + where: t.assessment_id == ^assessment.id, + join: tm in assoc(t, :team_members), + where: tm.student_id == ^cr.id, + limit: 1 + ) + team = Repo.one(query) + + case team do + %Team{} -> + %Submission{} + |> Submission.changeset(%{team: team, assessment: assessment}) + |> Repo.insert() + |> case do + {:ok, submission} -> {:ok, submission} + {:error, _} -> {:error, :race_condition} + end + _ -> + %Submission{} + |> Submission.changeset(%{student: cr, assessment: assessment}) + |> Repo.insert() + |> case do + {:ok, submission} -> {:ok, submission} + {:error, _} -> {:error, :race_condition} + end end end From e5cb4aefff5d42d2c12c5a45139d94982f9fd4d1 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Fri, 4 Aug 2023 22:41:58 +0800 Subject: [PATCH 025/128] Add cascade delete answer when Team is deleted --- lib/cadet/assessments/submission.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/cadet/assessments/submission.ex b/lib/cadet/assessments/submission.ex index b50155c29..a41ee5690 100644 --- a/lib/cadet/assessments/submission.ex +++ b/lib/cadet/assessments/submission.ex @@ -23,7 +23,7 @@ defmodule Cadet.Assessments.Submission do belongs_to(:team, Team) belongs_to(:unsubmitted_by, CourseRegistration) - has_many(:answers, Answer) + has_many(:answers, Answer, on_delete: :delete_all) timestamps() end From 016dc1dbe87d79c192c4f659e07a7f1837faf79e Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Fri, 4 Aug 2023 22:42:26 +0800 Subject: [PATCH 026/128] Update XOR validation in Submission --- lib/cadet/assessments/submission.ex | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/lib/cadet/assessments/submission.ex b/lib/cadet/assessments/submission.ex index a41ee5690..c30f68dc9 100644 --- a/lib/cadet/assessments/submission.ex +++ b/lib/cadet/assessments/submission.ex @@ -2,7 +2,7 @@ defmodule Cadet.Assessments.Submission do @moduledoc false use Cadet, :model - alias Cadet.Accounts.CourseRegistration + alias Cadet.Accounts.{CourseRegistration, Team} alias Cadet.Assessments.{Answer, Assessment, SubmissionStatus} @type t :: %__MODULE__{} @@ -30,9 +30,7 @@ defmodule Cadet.Assessments.Submission do @required_fields [ :assessment_id, - :status, - # XOR relationship between student_id and team_id - {:one_of, [:student_id, :team_id]} + :status ] @optional_fields ~w(xp_bonus unsubmitted_by_id unsubmitted_at)a @@ -41,8 +39,8 @@ defmodule Cadet.Assessments.Submission do submission |> cast(params, @required_fields ++ @optional_fields) |> validate_number(:xp_bonus, greater_than_or_equal_to: 0) - |> validate_xor_relationship() |> add_belongs_to_id_from_model([:team, :student, :assessment, :unsubmitted_by], params) + |> validate_xor_relationship() |> validate_required(@required_fields) |> foreign_key_constraint(:assessment_id) |> foreign_key_constraint(:unsubmitted_by_id) @@ -52,15 +50,15 @@ defmodule Cadet.Assessments.Submission do defp validate_xor_relationship(changeset) do case {get_field(changeset, :student_id), get_field(changeset, :team_id)} do {nil, nil} -> - add_error(changeset, :student_id, "either student_id or team_id must be present") - |> add_error(changeset, :team_id, "either student_id or team_id must be present") + add_error(changeset, :student_id, "either student or team_id must be present") + |> add_error(changeset, :team_id, "either student_id or team must be present") {nil, _} -> changeset {_, nil} -> changeset - {_student_id, _team_id} -> - add_error(changeset, :student_id, "student_id and team_id cannot be present at the same time") - |> add_error(changeset, :team_id, "student_id and team_id cannot be present at the same time") + {_student, _team} -> + add_error(changeset, :student_id, "student and team_id cannot be present at the same time") + |> add_error(changeset, :team_id, "student_id and team cannot be present at the same time") end end end From 613eebd5525553f9487b9742ca0b0d52852835b4 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Fri, 4 Aug 2023 22:43:13 +0800 Subject: [PATCH 027/128] Remove bulk_upload API call and doc --- .../admin_teams_controller.ex | 32 ------------------- 1 file changed, 32 deletions(-) diff --git a/lib/cadet_web/admin_controllers/admin_teams_controller.ex b/lib/cadet_web/admin_controllers/admin_teams_controller.ex index 365171e4e..39c38e868 100644 --- a/lib/cadet_web/admin_controllers/admin_teams_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_teams_controller.ex @@ -67,16 +67,6 @@ defmodule CadetWeb.AdminTeamsController do end end - # def bulk_upload(conn, %{"teams" => teams_params}) do - # case Teams.bulk_upload_teams(teams_params) do - # {:ok, _teams} -> - # text(conn, "Teams uploaded successfully.") - - # {:error, changesets} -> - # render(conn, "bulk_upload.json", changesets: changesets) - # end - # end - def delete(conn, %{"teamId" => id}) do team = Repo.get!(Team, id) @@ -145,28 +135,6 @@ defmodule CadetWeb.AdminTeamsController do response(403, "Forbidden") end - swagger_path :bulk_update do - post("/admin/assessments/{assessmentId}") - - summary("Updates an assessment") - - security([%{JWT: []}]) - - consumes("application/json") - - parameters do - assessmentId(:path, :integer, "Assessment ID", required: true) - - assessment(:body, Schema.ref(:AdminUpdateAssessmentPayload), "Updated assessment details", - required: true - ) - end - - response(200, "OK") - response(401, "Assessment is already opened") - response(403, "Forbidden") - end - swagger_path :delete do PhoenixSwagger.Path.delete("/admin/teams/{assessmentId}") From 71ece241f14d7a57b59b389551171e5f70cb8aa2 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Fri, 4 Aug 2023 22:44:33 +0800 Subject: [PATCH 028/128] Add retrieve of team submission answers --- lib/cadet/assessments/assessments.ex | 49 +++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 226006f66..749d6c883 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -12,6 +12,9 @@ defmodule Cadet.Assessments do Notification, Notifications, User, + Team, + Teams, + TeamMember, CourseRegistration, CourseRegistrations } @@ -88,9 +91,19 @@ defmodule Cadet.Assessments do end def assessments_total_xp(%CourseRegistration{id: cr_id}) do + query = + from(t in Team, + join: tm in assoc(t, :team_members), + where: tm.student_id == ^cr_id, + ) + teams = Repo.all(query) + submission_xp = Submission - |> where(student_id: ^cr_id) + |> where( + [s], + s.student_id == ^cr_id or s.team_id in ^Enum.map(teams, &(&1.id)) + ) |> join(:inner, [s], a in Answer, on: s.id == a.submission_id) |> group_by([s], s.id) |> select([s, a], %{ @@ -243,11 +256,27 @@ defmodule Cadet.Assessments do assessment = %Assessment{id: id}, course_reg = %CourseRegistration{role: role} ) do + + query = + from(t in Team, + where: t.assessment_id == ^assessment.id, + join: tm in assoc(t, :team_members), + where: tm.student_id == ^course_reg.id, + limit: 1 + ) + team = Repo.one(query) + team_id = + if team do + team.id + else + -1 + end + if Timex.compare(Timex.now(), assessment.open_at) >= 0 or role in @open_all_assessment_roles do answer_query = Answer |> join(:inner, [a], s in assoc(a, :submission)) - |> where([_, s], s.student_id == ^course_reg.id) + |> where([_, s], s.student_id == ^course_reg.id or s.team_id == ^team_id) questions = Question @@ -1325,16 +1354,20 @@ defmodule Cadet.Assessments do |> where(submission_id: ^id) |> join(:inner, [a], q in assoc(a, :question)) |> join(:inner, [_, q], ast in assoc(q, :assessment)) - |> join(:inner, [a, ..., ast], ac in assoc(ast, :config)) + |> join(:inner, [..., ast], ac in assoc(ast, :config)) |> join(:left, [a, ...], g in assoc(a, :grader)) - |> join(:left, [a, ..., g], gu in assoc(g, :user)) + |> join(:left, [_, ..., g], gu in assoc(g, :user)) |> join(:inner, [a, ...], s in assoc(a, :submission)) - |> join(:inner, [a, ..., s], st in assoc(s, :student)) - |> join(:inner, [a, ..., st], u in assoc(st, :user)) - |> preload([_, q, ast, ac, g, gu, s, st, u], + |> join(:left, [_, ..., s], st in assoc(s, :student)) + |> join(:left, [..., st], u in assoc(st, :user)) + |> join(:left, [..., s, _, _], t in assoc(s, :team)) + |> join(:left, [..., t], tm in assoc(t, :team_members)) + |> join(:left, [..., tm], tms in assoc(tm, :student)) + |> join(:left, [..., tms], tmu in assoc(tms, :user)) + |> preload([_, q, ast, ac, g, gu, s, st, u, t, tm, tms, tmu], question: {q, assessment: {ast, config: ac}}, grader: {g, user: gu}, - submission: {s, student: {st, user: u}} + submission: {s, student: {st, user: u}, team: {t, team_members: {tm, student: {tms, user: tmu}}}} ) answers = From 83534b44b2e589ff17dae64bce936d2aff282d76 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Fri, 4 Aug 2023 22:44:57 +0800 Subject: [PATCH 029/128] Add unsubmit for team submission --- lib/cadet/assessments/assessments.ex | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 749d6c883..f843649a7 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -878,10 +878,26 @@ defmodule Cadet.Assessments do end) |> Repo.transaction() - Cadet.Accounts.Notifications.handle_unsubmit_notifications( - submission.assessment.id, - Repo.get(CourseRegistration, submission.student_id) - ) + case submission.student_id do + nil -> # Team submission, handle notifications for team members + team = Repo.get(Team, submission.team_id) + team -> + team_members = + from t in Team, + join: tm in TeamMember, on: t.id == tm.team_id, + join: cr in CourseRegistration, on: tm.student_id == cr.student_id, + where: t.id == ^team.id, + select: cr.id + + Enum.each(team_members, fn tm_id -> + Cadet.Accounts.Notifications.handle_unsubmit_notifications(submission.assessment.id, tm_id) + end) + student_id -> + Cadet.Accounts.Notifications.handle_unsubmit_notifications( + submission.assessment.id, + Repo.get(CourseRegistration, submission.student_id) + ) + end {:ok, nil} else From bc8a49d724499b37c621ec1ad76e9d3e3818574e Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Fri, 4 Aug 2023 22:45:33 +0800 Subject: [PATCH 030/128] Add retrieval of team submissions for grading --- .../admin_views/admin_grading_view.ex | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/cadet_web/admin_views/admin_grading_view.ex b/lib/cadet_web/admin_views/admin_grading_view.ex index 8d196c781..bf884596a 100644 --- a/lib/cadet_web/admin_views/admin_grading_view.ex +++ b/lib/cadet_web/admin_views/admin_grading_view.ex @@ -9,8 +9,8 @@ defmodule CadetWeb.AdminGradingView do def render("grading_info.json", %{answer: answer}) do transform_map_for_view(answer, %{ - student: - &transform_map_for_view(&1.submission.student, %{name: fn st -> st.user.name end, id: :id}), + student: &extract_student_data(&1.submission.student), + team: &extract_team_data(&1.submission.team), question: &build_grading_question/1, solution: &(&1.question.question["solution"] || ""), grade: &build_grade/1 @@ -21,6 +21,23 @@ defmodule CadetWeb.AdminGradingView do %{cols: cols, rows: summary} end + defp extract_student_data(nil), do: %{} + defp extract_student_data(student) do + transform_map_for_view(student, %{name: fn st -> st.user.name end, id: :id}) + end + + defp extract_team_member_data(team_member) do + transform_map_for_view(team_member, %{name: &(&1.student.user.name), id: :id}) + end + defp extract_team_data(nil), do: %{} + defp extract_team_data(team) do + members = team.team_members + case members do + [] -> nil + _ -> Enum.map(members, &extract_team_member_data/1) + end + end + defp build_grading_question(answer) do %{question: answer.question} |> build_question_by_question_config(true) From 9c95f1538430ec06c3965cea735f06fd021cab4b Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Fri, 4 Aug 2023 22:50:27 +0800 Subject: [PATCH 031/128] Add handle team submission notifications --- lib/cadet/accounts/notifications.ex | 57 ++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/lib/cadet/accounts/notifications.ex b/lib/cadet/accounts/notifications.ex index 8ee558eb3..c3bfe49c2 100644 --- a/lib/cadet/accounts/notifications.ex +++ b/lib/cadet/accounts/notifications.ex @@ -8,7 +8,7 @@ defmodule Cadet.Accounts.Notifications do import Ecto.Query alias Cadet.Repo - alias Cadet.Accounts.{Notification, CourseRegistration, CourseRegistration} + alias Cadet.Accounts.{Notification, CourseRegistration, CourseRegistration, Team, TeamMember} alias Cadet.Assessments.Submission alias Ecto.Multi @@ -172,14 +172,37 @@ defmodule Cadet.Accounts.Notifications do submission = Submission |> Repo.get_by(id: submission_id) + {:ok, + case submission.student_id do + nil -> + team = Repo.get(Team, submission.team_id) + team -> + team_members = + from t in Team, + join: tm in TeamMember, on: t.id == tm.team_id, + join: cr in CourseRegistration, on: tm.student_id == cr.student_id, + where: t.id == ^team.id, + select: cr.id - write(%{ - type: type, - read: false, - role: :student, - course_reg_id: submission.student_id, - assessment_id: submission.assessment_id - }) + Enum.each(team_members, fn tm_id -> + write(%{ + type: type, + read: false, + role: :student, + course_reg_id: tm_id, + assessment_id: submission.assessment_id + }) + end) + _ -> + write(%{ + type: type, + read: false, + role: :student, + course_reg_id: submission.student_id, + assessment_id: submission.assessment_id + }) + end + } end @doc """ @@ -223,7 +246,23 @@ defmodule Cadet.Accounts.Notifications do @spec write_notification_when_student_submits(Submission.t()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} def write_notification_when_student_submits(submission = %Submission{}) do - avenger_id = get_avenger_id_of(submission.student_id) + id = case submission.student_id do + nil -> + team_id = String.to_integer(to_string(submission.team_id)) + team = + from(t in Team, + where: t.id == ^team_id, + preload: [:team_members] + ) + |> Repo.one() + + s_id = team.team_members |> hd() |> Map.get(:student_id) + s_id + _ -> + submission.student_id + end + + avenger_id = get_avenger_id_of(id) if is_nil(avenger_id) do {:ok, nil} From 2e9668fd2cafc01d1513e0c56e8a93f2fb712cf6 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Fri, 4 Aug 2023 22:55:25 +0800 Subject: [PATCH 032/128] Remove io inspect statement --- lib/cadet/assessments/assessment.ex | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/cadet/assessments/assessment.ex b/lib/cadet/assessments/assessment.ex index 7a49ce1f5..85f064355 100644 --- a/lib/cadet/assessments/assessment.ex +++ b/lib/cadet/assessments/assessment.ex @@ -67,8 +67,6 @@ defmodule Cadet.Assessments.Assessment do defp validate_config_course(changeset) do config_id = get_field(changeset, :config_id) course_id = get_field(changeset, :course_id) - IO.puts("Course ID: #{inspect(course_id)}") - IO.puts("Config ID: #{inspect(config_id)}") case Repo.get(AssessmentConfig, config_id) do nil -> add_error(changeset, :config, "does not exist") From 79de1ffdeda46dafe0511bf443a1b9d2279b3f54 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Fri, 4 Aug 2023 22:58:23 +0800 Subject: [PATCH 033/128] Remove io inspect statement --- lib/cadet/assessments/assessment.ex | 2 -- lib/cadet/assessments/assessments.ex | 1 - 2 files changed, 3 deletions(-) diff --git a/lib/cadet/assessments/assessment.ex b/lib/cadet/assessments/assessment.ex index 7a49ce1f5..85f064355 100644 --- a/lib/cadet/assessments/assessment.ex +++ b/lib/cadet/assessments/assessment.ex @@ -67,8 +67,6 @@ defmodule Cadet.Assessments.Assessment do defp validate_config_course(changeset) do config_id = get_field(changeset, :config_id) course_id = get_field(changeset, :course_id) - IO.puts("Course ID: #{inspect(course_id)}") - IO.puts("Config ID: #{inspect(config_id)}") case Repo.get(AssessmentConfig, config_id) do nil -> add_error(changeset, :config, "does not exist") diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index f843649a7..500e2f98f 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -663,7 +663,6 @@ defmodule Cadet.Assessments do end def update_assessment(id, params) when is_ecto_id(id) do - IO.inspect(params) simple_update( Assessment, id, From da2a782c2c918b463e12eb548c31ce083cad6040 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Sat, 5 Aug 2023 15:38:31 +0800 Subject: [PATCH 034/128] Revert seeds --- priv/repo/seeds.exs | 57 +++++++++------------------------------------ 1 file changed, 11 insertions(+), 46 deletions(-) diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 241f4ea24..98bc22f73 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -12,10 +12,6 @@ import Cadet.Factory alias Cadet.Assessments.SubmissionStatus -alias Cadet.Accounts.Team -alias Cadet.Accounts.Teams - - # insert default source version # Cadet.Repo.insert!(%Cadet.Settings.Sublanguage{chapter: 1, variant: "default"}) @@ -24,21 +20,17 @@ if Cadet.Env.env() == :dev do # Course course1 = insert(:course) course2 = insert(:course, %{course_name: "Algorithm", course_short_name: "CS2040S"}) - # IO.inspect(course1) # Users avenger1 = insert(:user, %{name: "avenger", latest_viewed_course: course1}) admin1 = insert(:user, %{name: "admin", latest_viewed_course: course1}) studenta1admin2 = insert(:user, %{name: "student a", latest_viewed_course: course1}) - studentb1 = insert(:user, %{name: "student 1", latest_viewed_course: course1}) - studentc1 = insert(:user, %{name: "student 2", latest_viewed_course: course1}) - studentd1 = insert(:user, %{name: "student 3", latest_viewed_course: course1}) - studente1 = insert(:user, %{name: "student 4", latest_viewed_course: course1}) - + studentb1 = insert(:user, %{latest_viewed_course: course1}) + studentc1 = insert(:user, %{latest_viewed_course: course1}) # CourseRegistration and Group avenger1_cr = insert(:course_registration, %{user: avenger1, course: course1, role: :staff}) - # _admin1_cr = insert(:course_registration, %{user: admin1, course: course1, role: :admin}) + _admin1_cr = insert(:course_registration, %{user: admin1, course: course1, role: :admin}) group = insert(:group, %{leader: avenger1_cr}) student1a_cr = @@ -55,37 +47,28 @@ if Cadet.Env.env() == :dev do student1c_cr = insert(:course_registration, %{user: studentc1, course: course1, role: :student, group: group}) - student1d_cr = - insert(:course_registration, %{user: studentd1, course: course1, role: :student, group: group}) - - student1e_cr = - insert(:course_registration, %{user: studente1, course: course1, role: :student, group: group}) - - students = [student1a_cr, student1b_cr, student1c_cr, student1d_cr, student1e_cr] + students = [student1a_cr, student1b_cr, student1c_cr] _admin2cr = insert(:course_registration, %{user: studenta1admin2, course: course2, role: :admin}) # Assessments for i <- 1..5 do - config = insert(:assessment_config, %{type: "Missions", order: i, course: course1}) - assessment1 = insert(:assessment, %{is_published: true, config: config, course: course1}) + config = insert(:assessment_config, %{type: "Mission#{i}", order: i, course: course1}) + assessment = insert(:assessment, %{is_published: true, config: config, course: course1}) config2 = insert(:assessment_config, %{type: "Homework#{i}", order: i, course: course2}) - assessment2 = insert(:assessment, %{is_published: true, config: config2, course: course2}) - - config3 = insert(:assessment_config, %{type: "Paths", order: i, course: course1}) - assessment3 = insert(:assessment, %{is_published: true, config: config3, course: course1}) + _assessment2 = insert(:assessment, %{is_published: true, config: config2, course: course2}) programming_questions = insert_list(3, :programming_question, %{ - assessment: assessment1, + assessment: assessment, max_xp: 1_000 }) mcq_questions = insert_list(3, :mcq_question, %{ - assessment: assessment1, + assessment: assessment, max_xp: 500 }) @@ -94,7 +77,7 @@ if Cadet.Env.env() == :dev do |> Enum.take(2) |> Enum.map( &insert(:submission, %{ - assessment: assessment1, + assessment: assessment, student: &1, status: Enum.random(SubmissionStatus.__enum_map__()) }) @@ -122,24 +105,6 @@ if Cadet.Env.env() == :dev do }) end - # Teams - team1a = insert(:team, %{assessment: assessment1}) - team1b = insert(:team, %{assessment: assessment1}) - - team3a = insert(:team, %{assessment: assessment3}) - team3b = insert(:team, %{assessment: assessment3}) - - # Team members - member1a = insert(:team_member, %{student: student1d_cr, team: team1a}) - member1b = insert(:team_member, %{student: student1e_cr, team: team1a}) - member1c = insert(:team_member, %{student: student1b_cr, team: team1b}) - member1d = insert(:team_member, %{student: student1c_cr, team: team1b}) - - member2a = insert(:team_member, %{student: student1d_cr, team: team3a}) - member2b = insert(:team_member, %{student: student1e_cr, team: team3a}) - member2c = insert(:team_member, %{student: student1b_cr, team: team3b}) - member2d = insert(:team_member, %{student: student1c_cr, team: team3b}) - # # Notifications # for submission <- submissions do # case submission.status do @@ -457,4 +422,4 @@ if Cadet.Env.env() == :dev do # prerequisite_uuid: achievement_2.uuid, # achievement_uuid: achievement_0.uuid # }) -end +end \ No newline at end of file From bf9e4db792344972dbf22606bc89052968d54803 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Sat, 5 Aug 2023 15:39:52 +0800 Subject: [PATCH 035/128] Revert seeds --- priv/repo/seeds.exs | 55 +++++++++------------------------------------ 1 file changed, 10 insertions(+), 45 deletions(-) diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 241f4ea24..dee7be580 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -12,10 +12,6 @@ import Cadet.Factory alias Cadet.Assessments.SubmissionStatus -alias Cadet.Accounts.Team -alias Cadet.Accounts.Teams - - # insert default source version # Cadet.Repo.insert!(%Cadet.Settings.Sublanguage{chapter: 1, variant: "default"}) @@ -24,21 +20,17 @@ if Cadet.Env.env() == :dev do # Course course1 = insert(:course) course2 = insert(:course, %{course_name: "Algorithm", course_short_name: "CS2040S"}) - # IO.inspect(course1) # Users avenger1 = insert(:user, %{name: "avenger", latest_viewed_course: course1}) admin1 = insert(:user, %{name: "admin", latest_viewed_course: course1}) studenta1admin2 = insert(:user, %{name: "student a", latest_viewed_course: course1}) - studentb1 = insert(:user, %{name: "student 1", latest_viewed_course: course1}) - studentc1 = insert(:user, %{name: "student 2", latest_viewed_course: course1}) - studentd1 = insert(:user, %{name: "student 3", latest_viewed_course: course1}) - studente1 = insert(:user, %{name: "student 4", latest_viewed_course: course1}) - + studentb1 = insert(:user, %{latest_viewed_course: course1}) + studentc1 = insert(:user, %{latest_viewed_course: course1}) # CourseRegistration and Group avenger1_cr = insert(:course_registration, %{user: avenger1, course: course1, role: :staff}) - # _admin1_cr = insert(:course_registration, %{user: admin1, course: course1, role: :admin}) + _admin1_cr = insert(:course_registration, %{user: admin1, course: course1, role: :admin}) group = insert(:group, %{leader: avenger1_cr}) student1a_cr = @@ -55,37 +47,28 @@ if Cadet.Env.env() == :dev do student1c_cr = insert(:course_registration, %{user: studentc1, course: course1, role: :student, group: group}) - student1d_cr = - insert(:course_registration, %{user: studentd1, course: course1, role: :student, group: group}) - - student1e_cr = - insert(:course_registration, %{user: studente1, course: course1, role: :student, group: group}) - - students = [student1a_cr, student1b_cr, student1c_cr, student1d_cr, student1e_cr] + students = [student1a_cr, student1b_cr, student1c_cr] _admin2cr = insert(:course_registration, %{user: studenta1admin2, course: course2, role: :admin}) # Assessments for i <- 1..5 do - config = insert(:assessment_config, %{type: "Missions", order: i, course: course1}) - assessment1 = insert(:assessment, %{is_published: true, config: config, course: course1}) + config = insert(:assessment_config, %{type: "Mission#{i}", order: i, course: course1}) + assessment = insert(:assessment, %{is_published: true, config: config, course: course1}) config2 = insert(:assessment_config, %{type: "Homework#{i}", order: i, course: course2}) - assessment2 = insert(:assessment, %{is_published: true, config: config2, course: course2}) - - config3 = insert(:assessment_config, %{type: "Paths", order: i, course: course1}) - assessment3 = insert(:assessment, %{is_published: true, config: config3, course: course1}) + _assessment2 = insert(:assessment, %{is_published: true, config: config2, course: course2}) programming_questions = insert_list(3, :programming_question, %{ - assessment: assessment1, + assessment: assessment, max_xp: 1_000 }) mcq_questions = insert_list(3, :mcq_question, %{ - assessment: assessment1, + assessment: assessment, max_xp: 500 }) @@ -94,7 +77,7 @@ if Cadet.Env.env() == :dev do |> Enum.take(2) |> Enum.map( &insert(:submission, %{ - assessment: assessment1, + assessment: assessment, student: &1, status: Enum.random(SubmissionStatus.__enum_map__()) }) @@ -122,24 +105,6 @@ if Cadet.Env.env() == :dev do }) end - # Teams - team1a = insert(:team, %{assessment: assessment1}) - team1b = insert(:team, %{assessment: assessment1}) - - team3a = insert(:team, %{assessment: assessment3}) - team3b = insert(:team, %{assessment: assessment3}) - - # Team members - member1a = insert(:team_member, %{student: student1d_cr, team: team1a}) - member1b = insert(:team_member, %{student: student1e_cr, team: team1a}) - member1c = insert(:team_member, %{student: student1b_cr, team: team1b}) - member1d = insert(:team_member, %{student: student1c_cr, team: team1b}) - - member2a = insert(:team_member, %{student: student1d_cr, team: team3a}) - member2b = insert(:team_member, %{student: student1e_cr, team: team3a}) - member2c = insert(:team_member, %{student: student1b_cr, team: team3b}) - member2d = insert(:team_member, %{student: student1c_cr, team: team3b}) - # # Notifications # for submission <- submissions do # case submission.status do From 7f908f6771937d39a325594ff643af01bf16515d Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Sat, 5 Aug 2023 15:41:11 +0800 Subject: [PATCH 036/128] Revert seeds --- priv/repo/seeds.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 98bc22f73..dee7be580 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -422,4 +422,4 @@ if Cadet.Env.env() == :dev do # prerequisite_uuid: achievement_2.uuid, # achievement_uuid: achievement_0.uuid # }) -end \ No newline at end of file +end From 8fcaaa34eaccbc2e461785ad3b2a4215f8dec4dd Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Sat, 5 Aug 2023 18:18:52 +0800 Subject: [PATCH 037/128] Add last_modified_at field for Answer --- lib/cadet/assessments/answer.ex | 3 ++- lib/cadet/assessments/assessments.ex | 3 ++- lib/cadet_web/helpers/assessments_helpers.ex | 1 + .../20230805094759_add_last_modified_to_answers.exs | 9 +++++++++ 4 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 priv/repo/migrations/20230805094759_add_last_modified_to_answers.exs diff --git a/lib/cadet/assessments/answer.ex b/lib/cadet/assessments/answer.ex index 1035591ab..3cf1bb2f8 100644 --- a/lib/cadet/assessments/answer.ex +++ b/lib/cadet/assessments/answer.ex @@ -23,6 +23,7 @@ defmodule Cadet.Assessments.Answer do field(:autograding_results, {:array, :map}, default: []) field(:answer, :map) field(:type, QuestionType, virtual: true) + field(:last_modified_at, :utc_datetime_usec) belongs_to(:grader, CourseRegistration) belongs_to(:submission, Submission) @@ -32,7 +33,7 @@ defmodule Cadet.Assessments.Answer do end @required_fields ~w(answer submission_id question_id type)a - @optional_fields ~w(xp xp_adjustment grader_id comments)a + @optional_fields ~w(xp xp_adjustment grader_id comments last_modified_at)a def changeset(answer, params) do answer diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 500e2f98f..3ed6ffad0 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -1731,7 +1731,8 @@ defmodule Cadet.Assessments do answer: answer_content, question_id: question.id, submission_id: submission.id, - type: question.type + type: question.type, + last_modified_at: Timex.now() }) Repo.insert( diff --git a/lib/cadet_web/helpers/assessments_helpers.ex b/lib/cadet_web/helpers/assessments_helpers.ex index 961aed928..c4b37df81 100644 --- a/lib/cadet_web/helpers/assessments_helpers.ex +++ b/lib/cadet_web/helpers/assessments_helpers.ex @@ -84,6 +84,7 @@ defmodule CadetWeb.AssessmentsHelpers do transform_map_for_view(answer, %{ answer: answer_builder_for(question_type), + lastModifiedAt: :last_modified_at, grader: grader_builder(grader), gradedAt: graded_at_builder(grader), xp: &((&1.xp || 0) + (&1.xp_adjustment || 0)), diff --git a/priv/repo/migrations/20230805094759_add_last_modified_to_answers.exs b/priv/repo/migrations/20230805094759_add_last_modified_to_answers.exs new file mode 100644 index 000000000..083772c42 --- /dev/null +++ b/priv/repo/migrations/20230805094759_add_last_modified_to_answers.exs @@ -0,0 +1,9 @@ +defmodule Cadet.Repo.Migrations.AddLastModifiedToAnswers do + use Ecto.Migration + + def change do + alter table(:answers) do + add :last_modified_at, :utc_datetime, default: fragment("CURRENT_TIMESTAMP") + end + end +end From e7dd797c45f4a5af351fbfce7095fa44af600fbb Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Tue, 15 Aug 2023 03:34:25 +0800 Subject: [PATCH 038/128] Add Save-Safe --- lib/cadet/assessments/assessments.ex | 50 ++++++++++++++++++- .../controllers/answer_controller.ex | 32 ++++++++++++ lib/cadet_web/router.ex | 1 + lib/cadet_web/views/answer_view.ex | 9 ++++ 4 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 lib/cadet_web/views/answer_view.ex diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 3ed6ffad0..54b124b14 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -753,7 +753,7 @@ defmodule Cadet.Assessments do end end - def get_submission(assessment_id, %CourseRegistration{id: cr_id}) + def get_submission(assessment_id, %CourseRegistration{id: cr_id}) when is_ecto_id(assessment_id) do query = from(t in Team, @@ -1737,12 +1737,58 @@ defmodule Cadet.Assessments do Repo.insert( answer_changeset, - on_conflict: [set: [answer: get_change(answer_changeset, :answer)]], + on_conflict: [set: [answer: get_change(answer_changeset, :answer), last_modified_at: Timex.now()]], conflict_target: [:submission_id, :question_id] ) end end + def checkLastModifiedAnswer( + question = %Question{}, + cr = %CourseRegistration{id: cr_id}, + last_modified_at, + force_submit + ) do + with {:ok, submission} <- find_or_create_submission(cr, question.assessment), + {:status, true} <- {:status, force_submit or submission.status != :submitted}, + {:ok, is_modified} <- check_last_modified_answer(submission, question, last_modified_at, cr_id) do + {:ok, is_modified} + else + {:status, _} -> + {:error, {:forbidden, "Assessment submission already finalised"}} + + {:error, :race_condition} -> + {:error, {:internal_server_error, "Please try again later."}} + + {:error, :invalid_vote} -> + {:error, {:bad_request, "Invalid vote! Vote is not saved."}} + + _ -> + {:error, {:bad_request, "Missing or invalid parameter(s)"}} + end + end + + defp check_last_modified_answer( + submission = %Submission{}, + question = %Question{}, + last_modified_at, + course_reg_id + ) do + case Repo.get_by(Answer, submission_id: submission.id, question_id: question.id) do + + %Answer{last_modified_at: existing_last_modified_at} -> + existing_iso8601 = DateTime.to_iso8601(existing_last_modified_at) + if existing_iso8601 == last_modified_at do + {:ok, false} + else + {:ok, true} + end + + nil -> + {:ok, false} + end + end + def insert_or_update_voting_answer(submission_id, course_reg_id, question_id, answer_content) do set_score_to_nil = SubmissionVotes diff --git a/lib/cadet_web/controllers/answer_controller.ex b/lib/cadet_web/controllers/answer_controller.ex index 5a5b22cb0..00cf65efc 100644 --- a/lib/cadet_web/controllers/answer_controller.ex +++ b/lib/cadet_web/controllers/answer_controller.ex @@ -38,6 +38,38 @@ defmodule CadetWeb.AnswerController do end end + def checkLastModified(conn, %{"questionid" => question_id, "lastModifiedAt" => last_modified_at}) + when is_ecto_id(question_id) do + course_reg = conn.assigns[:course_reg] + can_bypass? = course_reg.role in @bypass_closed_roles + + with {:question, question} when not is_nil(question) <- + {:question, Assessments.get_question(question_id)}, + {:is_open?, true} <- + {:is_open?, can_bypass? or Assessments.is_open?(question.assessment)}, + {:ok, lastModified} <- Assessments.checkLastModifiedAnswer(question, course_reg, last_modified_at, can_bypass?) do + conn + |> put_status(:ok) + |> put_resp_content_type("application/json") + |> render("lastModified.json", lastModified: lastModified) + else + {:question, nil} -> + conn + |> put_status(:not_found) + |> text("Question not found") + + {:is_open?, false} -> + conn + |> put_status(:forbidden) + |> text("Assessment not open") + + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + end + end + def submit(conn, _params) do send_resp(conn, :bad_request, "Missing or invalid parameter(s)") end diff --git a/lib/cadet_web/router.ex b/lib/cadet_web/router.ex index a4a6f1081..0c0aadc68 100644 --- a/lib/cadet_web/router.ex +++ b/lib/cadet_web/router.ex @@ -77,6 +77,7 @@ defmodule CadetWeb.Router do post("/assessments/:assessmentid/unlock", AssessmentsController, :unlock) post("/assessments/:assessmentid/submit", AssessmentsController, :submit) post("/assessments/question/:questionid/answer", AnswerController, :submit) + post("/assessments/question/:questionid/answerLastModified", AnswerController, :checkLastModified) get("/achievements", IncentivesController, :index_achievements) diff --git a/lib/cadet_web/views/answer_view.ex b/lib/cadet_web/views/answer_view.ex new file mode 100644 index 000000000..c1f6407f4 --- /dev/null +++ b/lib/cadet_web/views/answer_view.ex @@ -0,0 +1,9 @@ +defmodule CadetWeb.AnswerView do + use CadetWeb, :view + + def render("lastModified.json", %{lastModified: lastModified}) do + %{ + lastModified: lastModified + } + end +end \ No newline at end of file From 47d446fd33136d456711d74c9fd61935c267946e Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Mon, 21 Aug 2023 19:48:28 +0800 Subject: [PATCH 039/128] Add documentation for models --- lib/cadet/accounts/team.ex | 24 +++++++++++++++++++++++- lib/cadet/accounts/team_member.ex | 22 ++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/lib/cadet/accounts/team.ex b/lib/cadet/accounts/team.ex index 7f03a192a..b9d8e3027 100644 --- a/lib/cadet/accounts/team.ex +++ b/lib/cadet/accounts/team.ex @@ -1,11 +1,24 @@ defmodule Cadet.Accounts.Team do + @moduledoc """ + This module defines the Ecto schema and changeset for teams in the Cadet.Accounts context. + Teams represent groups of students collaborating on an assessment within a course. + """ + use Cadet, :model alias Cadet.Accounts.TeamMember alias Cadet.Assessments.{Assessment, Submission} + @doc """ + Ecto schema definition for teams. + This schema represents a group of students collaborating on a specific assessment within a course. + Fields: + - `assessment`: A reference to the assessment associated with the team. + - `submission`: A reference to the team's submission for the assessment. + - `team_members`: A list of team members associated with the team. + """ schema "teams" do - + belongs_to(:assessment, Assessment) has_one(:submission, Submission, on_delete: :delete_all) has_many(:team_members, TeamMember, on_delete: :delete_all) @@ -15,6 +28,15 @@ defmodule Cadet.Accounts.Team do @required_fields ~w(assessment_id)a + @doc """ + Builds an Ecto changeset for a team. + This function is used to create or update a team record based on the provided attributes. + Args: + - `team`: The existing team struct. + - `attrs`: The attributes to be cast and validated for the changeset. + Returns: + A changeset struct with cast and validated attributes. + """ def changeset(team, attrs) do team |> cast(attrs, @required_fields) diff --git a/lib/cadet/accounts/team_member.ex b/lib/cadet/accounts/team_member.ex index 3fa353aed..6fdb7e9fa 100644 --- a/lib/cadet/accounts/team_member.ex +++ b/lib/cadet/accounts/team_member.ex @@ -1,10 +1,23 @@ defmodule Cadet.Accounts.TeamMember do + @moduledoc """ + This module defines the Ecto schema and changeset for team members in the Cadet.Accounts context. + Team members represent the association between a student and a team within a course. + """ + use Ecto.Schema + import Ecto.Changeset alias Cadet.Accounts.CourseRegistration alias Cadet.Accounts.Team + @doc """ + Ecto schema definition for team members. + This schema represents the relationship between a student and a team within a course. + Fields: + - `student`: A reference to the student's course registration. + - `team`: A reference to the team associated with the student. + """ schema "team_members" do belongs_to(:student, CourseRegistration) @@ -15,6 +28,15 @@ defmodule Cadet.Accounts.TeamMember do @required_fields ~w(student_id team_id)a + @doc """ + Builds an Ecto changeset for a team member. + This function is used to create or update a team member record based on the provided attributes. + Args: + - `team_member`: The existing team member struct. + - `attrs`: The attributes to be cast and validated for the changeset. + Returns: + A changeset struct with cast and validated attributes. + """ def changeset(team_member, attrs) do team_member |> cast(attrs, @required_fields) From fec3e70a66ccceee1507bdbac0e3b153df936ff1 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Mon, 21 Aug 2023 19:49:02 +0800 Subject: [PATCH 040/128] Minor refactoring of Teams --- lib/cadet/accounts/teams.ex | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index 2e6b2bdd2..c03df6407 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -1,10 +1,11 @@ defmodule Cadet.Accounts.Teams do use Cadet, [:context, :display] - use Ecto.Schema + import Ecto.Changeset import Ecto.Query + alias Cadet.Repo alias Cadet.Accounts.{Team, TeamMember, CourseRegistration} alias Cadet.Assessments.{Answer, Assessment, Submission} @@ -19,9 +20,7 @@ defmodule Cadet.Accounts.Teams do {:halt, {:error, {:conflict, "Team with the same members already exists for this assessment!"}}} else {:ok, team} = %Team{} - |> cast(attrs, [:assessment_id]) - |> validate_required([:assessment_id]) - |> foreign_key_constraint(:assessment_id) + |> Team.changeset(attrs) |> Repo.insert() team_id = team.id Enum.each(team_attrs, fn student -> @@ -66,9 +65,6 @@ defmodule Cadet.Accounts.Teams do |> Repo.update() |> case do {:ok, updated_team} -> - if old_assessment_id != new_assessment_id do - delete_associated_submission(team_id, old_assessment_id) - end update_team_members(updated_team, student_ids, team_id) {:ok, updated_team} @@ -98,9 +94,6 @@ defmodule Cadet.Accounts.Teams do end) end - defp delete_associated_submission(team_id, old_assessment_id) do - end - def delete_team(%Team{} = team) do Submission |> where(team_id: ^team.id) From e1a20d0aba7f869afa80063d9c61ac0aaa739d45 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Sat, 26 Aug 2023 18:44:38 +0800 Subject: [PATCH 041/128] Add Swagger Documentation for AdminTeamsController --- .../admin_teams_controller.ex | 106 +++++++++++++----- 1 file changed, 80 insertions(+), 26 deletions(-) diff --git a/lib/cadet_web/admin_controllers/admin_teams_controller.ex b/lib/cadet_web/admin_controllers/admin_teams_controller.ex index 39c38e868..bfc4e18aa 100644 --- a/lib/cadet_web/admin_controllers/admin_teams_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_teams_controller.ex @@ -4,7 +4,6 @@ defmodule CadetWeb.AdminTeamsController do alias Cadet.Repo alias Cadet.Accounts.{Teams, Team} - alias CadetWeb.Router.Helpers, as: Routes def index(conn, _params) do teams = Team @@ -37,7 +36,7 @@ defmodule CadetWeb.AdminTeamsController do def create(conn, %{"team" => team_params}) do case Teams.create_team(team_params) do - {:ok, team} -> + {:ok, _team} -> conn |> put_status(:created) |> text("Teams created successfully.") @@ -55,7 +54,7 @@ defmodule CadetWeb.AdminTeamsController do |> Repo.preload([assessment: [:config], team_members: [student: [:user]]]) case Teams.update_team(team, assessmentId, student_ids) do - {:ok, updated_team} -> + {:ok, _updated_team} -> conn |> put_status(:ok) |> text("Teams updated successfully.") @@ -67,8 +66,8 @@ defmodule CadetWeb.AdminTeamsController do end end - def delete(conn, %{"teamId" => id}) do - team = Repo.get!(Team, id) + def delete(conn, %{"teamId" => team_id}) do + team = Repo.get!(Team, team_id) case Teams.delete_team(team) do {:ok, _} -> @@ -79,42 +78,37 @@ defmodule CadetWeb.AdminTeamsController do end swagger_path :index do - get("/admin/users/{courseRegId}/assessments") + get("/admin/teams") - summary("Fetches assessment overviews of a user") + summary("Fetches every team in the course") security([%{JWT: []}]) - parameters do - courseRegId(:path, :integer, "Course Reg ID", required: true) - end - - response(200, "OK", Schema.array(:AssessmentsList)) - response(401, "Unauthorised") + response(200, "OK", Schema.array(:TeamsList)) + response(400, "Bad Request") response(403, "Forbidden") end swagger_path :create do - post("/admin/assessments") + post("/admin/teams") - summary("Creates a new team or updates an existing team") + summary("Creates a new team") security([%{JWT: []}]) - consumes("multipart/form-data") + consumes("application/json") # Adjust the content type if applicable parameters do - assessment(:formData, :file, "Assessment to create or update", required: true) - forceUpdate(:formData, :boolean, "Force update", required: true) + team_params(:body, :AdminCreateTeamPayload, "Team parameters", required: true) end - response(200, "OK") - response(400, "XML parse error") + response(201, "Created") + response(400, "Bad Request") response(403, "Forbidden") end swagger_path :update do - post("/admin/assessments/{teamId}") + post("/admin/teams/{teamId}") summary("Updates a team") @@ -125,28 +119,88 @@ defmodule CadetWeb.AdminTeamsController do parameters do teamId(:path, :integer, "Team ID", required: true) - team(:body, Schema.ref(:AdminUpdateAssessmentPayload), "Updated team details", + team(:body, Schema.ref(:AdminUpdateTeamPayload), "Updated team details", required: true ) end response(200, "OK") - response(401, "Assessment is already opened") + response(400, "Bad Request") response(403, "Forbidden") end swagger_path :delete do - PhoenixSwagger.Path.delete("/admin/teams/{assessmentId}") + PhoenixSwagger.Path.delete("/admin/teams/{teamId}") - summary("Deletes an assessment") + summary("Deletes a team") security([%{JWT: []}]) parameters do - assessmentId(:path, :integer, "Assessment ID", required: true) + teamId(:path, :integer, "Team ID", required: true) end response(200, "OK") + response(400, "Bad Request") response(403, "Forbidden") end + + def swagger_definitions do + %{ + # Schemas for payloads to create or modify data + AdminCreateTeamPayload: %{ + "type" => "object", + "properties" => %{ + "name" => %{"type" => "string", "description" => "Team name"}, + "course" => %{"type" => "string", "description" => "Course name"}, + "other_property" => %{"type" => "string", "description" => "Other relevant property"} + }, + "required" => ["name", "course"] + }, + AdminUpdateTeamPayload: %{ + "type" => "object", + "properties" => %{ + "teamId" => %{"type" => "number", "description" => "The existing team id"}, + "assessmentId" => %{"type" => "number", "description" => "The updated assessment id"}, + "student_ids" => %{ + "type" => "array", + "items" => %{"$ref" => "#/definitions/AdminUpdateStudentId"}, + "description" => "The updated student ids" + } + }, + "required" => ["teamId", "assessmentId", "student_ids"] + }, + AdminUpdateStudentId: %{ + "type" => "object", + "properties" => %{ + "id" => %{"type" => "number", "description" => "Student ID"} + }, + "required" => ["id"] + }, + TeamList: %{ + "type" => "array", + "items" => %{"$ref" => "#/definitions/TeamItem"} + }, + TeamItem: %{ + "type" => "object", + "properties" => %{ + "teamId" => %{"type" => "integer", "description" => "Team ID"}, + "assessmentId" => %{"type" => "integer", "description" => "Assessment ID"}, + "assessmentName" => %{"type" => "string", "description" => "Assessment name"}, + "assessmentType" => %{"type" => "string", "description" => "Assessment type"}, + "studentIds" => %{ + "type" => "array", + "items" => %{"type" => "integer"}, + "description" => "Student IDs" + }, + "studentNames" => %{ + "type" => "array", + "items" => %{"type" => "string"}, + "description" => "Student names" + } + }, + "required" => ["teamId", "assessmentId", "assessmentName", "assessmentType", "studentIds", "studentNames"] + } + } + end end From a811fd7f039689f8209f14753aa4a29f2112a958 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Sat, 26 Aug 2023 18:56:06 +0800 Subject: [PATCH 042/128] Write Function Documentation for Teams --- lib/cadet/accounts/teams.ex | 60 +++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index c03df6407..c21d20770 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -1,4 +1,7 @@ defmodule Cadet.Accounts.Teams do + @moduledoc """ + This module provides functions to manage teams in the Cadet system. + """ use Cadet, [:context, :display] use Ecto.Schema @@ -10,6 +13,18 @@ defmodule Cadet.Accounts.Teams do alias Cadet.Accounts.{Team, TeamMember, CourseRegistration} alias Cadet.Assessments.{Answer, Assessment, Submission} + @doc """ + Creates a new team and assigns team members to it. + + ## Parameters + + * `attrs` - A map containing the attributes for assessment id and creating the team and its members. + + ## Returns + + Returns a tuple `{:ok, team}` on success; otherwise, an error tuple. + + """ def create_team(attrs) do assessment_id = attrs["assessment_id"] teams = attrs["student_ids"] @@ -35,6 +50,20 @@ defmodule Cadet.Accounts.Teams do end) end + @doc """ + Checks if one or more students are already in another team for the same assessment. + + ## Parameters + + * `team_id` - ID of the team being updated (use -1 for team creation) + * `student_ids` - List of student IDs + * `assessment_id` - ID of the assessment + + ## Returns + + Returns `true` if any student in the list is already a member of another team for the same assessment; otherwise, returns `false`. + + """ defp student_already_in_team?(team_id, student_ids, assessment_id) do query = from tm in TeamMember, @@ -47,7 +76,20 @@ defmodule Cadet.Accounts.Teams do Enum.any?(student_ids, fn student_id -> Enum.member?(existing_student_ids, student_id) end) end + @doc """ + Updates an existing team, the corresponding assessment, and its members. + ## Parameters + + * `team` - The existing team to be updated + * `new_assessment_id` - The ID of the updated assessment + * `student_ids` - List of student ids for team members + + ## Returns + + Returns a tuple `{:ok, updated_team}` on success, containing the updated team details; otherwise, an error tuple. + + """ def update_team(%Team{} = team, new_assessment_id, student_ids) do old_assessment_id = team.assessment_id team_id = team.id @@ -75,6 +117,16 @@ defmodule Cadet.Accounts.Teams do end end + @doc """ + Updates team members based on the new list of student IDs. + + ## Parameters + + * `team` - The team being updated + * `student_ids` - List of student ids for team members + * `team_id` - ID of the team + + """ defp update_team_members(team, student_ids, team_id) do current_student_ids = team.team_members |> Enum.map(&(&1.student_id)) new_student_ids = Enum.map(hd(student_ids), fn student -> Map.get(student, "userId") end) @@ -94,6 +146,14 @@ defmodule Cadet.Accounts.Teams do end) end + @doc """ + Deletes a team along with its associated submissions and answers. + + ## Parameters + + * `team` - The team to be deleted + + """ def delete_team(%Team{} = team) do Submission |> where(team_id: ^team.id) From 0b523c79ca9a83eed1be36597470f91b40c74918 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Sat, 26 Aug 2023 19:25:01 +0800 Subject: [PATCH 043/128] Add documentation and minor refactoring --- lib/cadet/accounts/teams.ex | 2 +- lib/cadet/assessments/assessments.ex | 10 +++--- lib/cadet_web/admin_views/admin_teams_view.ex | 21 ++++++++++++ .../controllers/answer_controller.ex | 2 +- lib/cadet_web/controllers/team_controller.ex | 34 +++++++++++++++++++ lib/cadet_web/views/team_view.ex | 12 +++++++ 6 files changed, 73 insertions(+), 8 deletions(-) diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index c21d20770..3267b69fc 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -14,7 +14,7 @@ defmodule Cadet.Accounts.Teams do alias Cadet.Assessments.{Answer, Assessment, Submission} @doc """ - Creates a new team and assigns team members to it. + Creates a new team and assigns an assessment and team members to it. ## Parameters diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 54b124b14..0db28ef8e 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -13,7 +13,6 @@ defmodule Cadet.Assessments do Notifications, User, Team, - Teams, TeamMember, CourseRegistration, CourseRegistrations @@ -1743,7 +1742,7 @@ defmodule Cadet.Assessments do end end - def checkLastModifiedAnswer( + def has_last_modified_answer?( question = %Question{}, cr = %CourseRegistration{id: cr_id}, last_modified_at, @@ -1751,7 +1750,7 @@ defmodule Cadet.Assessments do ) do with {:ok, submission} <- find_or_create_submission(cr, question.assessment), {:status, true} <- {:status, force_submit or submission.status != :submitted}, - {:ok, is_modified} <- check_last_modified_answer(submission, question, last_modified_at, cr_id) do + {:ok, is_modified} <- answer_last_modified?(submission, question, last_modified_at) do {:ok, is_modified} else {:status, _} -> @@ -1768,11 +1767,10 @@ defmodule Cadet.Assessments do end end - defp check_last_modified_answer( + defp answer_last_modified?( submission = %Submission{}, question = %Question{}, - last_modified_at, - course_reg_id + last_modified_at ) do case Repo.get_by(Answer, submission_id: submission.id, question_id: question.id) do diff --git a/lib/cadet_web/admin_views/admin_teams_view.ex b/lib/cadet_web/admin_views/admin_teams_view.ex index 1f7f6c826..538c509a6 100644 --- a/lib/cadet_web/admin_views/admin_teams_view.ex +++ b/lib/cadet_web/admin_views/admin_teams_view.ex @@ -1,10 +1,30 @@ defmodule CadetWeb.AdminTeamsView do + @moduledoc """ + View module for rendering admin teams data in JSON format. + """ + use CadetWeb, :view + @doc """ + Renders a list of team formation overviews in JSON format. + + ## Parameters + + * `teamFormationOverviews` - A list of team formation overviews to be rendered. + + """ def render("index.json", %{teamFormationOverviews: teamFormationOverviews}) do render_many(teamFormationOverviews, CadetWeb.AdminTeamsView, "team_formation_overview.json", as: :team_formation_overview) end + @doc """ + Renders a single team formation overview in JSON format. + + ## Parameters + + * `team_formation_overview` - The team formation overview to be rendered. + + """ def render("team_formation_overview.json", %{team_formation_overview: team_formation_overview}) do %{ teamId: team_formation_overview.teamId, @@ -16,3 +36,4 @@ defmodule CadetWeb.AdminTeamsView do } end end + diff --git a/lib/cadet_web/controllers/answer_controller.ex b/lib/cadet_web/controllers/answer_controller.ex index 00cf65efc..0450b5b82 100644 --- a/lib/cadet_web/controllers/answer_controller.ex +++ b/lib/cadet_web/controllers/answer_controller.ex @@ -47,7 +47,7 @@ defmodule CadetWeb.AnswerController do {:question, Assessments.get_question(question_id)}, {:is_open?, true} <- {:is_open?, can_bypass? or Assessments.is_open?(question.assessment)}, - {:ok, lastModified} <- Assessments.checkLastModifiedAnswer(question, course_reg, last_modified_at, can_bypass?) do + {:ok, lastModified} <- Assessments.has_last_modified_answer?(question, course_reg, last_modified_at, can_bypass?) do conn |> put_status(:ok) |> put_resp_content_type("application/json") diff --git a/lib/cadet_web/controllers/team_controller.ex b/lib/cadet_web/controllers/team_controller.ex index cab4313ea..4c558e95d 100644 --- a/lib/cadet_web/controllers/team_controller.ex +++ b/lib/cadet_web/controllers/team_controller.ex @@ -1,4 +1,8 @@ defmodule CadetWeb.TeamController do + @moduledoc """ + Controller module for handling team-related actions. + """ + use CadetWeb, :controller use PhoenixSwagger @@ -50,4 +54,34 @@ defmodule CadetWeb.TeamController do teamFormationOverview end + + swagger_path :index do + get("/admin/teams") + + summary("Fetches team formation overview based on assessment ID") + + security([%{JWT: []}]) + + parameters do + assessmentid(:query, :string, "Assessment ID", required: true) + end + + response(200, "OK", Schema.ref(:TeamFormationOverview)) + response(403, "Forbidden") + end + + @swagger_definitions %{ + TeamFormationOverview: %{ + "type" => "object", + "properties" => %{ + "teamId" => %{"type" => "number", "description" => "The ID of the team"}, + "assessmentId" => %{"type" => "number", "description" => "The ID of the assessment"}, + "assessmentName" => %{"type" => "string", "description" => "The name of the assessment"}, + "assessmentType" => %{"type" => "string", "description" => "The type of the assessment"}, + "studentIds" => %{"type" => "array", "items" => %{"type" => "number"}, "description" => "List of student IDs"}, + "studentNames" => %{"type" => "array", "items" => %{"type" => "string"}, "description" => "List of student names"} + }, + "required" => ["teamId", "assessmentId", "assessmentName", "assessmentType", "studentIds", "studentNames"] + } + } end diff --git a/lib/cadet_web/views/team_view.ex b/lib/cadet_web/views/team_view.ex index b89b02237..c93f684c1 100644 --- a/lib/cadet_web/views/team_view.ex +++ b/lib/cadet_web/views/team_view.ex @@ -1,6 +1,18 @@ defmodule CadetWeb.TeamView do + @moduledoc """ + View module for rendering team-related data as JSON. + """ + use CadetWeb, :view + @doc """ + Renders the JSON representation of team formation overview. + + ## Parameters + + * `teamFormationOverview` - A map containing team formation overview data. + + """ def render("index.json", %{teamFormationOverview: teamFormationOverview}) do %{ teamId: teamFormationOverview.teamId, From 9defe7537ce278da3923bd0ba506d6845c882f46 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Sat, 26 Aug 2023 22:23:25 +0800 Subject: [PATCH 044/128] Minor changes to existing tests --- lib/cadet/accounts/notifications.ex | 33 +++++++++---------- test/cadet/assessments/submission_test.exs | 2 +- .../admin_assessments_controller_test.exs | 2 ++ .../admin_grading_controller_test.exs | 25 +++++++++----- .../assessments_controller_test.exs | 4 +++ .../assessments/assessment_factory.ex | 3 +- 6 files changed, 41 insertions(+), 28 deletions(-) diff --git a/lib/cadet/accounts/notifications.ex b/lib/cadet/accounts/notifications.ex index c3bfe49c2..052e484a1 100644 --- a/lib/cadet/accounts/notifications.ex +++ b/lib/cadet/accounts/notifications.ex @@ -169,20 +169,19 @@ defmodule Cadet.Accounts.Notifications do @spec write_notification_when_graded(integer(), any()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} def write_notification_when_graded(submission_id, type) when type in [:graded, :autograded] do - submission = - Submission - |> Repo.get_by(id: submission_id) - {:ok, - case submission.student_id do - nil -> + case Repo.get(Submission, submission_id) do + nil -> + {:error, %Ecto.Changeset{}} + submission -> + case submission.student_id do + nil -> team = Repo.get(Team, submission.team_id) - team -> - team_members = - from t in Team, - join: tm in TeamMember, on: t.id == tm.team_id, - join: cr in CourseRegistration, on: tm.student_id == cr.student_id, - where: t.id == ^team.id, - select: cr.id + team_members = + from t in Team, + join: tm in TeamMember, on: t.id == tm.team_id, + join: cr in CourseRegistration, on: tm.student_id == cr.student_id, + where: t.id == ^team.id, + select: cr.id Enum.each(team_members, fn tm_id -> write(%{ @@ -193,16 +192,16 @@ defmodule Cadet.Accounts.Notifications do assessment_id: submission.assessment_id }) end) - _ -> + student_id -> write(%{ type: type, read: false, role: :student, - course_reg_id: submission.student_id, + course_reg_id: student_id, assessment_id: submission.assessment_id }) - end - } + end + end end @doc """ diff --git a/test/cadet/assessments/submission_test.exs b/test/cadet/assessments/submission_test.exs index 4d7438d8d..bcfcc4a78 100644 --- a/test/cadet/assessments/submission_test.exs +++ b/test/cadet/assessments/submission_test.exs @@ -3,7 +3,7 @@ defmodule Cadet.Assessments.SubmissionTest do use Cadet.ChangesetCase, entity: Submission - @required_fields ~w(student_id assessment_id)a + @required_fields ~w(assessment_id)a setup do course = insert(:course) diff --git a/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs b/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs index f1411ada3..85792f1e1 100644 --- a/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs @@ -83,6 +83,7 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do "type" => &1.config.type, "isManuallyGraded" => &1.config.is_manually_graded, "coverImage" => &1.cover_picture, + "maxTeamSize" => 0, "maxXp" => 4800, "status" => get_assessment_status(view_as, &1), "private" => false, @@ -129,6 +130,7 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do "type" => &1.config.type, "isManuallyGraded" => &1.config.is_manually_graded, "coverImage" => &1.cover_picture, + "maxTeamSize" => 0, "maxXp" => 4800, "status" => get_assessment_status(view_as, &1), "private" => false, diff --git a/test/cadet_web/admin_controllers/admin_grading_controller_test.exs b/test/cadet_web/admin_controllers/admin_grading_controller_test.exs index dfe691e7b..b35ba1639 100644 --- a/test/cadet_web/admin_controllers/admin_grading_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_grading_controller_test.exs @@ -120,7 +120,7 @@ defmodule CadetWeb.AdminGradingControllerTest do "xpAdjustment" => -2500, "xpBonus" => 100, "id" => submission.id, - "student" => %{ + "participant" => %{ "name" => submission.student.user.name, "id" => submission.student.id, "groupName" => submission.student.group.name, @@ -183,7 +183,7 @@ defmodule CadetWeb.AdminGradingControllerTest do "xpAdjustment" => -2500, "xpBonus" => 100, "id" => submission.id, - "student" => %{ + "participant" => %{ "name" => submission.student.user.name, "id" => submission.student.id, "groupName" => submission.student.group.name, @@ -292,7 +292,8 @@ defmodule CadetWeb.AdminGradingControllerTest do "student" => %{ "name" => &1.submission.student.user.name, "id" => &1.submission.student.id - } + }, + "team" => %{} } :mcq -> @@ -339,7 +340,8 @@ defmodule CadetWeb.AdminGradingControllerTest do "student" => %{ "name" => &1.submission.student.user.name, "id" => &1.submission.student.id - } + }, + "team" => %{} } :voting -> @@ -382,6 +384,7 @@ defmodule CadetWeb.AdminGradingControllerTest do "name" => &1.submission.student.user.name, "id" => &1.submission.student.id }, + "team" => %{}, "solution" => "" } end @@ -780,7 +783,7 @@ defmodule CadetWeb.AdminGradingControllerTest do "xpAdjustment" => -2500, "xpBonus" => 100, "id" => submission.id, - "student" => %{ + "participant" => %{ "name" => submission.student.user.name, "id" => submission.student.id, "groupName" => submission.student.group.name, @@ -823,7 +826,7 @@ defmodule CadetWeb.AdminGradingControllerTest do "xpAdjustment" => -2500, "xpBonus" => 100, "id" => submission.id, - "student" => %{ + "participant" => %{ "name" => submission.student.user.name, "id" => submission.student.id, "groupName" => submission.student.group.name, @@ -932,7 +935,8 @@ defmodule CadetWeb.AdminGradingControllerTest do "student" => %{ "name" => &1.submission.student.user.name, "id" => &1.submission.student.id - } + }, + "team" => %{} } :mcq -> @@ -979,7 +983,8 @@ defmodule CadetWeb.AdminGradingControllerTest do "student" => %{ "name" => &1.submission.student.user.name, "id" => &1.submission.student.id - } + }, + "team" => %{} } :voting -> @@ -1022,6 +1027,7 @@ defmodule CadetWeb.AdminGradingControllerTest do "name" => &1.submission.student.user.name, "id" => &1.submission.student.id }, + "team" => %{}, "solution" => "" } end @@ -1258,7 +1264,8 @@ defmodule CadetWeb.AdminGradingControllerTest do title: "mission", course: course, config: assessment_config, - is_published: true + is_published: true, + max_team_size: 0 }) questions = diff --git a/test/cadet_web/controllers/assessments_controller_test.exs b/test/cadet_web/controllers/assessments_controller_test.exs index 709fa960e..3c3a8ba54 100644 --- a/test/cadet_web/controllers/assessments_controller_test.exs +++ b/test/cadet_web/controllers/assessments_controller_test.exs @@ -73,6 +73,7 @@ defmodule CadetWeb.AssessmentsControllerTest do "type" => &1.config.type, "isManuallyGraded" => &1.config.is_manually_graded, "coverImage" => &1.cover_picture, + "maxTeamSize" => &1.max_team_size, "maxXp" => 4800, "status" => get_assessment_status(course_reg, &1), "private" => false, @@ -156,6 +157,7 @@ defmodule CadetWeb.AssessmentsControllerTest do "type" => &1.config.type, "isManuallyGraded" => &1.config.is_manually_graded, "coverImage" => &1.cover_picture, + "maxTeamSize" => &1.max_team_size, "maxXp" => 4800, "status" => get_assessment_status(student, &1), "private" => false, @@ -266,6 +268,7 @@ defmodule CadetWeb.AssessmentsControllerTest do "type" => &1.config.type, "isManuallyGraded" => &1.config.is_manually_graded, "coverImage" => &1.cover_picture, + "maxTeamSize" => &1.max_team_size, "maxXp" => 4800, "status" => get_assessment_status(course_reg, &1), "private" => false, @@ -461,6 +464,7 @@ defmodule CadetWeb.AssessmentsControllerTest do |> Enum.map(&Map.delete(&1, "solution")) |> Enum.map(&Map.delete(&1, "library")) |> Enum.map(&Map.delete(&1, "xp")) + |> Enum.map(&Map.delete(&1, "lastModifiedAt")) |> Enum.map(&Map.delete(&1, "maxXp")) |> Enum.map(&Map.delete(&1, "grader")) |> Enum.map(&Map.delete(&1, "gradedAt")) diff --git a/test/factories/assessments/assessment_factory.ex b/test/factories/assessments/assessment_factory.ex index 71682e52e..d51dc637c 100644 --- a/test/factories/assessments/assessment_factory.ex +++ b/test/factories/assessments/assessment_factory.ex @@ -38,7 +38,8 @@ defmodule Cadet.Assessments.AssessmentFactory do course: course, open_at: Timex.now(), close_at: Timex.shift(Timex.now(), days: Enum.random(1..30)), - is_published: false + is_published: false, + max_team_size: 0 } end end From 80b83a54b2f68db0e93cb203fa747f4ad935c3b9 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Tue, 12 Dec 2023 17:17:43 +0800 Subject: [PATCH 045/128] Cascade delete notification for submission --- lib/cadet/assessments/submission.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/cadet/assessments/submission.ex b/lib/cadet/assessments/submission.ex index c30f68dc9..7bb6e472f 100644 --- a/lib/cadet/assessments/submission.ex +++ b/lib/cadet/assessments/submission.ex @@ -24,6 +24,7 @@ defmodule Cadet.Assessments.Submission do belongs_to(:unsubmitted_by, CourseRegistration) has_many(:answers, Answer, on_delete: :delete_all) + has_one(:notification, Notification, on_delete: :delete_all) timestamps() end From 9d0c1ab3c7feda91daae21ec261b0df74abb6d74 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Tue, 12 Dec 2023 17:18:05 +0800 Subject: [PATCH 046/128] Update error message for team creation --- lib/cadet/accounts/teams.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index 3267b69fc..67cdcda50 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -32,7 +32,7 @@ defmodule Cadet.Accounts.Teams do Enum.reduce_while(teams, {:ok, nil}, fn team_attrs, {:ok, _} -> student_ids = Enum.map(team_attrs, &Map.get(&1, "userId")) if student_already_in_team?(-1, student_ids, assessment_id) do - {:halt, {:error, {:conflict, "Team with the same members already exists for this assessment!"}}} + {:halt, {:error, {:conflict, "One or more students already in a team for this assessment!"}}} else {:ok, team} = %Team{} |> Team.changeset(attrs) From 6a957e7fd73e73061c384ba48aa91a414489ddc7 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Tue, 12 Dec 2023 17:18:38 +0800 Subject: [PATCH 047/128] Update assessments Team retrieval --- lib/cadet/assessments/assessments.ex | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 0db28ef8e..f91b39b50 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -312,7 +312,7 @@ defmodule Cadet.Assessments do query = from(t in Team, join: tm in assoc(t, :team_members), - where: tm.student_id == ^cr.id, + where: tm.student_id == ^cr.id ) teams = Repo.all(query) @@ -879,21 +879,21 @@ defmodule Cadet.Assessments do case submission.student_id do nil -> # Team submission, handle notifications for team members team = Repo.get(Team, submission.team_id) - team -> - team_members = - from t in Team, - join: tm in TeamMember, on: t.id == tm.team_id, - join: cr in CourseRegistration, on: tm.student_id == cr.student_id, - where: t.id == ^team.id, - select: cr.id + team_members = + from t in Team, + join: tm in TeamMember, on: t.id == tm.team_id, + join: cr in CourseRegistration, on: tm.student_id == cr.student_id, + where: t.id == ^team.id, + select: cr.id Enum.each(team_members, fn tm_id -> Cadet.Accounts.Notifications.handle_unsubmit_notifications(submission.assessment.id, tm_id) end) + student_id -> Cadet.Accounts.Notifications.handle_unsubmit_notifications( submission.assessment.id, - Repo.get(CourseRegistration, submission.student_id) + Repo.get(CourseRegistration, student_id) ) end From 06612525391eb7f44581aa01816641a25bbc8b21 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 13 Dec 2023 10:15:23 +0800 Subject: [PATCH 048/128] Add cascading delete for notifications --- lib/cadet/assessments/submission.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/cadet/assessments/submission.ex b/lib/cadet/assessments/submission.ex index c30f68dc9..a093c890f 100644 --- a/lib/cadet/assessments/submission.ex +++ b/lib/cadet/assessments/submission.ex @@ -24,6 +24,7 @@ defmodule Cadet.Assessments.Submission do belongs_to(:unsubmitted_by, CourseRegistration) has_many(:answers, Answer, on_delete: :delete_all) + has_many(:answers, Notification, on_delete: :delete_all) timestamps() end From 4685c76d62665e10dedd10f1ce5f5349a3aadf6a Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Wed, 13 Dec 2023 17:19:32 +0800 Subject: [PATCH 049/128] Fix Team Delete Bug --- lib/cadet/accounts/teams.ex | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index 67cdcda50..ac93bbd8f 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -10,7 +10,7 @@ defmodule Cadet.Accounts.Teams do import Ecto.Query alias Cadet.Repo - alias Cadet.Accounts.{Team, TeamMember, CourseRegistration} + alias Cadet.Accounts.{Team, TeamMember, CourseRegistration, Notification} alias Cadet.Assessments.{Answer, Assessment, Submission} @doc """ @@ -155,6 +155,9 @@ defmodule Cadet.Accounts.Teams do """ def delete_team(%Team{} = team) do + submission = Submission + |> where(team_id: ^team.id) + |> Repo.one() Submission |> where(team_id: ^team.id) |> Repo.all() @@ -163,6 +166,9 @@ defmodule Cadet.Accounts.Teams do |> where(submission_id: ^x.id) |> Repo.delete_all() end) + Notification + |> where(submission_id: ^submission.id) + |> Repo.delete_all() team |> Repo.delete() end From 295254c0b500aa709e02de821bca44b3ba3e78ee Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Wed, 13 Dec 2023 18:06:15 +0800 Subject: [PATCH 050/128] Fix save answer bug when no team --- lib/cadet/accounts/teams.ex | 25 +++++++++++++++---------- lib/cadet/assessments/assessments.ex | 25 ++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index ac93bbd8f..03a1da3f6 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -158,17 +158,22 @@ defmodule Cadet.Accounts.Teams do submission = Submission |> where(team_id: ^team.id) |> Repo.one() - Submission - |> where(team_id: ^team.id) - |> Repo.all() - |> Enum.each(fn x -> - Answer - |> where(submission_id: ^x.id) + + if submission do + Submission + |> where(team_id: ^team.id) + |> Repo.all() + |> Enum.each(fn x -> + Answer + |> where(submission_id: ^x.id) + |> Repo.delete_all() + end) + + Notification + |> where(submission_id: ^submission.id) |> Repo.delete_all() - end) - Notification - |> where(submission_id: ^submission.id) - |> Repo.delete_all() + end + team |> Repo.delete() end diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index f91b39b50..eb8885f6a 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -731,7 +731,8 @@ defmodule Cadet.Assessments do raw_answer, force_submit ) do - with {:ok, submission} <- find_or_create_submission(cr, question.assessment), + with {:ok, team} <- find_team(question.assessment.id, cr_id), + {:ok, submission} <- find_or_create_submission(cr, question.assessment), {:status, true} <- {:status, force_submit or submission.status != :submitted}, {:ok, _answer} <- insert_or_update_answer(submission, question, raw_answer, cr_id) do update_submission_status_router(submission, question) @@ -744,6 +745,9 @@ defmodule Cadet.Assessments do {:error, :race_condition} -> {:error, {:internal_server_error, "Please try again later."}} + {:error, :team_not_found} -> + {:error, {:bad_request, "Your existing Team has been deleted!"}} + {:error, :invalid_vote} -> {:error, {:bad_request, "Invalid vote! Vote is not saved."}} @@ -752,8 +756,23 @@ defmodule Cadet.Assessments do end end - def get_submission(assessment_id, %CourseRegistration{id: cr_id}) - when is_ecto_id(assessment_id) do + defp find_team(assessment_id, cr_id) + when is_ecto_id(assessment_id) and is_ecto_id(cr_id) do + query = + from(t in Team, + where: t.assessment_id == ^assessment_id, + join: tm in assoc(t, :team_members), + where: tm.student_id == ^cr_id, + limit: 1 + ) + case Repo.one(query) do + nil -> {:error, :team_not_found} + team -> {:ok, team} + end + end + + def get_submission(assessment_id, %CourseRegistration{id: cr_id}) + when is_ecto_id(assessment_id) do query = from(t in Team, where: t.assessment_id == ^assessment_id, From 12512cb861a0247fbc970e7230222fa169220aa1 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 14 Dec 2023 14:58:58 +0800 Subject: [PATCH 051/128] Raise exception when mass team imports violates constraints --- lib/cadet/accounts/teams.ex | 75 ++++++++++++++----- test/cadet/accounts/team_test.exs | 118 ++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 17 deletions(-) create mode 100644 test/cadet/accounts/team_test.exs diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index 67cdcda50..848ef9ab0 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -29,24 +29,65 @@ defmodule Cadet.Accounts.Teams do assessment_id = attrs["assessment_id"] teams = attrs["student_ids"] - Enum.reduce_while(teams, {:ok, nil}, fn team_attrs, {:ok, _} -> - student_ids = Enum.map(team_attrs, &Map.get(&1, "userId")) - if student_already_in_team?(-1, student_ids, assessment_id) do - {:halt, {:error, {:conflict, "One or more students already in a team for this assessment!"}}} - else - {:ok, team} = %Team{} - |> Team.changeset(attrs) - |> Repo.insert() - team_id = team.id - Enum.each(team_attrs, fn student -> - student_id = Map.get(student, "userId") - attributes = %{student_id: student_id, team_id: team_id} - %TeamMember{} - |> cast(attributes, [:student_id, :team_id]) - |> Repo.insert() + cond do + !all_students_distinct(teams) -> + {:halt, {:error, {:conflict, "One or more students appears multiple times in a team!"}}} + + student_already_assigned(teams, assessment_id) -> + {:halt, {:error, {:conflict, "One or more students already in a team for this assessment!"}}} + + true -> + Enum.reduce_while(attrs["student_ids"], {:ok, nil}, fn team_attrs, {:ok, _} -> + student_ids = Enum.map(team_attrs, &Map.get(&1, "userId")) + IO.inspect(student_ids) + {:ok, team} = %Team{} + |> Team.changeset(attrs) + |> Repo.insert() + team_id = team.id + Enum.each(team_attrs, fn student -> + student_id = Map.get(student, "userId") + attributes = %{student_id: student_id, team_id: team_id} + %TeamMember{} + |> cast(attributes, [:student_id, :team_id]) + |> Repo.insert() + end) + {:cont, {:ok, team}} end) - {:cont, {:ok, team}} - end + end + end + + @doc """ + Validates whether there are student(s) who are already assigned to another group. + + ## Parameters + + * `team_attrs` - A list of all the teams and their members. + * `assessment_id` - Id of the target assessment. + + ## Returns + + Returns `true` on success; otherwise, `false`. + + """ + defp student_already_assigned(team_attrs, assessment_id) do + Enum.all?(team_attrs, fn team -> + ids = Enum.map(team, &Map.get(&1, "userId")) + + unique_ids_count = ids |> Enum.uniq() |> Enum.count() + all_ids_distinct = unique_ids_count == Enum.count(ids) + + student_already_in_team?(-1, ids, assessment_id) + end) + end + + defp all_students_distinct(team_attrs) do + Enum.all?(team_attrs, fn team -> + ids = Enum.map(team, &Map.get(&1, "userId")) + + unique_ids_count = ids |> Enum.uniq() |> Enum.count() + all_ids_distinct = unique_ids_count == Enum.count(ids) + + all_ids_distinct end) end diff --git a/test/cadet/accounts/team_test.exs b/test/cadet/accounts/team_test.exs new file mode 100644 index 000000000..d3489bd1c --- /dev/null +++ b/test/cadet/accounts/team_test.exs @@ -0,0 +1,118 @@ +defmodule Cadet.Accounts.TeamTest do + use Cadet.DataCase + alias Cadet.Accounts.{Teams, Team, TeamMember, User, CourseRegistration, CourseRegistrations} + alias Cadet.Assessments.{Assessment} + alias Cadet.Repo + + setup do + user1 = insert(:user, %{name: "user 1"}) + user2 = insert(:user, %{name: "user 2"}) + user3 = insert(:user, %{name: "user 3"}) + + assessment1 = insert(:assessment, %{title: "A1", max_team_size: 3}) + course1 = insert(:course, %{course_short_name: "course 1"}) + + {:ok, %{ + user1: user1, + user2: user2, + user3: user3, + course1: course1, + assessment1: assessment1 + }} + end + + test "creating a new team with valid attributes", %{user1: user1, user2: user2, assessment1: assessment1, course1: course1} do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + attrs = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id}], + ] + } + + assert {:ok, team} = Teams.create_team(attrs) + + # Check the number of team members + team_members = TeamMember + |> where([tm], tm.team_id == ^team.id) # Filtering by team ID + |> Repo.all() + + assert length(team_members) == 2 + + end + + test "creating a new team with duplicate students in the list", %{user1: user1, assessment1: assessment1, course1: course1} do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + attrs = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id},%{"userId" => course_reg1.id}], + ] + } + + result = Teams.create_team(attrs) + assert result == {:halt, {:error, {:conflict, "One or more students appears multiple times in a team!"}}} + end + + test "creating a team with students already in another team for the same assessment", %{user1: user1, user2: user2, user3: user3, assessment1: assessment1, course1: course1} do + + {:ok, course_reg1} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user1.id, course_id: course1.id, role: :student}) + {:ok, course_reg2} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user2.id, course_id: course1.id, role: :student}) + {:ok, course_reg3} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user3.id, course_id: course1.id, role: :student}) + + attrs_valid = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id}], + ] + } + + assert {:ok, team} = Teams.create_team(attrs_valid) + + attrs_invalid = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id},%{"userId" => course_reg3.id}], + ] + } + + result = Teams.create_team(attrs_invalid) + assert result == {:halt, {:error, {:conflict, "One or more students already in a team for this assessment!"}}} + + end + +# test "updating an existing team with valid attributes" do +# # Test case for updating an existing team +# # Set up the initial conditions +# # ... + +# # Perform the assertions +# # ... +# end + +# test "deleting a team and associated submissions and answers" do +# # Test case for deleting a team along with its associated submissions and answers +# # Set up the initial conditions +# # ... + +# # Perform the assertions +# # ... +# end +end \ No newline at end of file From 75d25c689a7ccc7d02a7c533d3ca7b51b5ae5699 Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Thu, 14 Dec 2023 16:37:15 +0800 Subject: [PATCH 052/128] remove test file --- test/cadet/accounts/team_test.exs | 118 ------------------------------ 1 file changed, 118 deletions(-) delete mode 100644 test/cadet/accounts/team_test.exs diff --git a/test/cadet/accounts/team_test.exs b/test/cadet/accounts/team_test.exs deleted file mode 100644 index d3489bd1c..000000000 --- a/test/cadet/accounts/team_test.exs +++ /dev/null @@ -1,118 +0,0 @@ -defmodule Cadet.Accounts.TeamTest do - use Cadet.DataCase - alias Cadet.Accounts.{Teams, Team, TeamMember, User, CourseRegistration, CourseRegistrations} - alias Cadet.Assessments.{Assessment} - alias Cadet.Repo - - setup do - user1 = insert(:user, %{name: "user 1"}) - user2 = insert(:user, %{name: "user 2"}) - user3 = insert(:user, %{name: "user 3"}) - - assessment1 = insert(:assessment, %{title: "A1", max_team_size: 3}) - course1 = insert(:course, %{course_short_name: "course 1"}) - - {:ok, %{ - user1: user1, - user2: user2, - user3: user3, - course1: course1, - assessment1: assessment1 - }} - end - - test "creating a new team with valid attributes", %{user1: user1, user2: user2, assessment1: assessment1, course1: course1} do - {:ok, course_reg1} = - CourseRegistrations.insert_or_update_course_registration(%{ - user_id: user1.id, - course_id: course1.id, - role: :student - }) - {:ok, course_reg2} = - CourseRegistrations.insert_or_update_course_registration(%{ - user_id: user2.id, - course_id: course1.id, - role: :student - }) - attrs = %{ - "assessment_id" => assessment1.id, - "student_ids" => [ - [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id}], - ] - } - - assert {:ok, team} = Teams.create_team(attrs) - - # Check the number of team members - team_members = TeamMember - |> where([tm], tm.team_id == ^team.id) # Filtering by team ID - |> Repo.all() - - assert length(team_members) == 2 - - end - - test "creating a new team with duplicate students in the list", %{user1: user1, assessment1: assessment1, course1: course1} do - {:ok, course_reg1} = - CourseRegistrations.insert_or_update_course_registration(%{ - user_id: user1.id, - course_id: course1.id, - role: :student - }) - - attrs = %{ - "assessment_id" => assessment1.id, - "student_ids" => [ - [%{"userId" => course_reg1.id},%{"userId" => course_reg1.id}], - ] - } - - result = Teams.create_team(attrs) - assert result == {:halt, {:error, {:conflict, "One or more students appears multiple times in a team!"}}} - end - - test "creating a team with students already in another team for the same assessment", %{user1: user1, user2: user2, user3: user3, assessment1: assessment1, course1: course1} do - - {:ok, course_reg1} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user1.id, course_id: course1.id, role: :student}) - {:ok, course_reg2} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user2.id, course_id: course1.id, role: :student}) - {:ok, course_reg3} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user3.id, course_id: course1.id, role: :student}) - - attrs_valid = %{ - "assessment_id" => assessment1.id, - "student_ids" => [ - [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id}], - ] - } - - assert {:ok, team} = Teams.create_team(attrs_valid) - - attrs_invalid = %{ - "assessment_id" => assessment1.id, - "student_ids" => [ - [%{"userId" => course_reg1.id},%{"userId" => course_reg3.id}], - ] - } - - result = Teams.create_team(attrs_invalid) - assert result == {:halt, {:error, {:conflict, "One or more students already in a team for this assessment!"}}} - - end - -# test "updating an existing team with valid attributes" do -# # Test case for updating an existing team -# # Set up the initial conditions -# # ... - -# # Perform the assertions -# # ... -# end - -# test "deleting a team and associated submissions and answers" do -# # Test case for deleting a team along with its associated submissions and answers -# # Set up the initial conditions -# # ... - -# # Perform the assertions -# # ... -# end -end \ No newline at end of file From 16fd4ff41f0fe02226e289f3ac20013a187004dc Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Fri, 15 Dec 2023 10:21:31 +0800 Subject: [PATCH 053/128] Fix bug of importing duplicate students through excel --- lib/cadet/accounts/teams.ex | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index 848ef9ab0..8f865f934 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -31,10 +31,10 @@ defmodule Cadet.Accounts.Teams do cond do !all_students_distinct(teams) -> - {:halt, {:error, {:conflict, "One or more students appears multiple times in a team!"}}} + {:error, {:conflict, "One or more students appears multiple times in a team!"}} student_already_assigned(teams, assessment_id) -> - {:halt, {:error, {:conflict, "One or more students already in a team for this assessment!"}}} + {:error, {:conflict, "One or more students already in a team for this assessment!"}} true -> Enum.reduce_while(attrs["student_ids"], {:ok, nil}, fn team_attrs, {:ok, _} -> @@ -81,14 +81,15 @@ defmodule Cadet.Accounts.Teams do end defp all_students_distinct(team_attrs) do - Enum.all?(team_attrs, fn team -> - ids = Enum.map(team, &Map.get(&1, "userId")) - - unique_ids_count = ids |> Enum.uniq() |> Enum.count() - all_ids_distinct = unique_ids_count == Enum.count(ids) + all_ids = team_attrs + |> Enum.flat_map(fn team -> + Enum.map(team, fn row -> Map.get(row, "userId") end) + end) + + all_ids_count = all_ids |> Enum.uniq() |> Enum.count() + all_ids_distinct = all_ids_count == Enum.count(all_ids) - all_ids_distinct - end) + all_ids_distinct end @doc """ From 1e66007b0beb858037ac38ec7543fe7d8edb35d1 Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Fri, 15 Dec 2023 10:27:15 +0800 Subject: [PATCH 054/128] Refactor default max team size to 1 --- lib/cadet/assessments/assessment.ex | 2 +- .../admin_controllers/admin_grading_controller_test.exs | 4 ++-- test/factories/assessments/assessment_factory.ex | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/cadet/assessments/assessment.ex b/lib/cadet/assessments/assessment.ex index 85f064355..81f34973b 100644 --- a/lib/cadet/assessments/assessment.ex +++ b/lib/cadet/assessments/assessment.ex @@ -32,7 +32,7 @@ defmodule Cadet.Assessments.Assessment do field(:story, :string) field(:reading, :string) field(:password, :string, default: nil) - field(:max_team_size, :integer, default: 0) + field(:max_team_size, :integer, default: 1) belongs_to(:config, AssessmentConfig) belongs_to(:course, Course) diff --git a/test/cadet_web/admin_controllers/admin_grading_controller_test.exs b/test/cadet_web/admin_controllers/admin_grading_controller_test.exs index b35ba1639..2543e945d 100644 --- a/test/cadet_web/admin_controllers/admin_grading_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_grading_controller_test.exs @@ -1265,9 +1265,9 @@ defmodule CadetWeb.AdminGradingControllerTest do course: course, config: assessment_config, is_published: true, - max_team_size: 0 + max_team_size: 1 }) - +S questions = for index <- 0..2 do # insert with display order in reverse diff --git a/test/factories/assessments/assessment_factory.ex b/test/factories/assessments/assessment_factory.ex index d51dc637c..e85267418 100644 --- a/test/factories/assessments/assessment_factory.ex +++ b/test/factories/assessments/assessment_factory.ex @@ -39,7 +39,7 @@ defmodule Cadet.Assessments.AssessmentFactory do open_at: Timex.now(), close_at: Timex.shift(Timex.now(), days: Enum.random(1..30)), is_published: false, - max_team_size: 0 + max_team_size: 1 } end end From c7c3572005b21ad4c88a4867fa0e57d125b4e80a Mon Sep 17 00:00:00 2001 From: root Date: Fri, 15 Dec 2023 16:13:59 +0800 Subject: [PATCH 055/128] Check size of the team before insertion --- lib/cadet/accounts/teams.ex | 434 ++++++++++++++++++------------------ 1 file changed, 223 insertions(+), 211 deletions(-) diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index 8f865f934..113b616eb 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -1,211 +1,223 @@ -defmodule Cadet.Accounts.Teams do - @moduledoc """ - This module provides functions to manage teams in the Cadet system. - """ - - use Cadet, [:context, :display] - use Ecto.Schema - - import Ecto.Changeset - import Ecto.Query - - alias Cadet.Repo - alias Cadet.Accounts.{Team, TeamMember, CourseRegistration} - alias Cadet.Assessments.{Answer, Assessment, Submission} - - @doc """ - Creates a new team and assigns an assessment and team members to it. - - ## Parameters - - * `attrs` - A map containing the attributes for assessment id and creating the team and its members. - - ## Returns - - Returns a tuple `{:ok, team}` on success; otherwise, an error tuple. - - """ - def create_team(attrs) do - assessment_id = attrs["assessment_id"] - teams = attrs["student_ids"] - - cond do - !all_students_distinct(teams) -> - {:error, {:conflict, "One or more students appears multiple times in a team!"}} - - student_already_assigned(teams, assessment_id) -> - {:error, {:conflict, "One or more students already in a team for this assessment!"}} - - true -> - Enum.reduce_while(attrs["student_ids"], {:ok, nil}, fn team_attrs, {:ok, _} -> - student_ids = Enum.map(team_attrs, &Map.get(&1, "userId")) - IO.inspect(student_ids) - {:ok, team} = %Team{} - |> Team.changeset(attrs) - |> Repo.insert() - team_id = team.id - Enum.each(team_attrs, fn student -> - student_id = Map.get(student, "userId") - attributes = %{student_id: student_id, team_id: team_id} - %TeamMember{} - |> cast(attributes, [:student_id, :team_id]) - |> Repo.insert() - end) - {:cont, {:ok, team}} - end) - end - end - - @doc """ - Validates whether there are student(s) who are already assigned to another group. - - ## Parameters - - * `team_attrs` - A list of all the teams and their members. - * `assessment_id` - Id of the target assessment. - - ## Returns - - Returns `true` on success; otherwise, `false`. - - """ - defp student_already_assigned(team_attrs, assessment_id) do - Enum.all?(team_attrs, fn team -> - ids = Enum.map(team, &Map.get(&1, "userId")) - - unique_ids_count = ids |> Enum.uniq() |> Enum.count() - all_ids_distinct = unique_ids_count == Enum.count(ids) - - student_already_in_team?(-1, ids, assessment_id) - end) - end - - defp all_students_distinct(team_attrs) do - all_ids = team_attrs - |> Enum.flat_map(fn team -> - Enum.map(team, fn row -> Map.get(row, "userId") end) - end) - - all_ids_count = all_ids |> Enum.uniq() |> Enum.count() - all_ids_distinct = all_ids_count == Enum.count(all_ids) - - all_ids_distinct - end - - @doc """ - Checks if one or more students are already in another team for the same assessment. - - ## Parameters - - * `team_id` - ID of the team being updated (use -1 for team creation) - * `student_ids` - List of student IDs - * `assessment_id` - ID of the assessment - - ## Returns - - Returns `true` if any student in the list is already a member of another team for the same assessment; otherwise, returns `false`. - - """ - defp student_already_in_team?(team_id, student_ids, assessment_id) do - query = - from tm in TeamMember, - join: t in assoc(tm, :team), - where: tm.student_id in ^student_ids and t.assessment_id == ^assessment_id and t.id != ^team_id, - select: tm.student_id - - existing_student_ids = Repo.all(query) - - Enum.any?(student_ids, fn student_id -> Enum.member?(existing_student_ids, student_id) end) - end - - @doc """ - Updates an existing team, the corresponding assessment, and its members. - - ## Parameters - - * `team` - The existing team to be updated - * `new_assessment_id` - The ID of the updated assessment - * `student_ids` - List of student ids for team members - - ## Returns - - Returns a tuple `{:ok, updated_team}` on success, containing the updated team details; otherwise, an error tuple. - - """ - def update_team(%Team{} = team, new_assessment_id, student_ids) do - old_assessment_id = team.assessment_id - team_id = team.id - new_student_ids = Enum.map(hd(student_ids), fn student -> Map.get(student, "userId") end) - if student_already_in_team?(team_id, new_student_ids, new_assessment_id) do - {:error, {:conflict, "One or more students are already in another team for the same assessment!"}} - else - attrs = %{assessment_id: new_assessment_id} - - team - |> cast(attrs, [:assessment_id]) - |> validate_required([:assessment_id]) - |> foreign_key_constraint(:assessment_id) - |> Ecto.Changeset.change() - |> Repo.update() - |> case do - {:ok, updated_team} -> - - update_team_members(updated_team, student_ids, team_id) - {:ok, updated_team} - - error -> - error - end - end - end - - @doc """ - Updates team members based on the new list of student IDs. - - ## Parameters - - * `team` - The team being updated - * `student_ids` - List of student ids for team members - * `team_id` - ID of the team - - """ - defp update_team_members(team, student_ids, team_id) do - current_student_ids = team.team_members |> Enum.map(&(&1.student_id)) - new_student_ids = Enum.map(hd(student_ids), fn student -> Map.get(student, "userId") end) - - student_ids_to_add = Enum.filter(new_student_ids, fn elem -> not Enum.member?(current_student_ids, elem) end) - student_ids_to_remove = Enum.filter(current_student_ids, fn elem -> not Enum.member?(new_student_ids, elem) end) - - Enum.each(student_ids_to_add, fn student_id -> - %TeamMember{} - |> Ecto.Changeset.change(%{team_id: team_id, student_id: student_id}) # Change here - |> Repo.insert() - end) - - Enum.each(student_ids_to_remove, fn student_id -> - from(tm in TeamMember, where: tm.team_id == ^team_id and tm.student_id == ^student_id) - |> Repo.delete_all() - end) - end - - @doc """ - Deletes a team along with its associated submissions and answers. - - ## Parameters - - * `team` - The team to be deleted - - """ - def delete_team(%Team{} = team) do - Submission - |> where(team_id: ^team.id) - |> Repo.all() - |> Enum.each(fn x -> - Answer - |> where(submission_id: ^x.id) - |> Repo.delete_all() - end) - team - |> Repo.delete() - end -end +defmodule Cadet.Accounts.Teams do + @moduledoc """ + This module provides functions to manage teams in the Cadet system. + """ + + use Cadet, [:context, :display] + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + + alias Cadet.Repo + alias Cadet.Accounts.{Team, TeamMember, CourseRegistration} + alias Cadet.Assessments.{Answer, Assessment, Submission} + + @doc """ + Creates a new team and assigns an assessment and team members to it. + + ## Parameters + + * `attrs` - A map containing the attributes for assessment id and creating the team and its members. + + ## Returns + + Returns a tuple `{:ok, team}` on success; otherwise, an error tuple. + + """ + def create_team(attrs) do + assessment_id = attrs["assessment_id"] + teams = attrs["student_ids"] + assessment = Cadet.Repo.get(Cadet.Assessments.Assessment, assessment_id) + IO.inspect(assessment.max_team_size) + + cond do + !all_team_within_max_size(teams, assessment.max_team_size) -> + {:error, {:conflict, "One or more teams exceed the maximum team size!"}} + + !all_students_distinct(teams) -> + {:error, {:conflict, "One or more students appears multiple times in a team!"}} + + student_already_assigned(teams, assessment_id) -> + {:error, {:conflict, "One or more students already in a team for this assessment!"}} + + true -> + Enum.reduce_while(attrs["student_ids"], {:ok, nil}, fn team_attrs, {:ok, _} -> + student_ids = Enum.map(team_attrs, &Map.get(&1, "userId")) + IO.inspect(student_ids) + {:ok, team} = %Team{} + |> Team.changeset(attrs) + |> Repo.insert() + team_id = team.id + Enum.each(team_attrs, fn student -> + student_id = Map.get(student, "userId") + attributes = %{student_id: student_id, team_id: team_id} + %TeamMember{} + |> cast(attributes, [:student_id, :team_id]) + |> Repo.insert() + end) + {:cont, {:ok, team}} + end) + end + end + + @doc """ + Validates whether there are student(s) who are already assigned to another group. + + ## Parameters + + * `team_attrs` - A list of all the teams and their members. + * `assessment_id` - Id of the target assessment. + + ## Returns + + Returns `true` on success; otherwise, `false`. + + """ + defp student_already_assigned(team_attrs, assessment_id) do + Enum.all?(team_attrs, fn team -> + ids = Enum.map(team, &Map.get(&1, "userId")) + + unique_ids_count = ids |> Enum.uniq() |> Enum.count() + all_ids_distinct = unique_ids_count == Enum.count(ids) + + student_already_in_team?(-1, ids, assessment_id) + end) + end + + defp all_students_distinct(team_attrs) do + all_ids = team_attrs + |> Enum.flat_map(fn team -> + Enum.map(team, fn row -> Map.get(row, "userId") end) + end) + + all_ids_count = all_ids |> Enum.uniq() |> Enum.count() + all_ids_distinct = all_ids_count == Enum.count(all_ids) + + all_ids_distinct + end + + defp all_team_within_max_size(teams, max_team_size) do + Enum.all?(teams, fn team -> + ids = Enum.map(team, &Map.get(&1, "userId")) + length(ids) <= max_team_size + end) + end + + @doc """ + Checks if one or more students are already in another team for the same assessment. + + ## Parameters + + * `team_id` - ID of the team being updated (use -1 for team creation) + * `student_ids` - List of student IDs + * `assessment_id` - ID of the assessment + + ## Returns + + Returns `true` if any student in the list is already a member of another team for the same assessment; otherwise, returns `false`. + + """ + defp student_already_in_team?(team_id, student_ids, assessment_id) do + query = + from tm in TeamMember, + join: t in assoc(tm, :team), + where: tm.student_id in ^student_ids and t.assessment_id == ^assessment_id and t.id != ^team_id, + select: tm.student_id + + existing_student_ids = Repo.all(query) + + Enum.any?(student_ids, fn student_id -> Enum.member?(existing_student_ids, student_id) end) + end + + @doc """ + Updates an existing team, the corresponding assessment, and its members. + + ## Parameters + + * `team` - The existing team to be updated + * `new_assessment_id` - The ID of the updated assessment + * `student_ids` - List of student ids for team members + + ## Returns + + Returns a tuple `{:ok, updated_team}` on success, containing the updated team details; otherwise, an error tuple. + + """ + def update_team(%Team{} = team, new_assessment_id, student_ids) do + old_assessment_id = team.assessment_id + team_id = team.id + new_student_ids = Enum.map(hd(student_ids), fn student -> Map.get(student, "userId") end) + if student_already_in_team?(team_id, new_student_ids, new_assessment_id) do + {:error, {:conflict, "One or more students are already in another team for the same assessment!"}} + else + attrs = %{assessment_id: new_assessment_id} + + team + |> cast(attrs, [:assessment_id]) + |> validate_required([:assessment_id]) + |> foreign_key_constraint(:assessment_id) + |> Ecto.Changeset.change() + |> Repo.update() + |> case do + {:ok, updated_team} -> + + update_team_members(updated_team, student_ids, team_id) + {:ok, updated_team} + + error -> + error + end + end + end + + @doc """ + Updates team members based on the new list of student IDs. + + ## Parameters + + * `team` - The team being updated + * `student_ids` - List of student ids for team members + * `team_id` - ID of the team + + """ + defp update_team_members(team, student_ids, team_id) do + current_student_ids = team.team_members |> Enum.map(&(&1.student_id)) + new_student_ids = Enum.map(hd(student_ids), fn student -> Map.get(student, "userId") end) + + student_ids_to_add = Enum.filter(new_student_ids, fn elem -> not Enum.member?(current_student_ids, elem) end) + student_ids_to_remove = Enum.filter(current_student_ids, fn elem -> not Enum.member?(new_student_ids, elem) end) + + Enum.each(student_ids_to_add, fn student_id -> + %TeamMember{} + |> Ecto.Changeset.change(%{team_id: team_id, student_id: student_id}) # Change here + |> Repo.insert() + end) + + Enum.each(student_ids_to_remove, fn student_id -> + from(tm in TeamMember, where: tm.team_id == ^team_id and tm.student_id == ^student_id) + |> Repo.delete_all() + end) + end + + @doc """ + Deletes a team along with its associated submissions and answers. + + ## Parameters + + * `team` - The team to be deleted + + """ + def delete_team(%Team{} = team) do + Submission + |> where(team_id: ^team.id) + |> Repo.all() + |> Enum.each(fn x -> + Answer + |> where(submission_id: ^x.id) + |> Repo.delete_all() + end) + team + |> Repo.delete() + end +end From 81c608ba64c90a6084bc1af5f4b030c8d05e93b6 Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Fri, 15 Dec 2023 16:17:33 +0800 Subject: [PATCH 056/128] Fix format --- lib/cadet/accounts/teams.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index 113b616eb..b47f253fe 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -29,7 +29,6 @@ defmodule Cadet.Accounts.Teams do assessment_id = attrs["assessment_id"] teams = attrs["student_ids"] assessment = Cadet.Repo.get(Cadet.Assessments.Assessment, assessment_id) - IO.inspect(assessment.max_team_size) cond do !all_team_within_max_size(teams, assessment.max_team_size) -> From 9b01cf73dc99e884e4f0fe9bc456e95c79ec03c1 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Mon, 18 Dec 2023 17:55:41 +0800 Subject: [PATCH 057/128] Fix unsubmit handle notifications bug --- lib/cadet/assessments/assessments.ex | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index eb8885f6a..96b091808 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -898,15 +898,20 @@ defmodule Cadet.Assessments do case submission.student_id do nil -> # Team submission, handle notifications for team members team = Repo.get(Team, submission.team_id) - team_members = - from t in Team, + query = + from(t in Team, join: tm in TeamMember, on: t.id == tm.team_id, - join: cr in CourseRegistration, on: tm.student_id == cr.student_id, + join: cr in CourseRegistration, on: tm.student_id == cr.id, where: t.id == ^team.id, select: cr.id + ) + team_members = Repo.all(query) Enum.each(team_members, fn tm_id -> - Cadet.Accounts.Notifications.handle_unsubmit_notifications(submission.assessment.id, tm_id) + Cadet.Accounts.Notifications.handle_unsubmit_notifications( + submission.assessment.id, + Repo.get(CourseRegistration, tm_id) + ) end) student_id -> From 7953f62f159c3988ce73f6a10f1e05887a31bf40 Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Tue, 19 Dec 2023 08:11:45 +0800 Subject: [PATCH 058/128] Fix end of line issue --- lib/cadet/accounts/teams.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index b47f253fe..1d9b3b635 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -35,7 +35,7 @@ defmodule Cadet.Accounts.Teams do {:error, {:conflict, "One or more teams exceed the maximum team size!"}} !all_students_distinct(teams) -> - {:error, {:conflict, "One or more students appears multiple times in a team!"}} + {:error, {:conflict, "One or more students appear multiple times in a team!"}} student_already_assigned(teams, assessment_id) -> {:error, {:conflict, "One or more students already in a team for this assessment!"}} From ac1ca18d5d966f2661726d90d2e81570557f0370 Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Tue, 19 Dec 2023 08:16:05 +0800 Subject: [PATCH 059/128] Fix end of line issue --- lib/cadet/accounts/teams.ex | 36 +++++------------------------------- 1 file changed, 5 insertions(+), 31 deletions(-) diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index 1d9b3b635..16c104d0d 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -5,7 +5,7 @@ defmodule Cadet.Accounts.Teams do use Cadet, [:context, :display] use Ecto.Schema - + import Ecto.Changeset import Ecto.Query @@ -15,15 +15,10 @@ defmodule Cadet.Accounts.Teams do @doc """ Creates a new team and assigns an assessment and team members to it. - ## Parameters - * `attrs` - A map containing the attributes for assessment id and creating the team and its members. - ## Returns - Returns a tuple `{:ok, team}` on success; otherwise, an error tuple. - """ def create_team(attrs) do assessment_id = attrs["assessment_id"] @@ -33,10 +28,10 @@ defmodule Cadet.Accounts.Teams do cond do !all_team_within_max_size(teams, assessment.max_team_size) -> {:error, {:conflict, "One or more teams exceed the maximum team size!"}} - + !all_students_distinct(teams) -> {:error, {:conflict, "One or more students appear multiple times in a team!"}} - + student_already_assigned(teams, assessment_id) -> {:error, {:conflict, "One or more students already in a team for this assessment!"}} @@ -62,16 +57,11 @@ defmodule Cadet.Accounts.Teams do @doc """ Validates whether there are student(s) who are already assigned to another group. - ## Parameters - * `team_attrs` - A list of all the teams and their members. * `assessment_id` - Id of the target assessment. - ## Returns - Returns `true` on success; otherwise, `false`. - """ defp student_already_assigned(team_attrs, assessment_id) do Enum.all?(team_attrs, fn team -> @@ -89,7 +79,7 @@ defmodule Cadet.Accounts.Teams do |> Enum.flat_map(fn team -> Enum.map(team, fn row -> Map.get(row, "userId") end) end) - + all_ids_count = all_ids |> Enum.uniq() |> Enum.count() all_ids_distinct = all_ids_count == Enum.count(all_ids) @@ -105,17 +95,12 @@ defmodule Cadet.Accounts.Teams do @doc """ Checks if one or more students are already in another team for the same assessment. - ## Parameters - * `team_id` - ID of the team being updated (use -1 for team creation) * `student_ids` - List of student IDs * `assessment_id` - ID of the assessment - ## Returns - Returns `true` if any student in the list is already a member of another team for the same assessment; otherwise, returns `false`. - """ defp student_already_in_team?(team_id, student_ids, assessment_id) do query = @@ -131,17 +116,12 @@ defmodule Cadet.Accounts.Teams do @doc """ Updates an existing team, the corresponding assessment, and its members. - ## Parameters - * `team` - The existing team to be updated * `new_assessment_id` - The ID of the updated assessment * `student_ids` - List of student ids for team members - ## Returns - Returns a tuple `{:ok, updated_team}` on success, containing the updated team details; otherwise, an error tuple. - """ def update_team(%Team{} = team, new_assessment_id, student_ids) do old_assessment_id = team.assessment_id @@ -172,13 +152,10 @@ defmodule Cadet.Accounts.Teams do @doc """ Updates team members based on the new list of student IDs. - ## Parameters - * `team` - The team being updated * `student_ids` - List of student ids for team members * `team_id` - ID of the team - """ defp update_team_members(team, student_ids, team_id) do current_student_ids = team.team_members |> Enum.map(&(&1.student_id)) @@ -201,11 +178,8 @@ defmodule Cadet.Accounts.Teams do @doc """ Deletes a team along with its associated submissions and answers. - ## Parameters - * `team` - The team to be deleted - """ def delete_team(%Team{} = team) do Submission @@ -219,4 +193,4 @@ defmodule Cadet.Accounts.Teams do team |> Repo.delete() end -end +end \ No newline at end of file From 3444acadecaaf4ab962d4d30d803e273b5338f6a Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Tue, 19 Dec 2023 08:28:20 +0800 Subject: [PATCH 060/128] Fix end of line issue --- lib/cadet/accounts/teams.ex | 390 ++++++++++++++++++------------------ 1 file changed, 195 insertions(+), 195 deletions(-) diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index 16c104d0d..460817e3a 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -1,196 +1,196 @@ -defmodule Cadet.Accounts.Teams do - @moduledoc """ - This module provides functions to manage teams in the Cadet system. - """ - - use Cadet, [:context, :display] - use Ecto.Schema - - import Ecto.Changeset - import Ecto.Query - - alias Cadet.Repo - alias Cadet.Accounts.{Team, TeamMember, CourseRegistration} - alias Cadet.Assessments.{Answer, Assessment, Submission} - - @doc """ - Creates a new team and assigns an assessment and team members to it. - ## Parameters - * `attrs` - A map containing the attributes for assessment id and creating the team and its members. - ## Returns - Returns a tuple `{:ok, team}` on success; otherwise, an error tuple. - """ - def create_team(attrs) do - assessment_id = attrs["assessment_id"] - teams = attrs["student_ids"] - assessment = Cadet.Repo.get(Cadet.Assessments.Assessment, assessment_id) - - cond do - !all_team_within_max_size(teams, assessment.max_team_size) -> - {:error, {:conflict, "One or more teams exceed the maximum team size!"}} - - !all_students_distinct(teams) -> - {:error, {:conflict, "One or more students appear multiple times in a team!"}} - - student_already_assigned(teams, assessment_id) -> - {:error, {:conflict, "One or more students already in a team for this assessment!"}} - - true -> - Enum.reduce_while(attrs["student_ids"], {:ok, nil}, fn team_attrs, {:ok, _} -> - student_ids = Enum.map(team_attrs, &Map.get(&1, "userId")) - IO.inspect(student_ids) - {:ok, team} = %Team{} - |> Team.changeset(attrs) - |> Repo.insert() - team_id = team.id - Enum.each(team_attrs, fn student -> - student_id = Map.get(student, "userId") - attributes = %{student_id: student_id, team_id: team_id} - %TeamMember{} - |> cast(attributes, [:student_id, :team_id]) - |> Repo.insert() - end) - {:cont, {:ok, team}} - end) - end - end - - @doc """ - Validates whether there are student(s) who are already assigned to another group. - ## Parameters - * `team_attrs` - A list of all the teams and their members. - * `assessment_id` - Id of the target assessment. - ## Returns - Returns `true` on success; otherwise, `false`. - """ - defp student_already_assigned(team_attrs, assessment_id) do - Enum.all?(team_attrs, fn team -> - ids = Enum.map(team, &Map.get(&1, "userId")) - - unique_ids_count = ids |> Enum.uniq() |> Enum.count() - all_ids_distinct = unique_ids_count == Enum.count(ids) - - student_already_in_team?(-1, ids, assessment_id) - end) - end - - defp all_students_distinct(team_attrs) do - all_ids = team_attrs - |> Enum.flat_map(fn team -> - Enum.map(team, fn row -> Map.get(row, "userId") end) - end) - - all_ids_count = all_ids |> Enum.uniq() |> Enum.count() - all_ids_distinct = all_ids_count == Enum.count(all_ids) - - all_ids_distinct - end - - defp all_team_within_max_size(teams, max_team_size) do - Enum.all?(teams, fn team -> - ids = Enum.map(team, &Map.get(&1, "userId")) - length(ids) <= max_team_size - end) - end - - @doc """ - Checks if one or more students are already in another team for the same assessment. - ## Parameters - * `team_id` - ID of the team being updated (use -1 for team creation) - * `student_ids` - List of student IDs - * `assessment_id` - ID of the assessment - ## Returns - Returns `true` if any student in the list is already a member of another team for the same assessment; otherwise, returns `false`. - """ - defp student_already_in_team?(team_id, student_ids, assessment_id) do - query = - from tm in TeamMember, - join: t in assoc(tm, :team), - where: tm.student_id in ^student_ids and t.assessment_id == ^assessment_id and t.id != ^team_id, - select: tm.student_id - - existing_student_ids = Repo.all(query) - - Enum.any?(student_ids, fn student_id -> Enum.member?(existing_student_ids, student_id) end) - end - - @doc """ - Updates an existing team, the corresponding assessment, and its members. - ## Parameters - * `team` - The existing team to be updated - * `new_assessment_id` - The ID of the updated assessment - * `student_ids` - List of student ids for team members - ## Returns - Returns a tuple `{:ok, updated_team}` on success, containing the updated team details; otherwise, an error tuple. - """ - def update_team(%Team{} = team, new_assessment_id, student_ids) do - old_assessment_id = team.assessment_id - team_id = team.id - new_student_ids = Enum.map(hd(student_ids), fn student -> Map.get(student, "userId") end) - if student_already_in_team?(team_id, new_student_ids, new_assessment_id) do - {:error, {:conflict, "One or more students are already in another team for the same assessment!"}} - else - attrs = %{assessment_id: new_assessment_id} - - team - |> cast(attrs, [:assessment_id]) - |> validate_required([:assessment_id]) - |> foreign_key_constraint(:assessment_id) - |> Ecto.Changeset.change() - |> Repo.update() - |> case do - {:ok, updated_team} -> - - update_team_members(updated_team, student_ids, team_id) - {:ok, updated_team} - - error -> - error - end - end - end - - @doc """ - Updates team members based on the new list of student IDs. - ## Parameters - * `team` - The team being updated - * `student_ids` - List of student ids for team members - * `team_id` - ID of the team - """ - defp update_team_members(team, student_ids, team_id) do - current_student_ids = team.team_members |> Enum.map(&(&1.student_id)) - new_student_ids = Enum.map(hd(student_ids), fn student -> Map.get(student, "userId") end) - - student_ids_to_add = Enum.filter(new_student_ids, fn elem -> not Enum.member?(current_student_ids, elem) end) - student_ids_to_remove = Enum.filter(current_student_ids, fn elem -> not Enum.member?(new_student_ids, elem) end) - - Enum.each(student_ids_to_add, fn student_id -> - %TeamMember{} - |> Ecto.Changeset.change(%{team_id: team_id, student_id: student_id}) # Change here - |> Repo.insert() - end) - - Enum.each(student_ids_to_remove, fn student_id -> - from(tm in TeamMember, where: tm.team_id == ^team_id and tm.student_id == ^student_id) - |> Repo.delete_all() - end) - end - - @doc """ - Deletes a team along with its associated submissions and answers. - ## Parameters - * `team` - The team to be deleted - """ - def delete_team(%Team{} = team) do - Submission - |> where(team_id: ^team.id) - |> Repo.all() - |> Enum.each(fn x -> - Answer - |> where(submission_id: ^x.id) - |> Repo.delete_all() - end) - team - |> Repo.delete() - end +defmodule Cadet.Accounts.Teams do + @moduledoc """ + This module provides functions to manage teams in the Cadet system. + """ + + use Cadet, [:context, :display] + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + + alias Cadet.Repo + alias Cadet.Accounts.{Team, TeamMember, CourseRegistration} + alias Cadet.Assessments.{Answer, Assessment, Submission} + + @doc """ + Creates a new team and assigns an assessment and team members to it. + ## Parameters + * `attrs` - A map containing the attributes for assessment id and creating the team and its members. + ## Returns + Returns a tuple `{:ok, team}` on success; otherwise, an error tuple. + """ + def create_team(attrs) do + assessment_id = attrs["assessment_id"] + teams = attrs["student_ids"] + assessment = Cadet.Repo.get(Cadet.Assessments.Assessment, assessment_id) + + cond do + !all_team_within_max_size(teams, assessment.max_team_size) -> + {:error, {:conflict, "One or more teams exceed the maximum team size!"}} + + !all_students_distinct(teams) -> + {:error, {:conflict, "One or more students appear multiple times in a team!"}} + + student_already_assigned(teams, assessment_id) -> + {:error, {:conflict, "One or more students already in a team for this assessment!"}} + + true -> + Enum.reduce_while(attrs["student_ids"], {:ok, nil}, fn team_attrs, {:ok, _} -> + student_ids = Enum.map(team_attrs, &Map.get(&1, "userId")) + IO.inspect(student_ids) + {:ok, team} = %Team{} + |> Team.changeset(attrs) + |> Repo.insert() + team_id = team.id + Enum.each(team_attrs, fn student -> + student_id = Map.get(student, "userId") + attributes = %{student_id: student_id, team_id: team_id} + %TeamMember{} + |> cast(attributes, [:student_id, :team_id]) + |> Repo.insert() + end) + {:cont, {:ok, team}} + end) + end + end + + @doc """ + Validates whether there are student(s) who are already assigned to another group. + ## Parameters + * `team_attrs` - A list of all the teams and their members. + * `assessment_id` - Id of the target assessment. + ## Returns + Returns `true` on success; otherwise, `false`. + """ + defp student_already_assigned(team_attrs, assessment_id) do + Enum.all?(team_attrs, fn team -> + ids = Enum.map(team, &Map.get(&1, "userId")) + + unique_ids_count = ids |> Enum.uniq() |> Enum.count() + all_ids_distinct = unique_ids_count == Enum.count(ids) + + student_already_in_team?(-1, ids, assessment_id) + end) + end + + defp all_students_distinct(team_attrs) do + all_ids = team_attrs + |> Enum.flat_map(fn team -> + Enum.map(team, fn row -> Map.get(row, "userId") end) + end) + + all_ids_count = all_ids |> Enum.uniq() |> Enum.count() + all_ids_distinct = all_ids_count == Enum.count(all_ids) + + all_ids_distinct + end + + defp all_team_within_max_size(teams, max_team_size) do + Enum.all?(teams, fn team -> + ids = Enum.map(team, &Map.get(&1, "userId")) + length(ids) <= max_team_size + end) + end + + @doc """ + Checks if one or more students are already in another team for the same assessment. + ## Parameters + * `team_id` - ID of the team being updated (use -1 for team creation) + * `student_ids` - List of student IDs + * `assessment_id` - ID of the assessment + ## Returns + Returns `true` if any student in the list is already a member of another team for the same assessment; otherwise, returns `false`. + """ + defp student_already_in_team?(team_id, student_ids, assessment_id) do + query = + from tm in TeamMember, + join: t in assoc(tm, :team), + where: tm.student_id in ^student_ids and t.assessment_id == ^assessment_id and t.id != ^team_id, + select: tm.student_id + + existing_student_ids = Repo.all(query) + + Enum.any?(student_ids, fn student_id -> Enum.member?(existing_student_ids, student_id) end) + end + + @doc """ + Updates an existing team, the corresponding assessment, and its members. + ## Parameters + * `team` - The existing team to be updated + * `new_assessment_id` - The ID of the updated assessment + * `student_ids` - List of student ids for team members + ## Returns + Returns a tuple `{:ok, updated_team}` on success, containing the updated team details; otherwise, an error tuple. + """ + def update_team(%Team{} = team, new_assessment_id, student_ids) do + old_assessment_id = team.assessment_id + team_id = team.id + new_student_ids = Enum.map(hd(student_ids), fn student -> Map.get(student, "userId") end) + if student_already_in_team?(team_id, new_student_ids, new_assessment_id) do + {:error, {:conflict, "One or more students are already in another team for the same assessment!"}} + else + attrs = %{assessment_id: new_assessment_id} + + team + |> cast(attrs, [:assessment_id]) + |> validate_required([:assessment_id]) + |> foreign_key_constraint(:assessment_id) + |> Ecto.Changeset.change() + |> Repo.update() + |> case do + {:ok, updated_team} -> + + update_team_members(updated_team, student_ids, team_id) + {:ok, updated_team} + + error -> + error + end + end + end + + @doc """ + Updates team members based on the new list of student IDs. + ## Parameters + * `team` - The team being updated + * `student_ids` - List of student ids for team members + * `team_id` - ID of the team + """ + defp update_team_members(team, student_ids, team_id) do + current_student_ids = team.team_members |> Enum.map(&(&1.student_id)) + new_student_ids = Enum.map(hd(student_ids), fn student -> Map.get(student, "userId") end) + + student_ids_to_add = Enum.filter(new_student_ids, fn elem -> not Enum.member?(current_student_ids, elem) end) + student_ids_to_remove = Enum.filter(current_student_ids, fn elem -> not Enum.member?(new_student_ids, elem) end) + + Enum.each(student_ids_to_add, fn student_id -> + %TeamMember{} + |> Ecto.Changeset.change(%{team_id: team_id, student_id: student_id}) # Change here + |> Repo.insert() + end) + + Enum.each(student_ids_to_remove, fn student_id -> + from(tm in TeamMember, where: tm.team_id == ^team_id and tm.student_id == ^student_id) + |> Repo.delete_all() + end) + end + + @doc """ + Deletes a team along with its associated submissions and answers. + ## Parameters + * `team` - The team to be deleted + """ + def delete_team(%Team{} = team) do + Submission + |> where(team_id: ^team.id) + |> Repo.all() + |> Enum.each(fn x -> + Answer + |> where(submission_id: ^x.id) + |> Repo.delete_all() + end) + team + |> Repo.delete() + end end \ No newline at end of file From 5efddde4d512f4e861219f3ccee6a5570365851b Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Tue, 19 Dec 2023 14:53:34 +0800 Subject: [PATCH 061/128] Raise exception when some of the students in mass team import are not enrolled in the course --- lib/cadet/accounts/teams.ex | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index 460817e3a..995501c1c 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -32,13 +32,16 @@ defmodule Cadet.Accounts.Teams do !all_students_distinct(teams) -> {:error, {:conflict, "One or more students appear multiple times in a team!"}} + !all_student_enrolled_in_course(teams, assessment.course_id) -> + {:error, {:conflict, "One or more students not enrolled in this course!"}} + student_already_assigned(teams, assessment_id) -> {:error, {:conflict, "One or more students already in a team for this assessment!"}} true -> Enum.reduce_while(attrs["student_ids"], {:ok, nil}, fn team_attrs, {:ok, _} -> student_ids = Enum.map(team_attrs, &Map.get(&1, "userId")) - IO.inspect(student_ids) + {:ok, team} = %Team{} |> Team.changeset(attrs) |> Repo.insert() @@ -93,6 +96,20 @@ defmodule Cadet.Accounts.Teams do end) end + defp all_student_enrolled_in_course(teams, course_id) do + all_ids = teams + |> Enum.flat_map(fn team -> + Enum.map(team, fn row -> Map.get(row, "userId") end) + end) + + query = from(cr in Cadet.Accounts.CourseRegistration, + where: cr.id in ^all_ids, + select: count(cr.id)) + + count = Repo.one(query) + count == length(all_ids) + end + @doc """ Checks if one or more students are already in another team for the same assessment. ## Parameters From 408e348c61e5cb08db10e58bf0b0acc09fe487a6 Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Tue, 19 Dec 2023 16:09:25 +0800 Subject: [PATCH 062/128] Update docs --- lib/cadet/accounts/teams.ex | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index 995501c1c..ad49dd506 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -77,6 +77,13 @@ defmodule Cadet.Accounts.Teams do end) end + @doc """ + Checks there is no duplicated student during team creation. + ## Parameters + * `team_attrs` - IDs of the team members being created + ## Returns + Returns `true` if all students in the list are distinct; otherwise, returns `false`. + """ defp all_students_distinct(team_attrs) do all_ids = team_attrs |> Enum.flat_map(fn team -> @@ -89,6 +96,14 @@ defmodule Cadet.Accounts.Teams do all_ids_distinct end + @doc """ + Checks if all the teams satisfy the max team size constraint. + ## Parameters + * `teams` - IDs of the team members being created + * `max_team_size` - max team size of the team + ## Returns + Returns `true` if all the teams have size less or equal to the max team size; otherwise, returns `false`. + """ defp all_team_within_max_size(teams, max_team_size) do Enum.all?(teams, fn team -> ids = Enum.map(team, &Map.get(&1, "userId")) @@ -96,6 +111,14 @@ defmodule Cadet.Accounts.Teams do end) end + @doc """ + Checks if one or more students are enrolled in the course. + ## Parameters + * `teams` - ID of the team being created + * `course_id` - ID of the course + ## Returns + Returns `true` if all students in the list enroll in the course; otherwise, returns `false`. + """ defp all_student_enrolled_in_course(teams, course_id) do all_ids = teams |> Enum.flat_map(fn team -> From 645406350f2178b421afb61891855ec2efdb5da4 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Tue, 19 Dec 2023 17:29:44 +0800 Subject: [PATCH 063/128] Fix Submission Grading Bug --- lib/cadet/accounts/notifications.ex | 8 +++++--- .../admin_controllers/admin_grading_controller.ex | 2 ++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/cadet/accounts/notifications.ex b/lib/cadet/accounts/notifications.ex index 052e484a1..bcefc0f02 100644 --- a/lib/cadet/accounts/notifications.ex +++ b/lib/cadet/accounts/notifications.ex @@ -176,12 +176,14 @@ defmodule Cadet.Accounts.Notifications do case submission.student_id do nil -> team = Repo.get(Team, submission.team_id) - team_members = - from t in Team, + query = + from(t in Team, join: tm in TeamMember, on: t.id == tm.team_id, - join: cr in CourseRegistration, on: tm.student_id == cr.student_id, + join: cr in CourseRegistration, on: tm.student_id == cr.id, where: t.id == ^team.id, select: cr.id + ) + team_members = Repo.all(query) Enum.each(team_members, fn tm_id -> write(%{ diff --git a/lib/cadet_web/admin_controllers/admin_grading_controller.ex b/lib/cadet_web/admin_controllers/admin_grading_controller.ex index 2bf3c0360..4fcbdc731 100644 --- a/lib/cadet_web/admin_controllers/admin_grading_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_grading_controller.ex @@ -54,6 +54,8 @@ defmodule CadetWeb.AdminGradingController do ) do {:ok, _} -> text(conn, "OK") + :ok -> + text(conn, "OK") {:error, {status, message}} -> conn From f83914c334aedf2b061567a6433a19adc9ba8d92 Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Tue, 19 Dec 2023 17:36:55 +0800 Subject: [PATCH 064/128] Update docs --- lib/cadet/accounts/teams.ex | 41 +++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index ad49dd506..3f446ca31 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -15,10 +15,15 @@ defmodule Cadet.Accounts.Teams do @doc """ Creates a new team and assigns an assessment and team members to it. + ## Parameters + * `attrs` - A map containing the attributes for assessment id and creating the team and its members. + ## Returns + Returns a tuple `{:ok, team}` on success; otherwise, an error tuple. + """ def create_team(attrs) do assessment_id = attrs["assessment_id"] @@ -60,11 +65,16 @@ defmodule Cadet.Accounts.Teams do @doc """ Validates whether there are student(s) who are already assigned to another group. + ## Parameters + * `team_attrs` - A list of all the teams and their members. * `assessment_id` - Id of the target assessment. + ## Returns + Returns `true` on success; otherwise, `false`. + """ defp student_already_assigned(team_attrs, assessment_id) do Enum.all?(team_attrs, fn team -> @@ -79,10 +89,15 @@ defmodule Cadet.Accounts.Teams do @doc """ Checks there is no duplicated student during team creation. + ## Parameters + * `team_attrs` - IDs of the team members being created + ## Returns + Returns `true` if all students in the list are distinct; otherwise, returns `false`. + """ defp all_students_distinct(team_attrs) do all_ids = team_attrs @@ -98,11 +113,16 @@ defmodule Cadet.Accounts.Teams do @doc """ Checks if all the teams satisfy the max team size constraint. + ## Parameters + * `teams` - IDs of the team members being created * `max_team_size` - max team size of the team + ## Returns + Returns `true` if all the teams have size less or equal to the max team size; otherwise, returns `false`. + """ defp all_team_within_max_size(teams, max_team_size) do Enum.all?(teams, fn team -> @@ -113,11 +133,16 @@ defmodule Cadet.Accounts.Teams do @doc """ Checks if one or more students are enrolled in the course. + ## Parameters + * `teams` - ID of the team being created * `course_id` - ID of the course + ## Returns + Returns `true` if all students in the list enroll in the course; otherwise, returns `false`. + """ defp all_student_enrolled_in_course(teams, course_id) do all_ids = teams @@ -135,12 +160,17 @@ defmodule Cadet.Accounts.Teams do @doc """ Checks if one or more students are already in another team for the same assessment. + ## Parameters + * `team_id` - ID of the team being updated (use -1 for team creation) * `student_ids` - List of student IDs * `assessment_id` - ID of the assessment + ## Returns + Returns `true` if any student in the list is already a member of another team for the same assessment; otherwise, returns `false`. + """ defp student_already_in_team?(team_id, student_ids, assessment_id) do query = @@ -156,12 +186,17 @@ defmodule Cadet.Accounts.Teams do @doc """ Updates an existing team, the corresponding assessment, and its members. + ## Parameters + * `team` - The existing team to be updated * `new_assessment_id` - The ID of the updated assessment * `student_ids` - List of student ids for team members + ## Returns + Returns a tuple `{:ok, updated_team}` on success, containing the updated team details; otherwise, an error tuple. + """ def update_team(%Team{} = team, new_assessment_id, student_ids) do old_assessment_id = team.assessment_id @@ -192,10 +227,13 @@ defmodule Cadet.Accounts.Teams do @doc """ Updates team members based on the new list of student IDs. + ## Parameters + * `team` - The team being updated * `student_ids` - List of student ids for team members * `team_id` - ID of the team + """ defp update_team_members(team, student_ids, team_id) do current_student_ids = team.team_members |> Enum.map(&(&1.student_id)) @@ -218,8 +256,11 @@ defmodule Cadet.Accounts.Teams do @doc """ Deletes a team along with its associated submissions and answers. + ## Parameters + * `team` - The team to be deleted + """ def delete_team(%Team{} = team) do Submission From 2d063c9c25b45a45ea63d14ccc6f8a7098105236 Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Thu, 21 Dec 2023 10:29:29 +0800 Subject: [PATCH 065/128] Fix team deletion bug when there is a submitted assessment --- lib/cadet/accounts/teams.ex | 60 ++++++++++++++++++++++++++----------- 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index 3f446ca31..6cf97899b 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -31,16 +31,16 @@ defmodule Cadet.Accounts.Teams do assessment = Cadet.Repo.get(Cadet.Assessments.Assessment, assessment_id) cond do - !all_team_within_max_size(teams, assessment.max_team_size) -> + !all_team_within_max_size?(teams, assessment.max_team_size) -> {:error, {:conflict, "One or more teams exceed the maximum team size!"}} - !all_students_distinct(teams) -> + !all_students_distinct?(teams) -> {:error, {:conflict, "One or more students appear multiple times in a team!"}} - !all_student_enrolled_in_course(teams, assessment.course_id) -> + !all_student_enrolled_in_course?(teams, assessment.course_id) -> {:error, {:conflict, "One or more students not enrolled in this course!"}} - student_already_assigned(teams, assessment_id) -> + student_already_assigned?(teams, assessment_id) -> {:error, {:conflict, "One or more students already in a team for this assessment!"}} true -> @@ -76,7 +76,7 @@ defmodule Cadet.Accounts.Teams do Returns `true` on success; otherwise, `false`. """ - defp student_already_assigned(team_attrs, assessment_id) do + defp student_already_assigned?(team_attrs, assessment_id) do Enum.all?(team_attrs, fn team -> ids = Enum.map(team, &Map.get(&1, "userId")) @@ -99,7 +99,7 @@ defmodule Cadet.Accounts.Teams do Returns `true` if all students in the list are distinct; otherwise, returns `false`. """ - defp all_students_distinct(team_attrs) do + defp all_students_distinct?(team_attrs) do all_ids = team_attrs |> Enum.flat_map(fn team -> Enum.map(team, fn row -> Map.get(row, "userId") end) @@ -124,7 +124,7 @@ defmodule Cadet.Accounts.Teams do Returns `true` if all the teams have size less or equal to the max team size; otherwise, returns `false`. """ - defp all_team_within_max_size(teams, max_team_size) do + defp all_team_within_max_size?(teams, max_team_size) do Enum.all?(teams, fn team -> ids = Enum.map(team, &Map.get(&1, "userId")) length(ids) <= max_team_size @@ -144,14 +144,14 @@ defmodule Cadet.Accounts.Teams do Returns `true` if all students in the list enroll in the course; otherwise, returns `false`. """ - defp all_student_enrolled_in_course(teams, course_id) do + defp all_student_enrolled_in_course?(teams, course_id) do all_ids = teams |> Enum.flat_map(fn team -> Enum.map(team, fn row -> Map.get(row, "userId") end) end) query = from(cr in Cadet.Accounts.CourseRegistration, - where: cr.id in ^all_ids, + where: cr.id in ^all_ids and cr.course_id == ^course_id, select: count(cr.id)) count = Repo.one(query) @@ -263,15 +263,39 @@ defmodule Cadet.Accounts.Teams do """ def delete_team(%Team{} = team) do - Submission - |> where(team_id: ^team.id) + if (has_submitted_answer?(team.id)) do + {:error, {:conflict, "This team has submitted their answers! Unable to delete the team!"}} + else + Submission + |> where(team_id: ^team.id) + |> Repo.all() + |> Enum.each(fn x -> + Answer + |> where(submission_id: ^x.id) + |> Repo.delete_all() + end) + team + |> Repo.delete() + end + end + + @doc """ + Check whether a team has subnitted submissions and answers. + + ## Parameters + + * `team_id` - The team id of the team to be checked + + ## Returns + + Returns `true` if any one of the submission has the status of "submitted", `false` otherwise + + """ + defp has_submitted_answer?(team_id) do + submission = Submission + |> where([s], s.team_id == ^team_id and s.status == :submitted) |> Repo.all() - |> Enum.each(fn x -> - Answer - |> where(submission_id: ^x.id) - |> Repo.delete_all() - end) - team - |> Repo.delete() + + length(submission) > 0 end end \ No newline at end of file From dd73093ef620976c340c342beefe08d26b906725 Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Fri, 22 Dec 2023 14:31:46 +0800 Subject: [PATCH 066/128] Send proper error msg to frontend when delete team fails --- lib/cadet_web/admin_controllers/admin_teams_controller.ex | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/cadet_web/admin_controllers/admin_teams_controller.ex b/lib/cadet_web/admin_controllers/admin_teams_controller.ex index bfc4e18aa..bcdb2426a 100644 --- a/lib/cadet_web/admin_controllers/admin_teams_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_teams_controller.ex @@ -70,6 +70,10 @@ defmodule CadetWeb.AdminTeamsController do team = Repo.get!(Team, team_id) case Teams.delete_team(team) do + {:error, {status, error_message}} -> + conn + |> put_status(status) + |> text(error_message) {:ok, _} -> text(conn, "Team deleted successfully.") {:error, _changeset} -> From a9e14a9c81598b8f3a80fb7c4d1a80e26be66177 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Thu, 4 Jan 2024 16:32:36 +0800 Subject: [PATCH 067/128] Resolve merge conflict from upstream --- config/config.exs | 4 +- lib/cadet/assessments/assessments.ex | 305 +++++++++------ .../question_types/voting_question.ex | 4 +- lib/cadet/auth/guardian.ex | 22 +- lib/cadet/jobs/xml_parser.ex | 3 +- lib/cadet/workers/NotificationWorker.ex | 11 +- .../admin_achievements_controller.ex | 6 +- .../admin_assessments_controller.ex | 8 +- .../admin_assets_controller.ex | 6 +- .../admin_courses_controller.ex | 4 +- .../admin_grading_controller.ex | 19 +- .../admin_stories_controller.ex | 6 +- .../admin_user_controller.ex | 12 +- .../admin_views/admin_grading_view.ex | 92 ++++- .../controllers/assessments_controller.ex | 8 +- .../controllers/courses_controller.ex | 6 +- .../controllers/incentives_controller.ex | 6 +- .../controllers/notifications_controller.ex | 4 +- .../controllers/sourcecast_controller.ex | 2 +- .../controllers/stories_controller.ex | 2 +- lib/cadet_web/router.ex | 13 +- mix.exs | 6 +- mix.lock | 82 ++-- ...01_create_submissions_assessment_index.exs | 11 + test/cadet/assessments/assessments_test.exs | 356 +++++++++++++++++- test/cadet/assessments/question_test.exs | 3 +- .../question_types/voting_question_test.exs | 23 +- test/cadet/auth/guardian_test.exs | 24 +- test/cadet/email_test.exs | 6 +- .../notification_worker_test.exs | 6 +- .../admin_grading_controller_test.exs | 22 +- .../admin_user_controller_test.exs | 2 +- .../controllers/courses_controller_test.exs | 2 +- .../factories/assessments/question_factory.ex | 6 +- test/support/xml_generator.ex | 5 +- 35 files changed, 832 insertions(+), 265 deletions(-) create mode 100644 priv/repo/migrations/20231105164101_create_submissions_assessment_index.exs diff --git a/config/config.exs b/config/config.exs index 244fe1e2e..c998adfe2 100644 --- a/config/config.exs +++ b/config/config.exs @@ -23,7 +23,9 @@ config :cadet, Cadet.Jobs.Scheduler, # Compute contest leaderboard that close in the previous day at 00:01 {"1 0 * * *", {Cadet.Assessments, :update_final_contest_leaderboards, []}}, # Compute rolling leaderboard every 2 hours - {"0 */2 * * *", {Cadet.Assessments, :update_rolling_contest_leaderboards, []}} + {"0 */2 * * *", {Cadet.Assessments, :update_rolling_contest_leaderboards, []}}, + # Collate contest entries that close in the previous day at 00:01 + {"1 0 * * *", {Cadet.Assessments, :update_final_contest_entries, []}} ] # Configures the endpoint diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 96b091808..94ad5bad5 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -25,6 +25,7 @@ defmodule Cadet.Assessments do alias Cadet.ProgramAnalysis.Lexer alias Ecto.Multi alias Cadet.Incentives.Achievements + alias Timex.Duration require Decimal @@ -37,18 +38,33 @@ defmodule Cadet.Assessments do def delete_assessment(id) do assessment = Repo.get(Assessment, id) - Submission - |> where(assessment_id: ^id) - |> delete_submission_assocation(id) + is_voted_on = + Question + |> where(type: :voting) + |> join(:inner, [q], asst in assoc(q, :assessment)) + |> where( + [q, asst], + q.question["contest_number"] == ^assessment.number and + asst.course_id == ^assessment.course_id + ) + |> Repo.exists?() - Question - |> where(assessment_id: ^id) - |> Repo.all() - |> Enum.each(fn q -> - delete_submission_votes_association(q) - end) + if is_voted_on do + {:error, {:bad_request, "Contest voting for this contest is still up"}} + else + Submission + |> where(assessment_id: ^id) + |> delete_submission_assocation(id) - Repo.delete(assessment) + Question + |> where(assessment_id: ^id) + |> Repo.all() + |> Enum.each(fn q -> + delete_submission_votes_association(q) + end) + + Repo.delete(assessment) + end end defp delete_submission_votes_association(question) do @@ -541,6 +557,49 @@ defmodule Cadet.Assessments do Question.changeset(%Question{}, params_with_assessment_id) end + def update_final_contest_entries do + # 1435 = 1 day - 5 minutes + if Log.log_execution("update_final_contest_entries", Duration.from_minutes(1435)) do + Logger.info("Started update of contest entry pools") + questions = fetch_unassigned_voting_questions() + + for q <- questions do + insert_voting(q.course_id, q.question["contest_number"], q.question_id) + end + + Logger.info("Successfully update contest entry pools") + end + end + + # fetch voting questions where entries have not been assigned + def fetch_unassigned_voting_questions do + voting_assigned_question_ids = + SubmissionVotes + |> select([v], v.question_id) + |> Repo.all() + + valid_assessments = + Assessment + |> select([a], %{number: a.number, course_id: a.course_id}) + |> Repo.all() + + valid_questions = + Question + |> where(type: :voting) + |> where([q], q.id not in ^voting_assigned_question_ids) + |> join(:inner, [q], asst in assoc(q, :assessment)) + |> select([q, asst], %{course_id: asst.course_id, question: q.question, question_id: q.id}) + |> Repo.all() + + # fetch only voting where there is a corresponding contest + Enum.filter(valid_questions, fn q -> + Enum.any?( + valid_assessments, + fn a -> a.number == q.question["contest_number"] and a.course_id == q.course_id end + ) + end) + end + @doc """ Generates and assigns contest entries for users with given usernames. """ @@ -563,102 +622,119 @@ defmodule Cadet.Assessments do {:error, error_changeset} else - # Returns contest submission ids with answers that contain "return" - contest_submission_ids = - Submission - |> join(:inner, [s], ans in assoc(s, :answers)) - |> join(:inner, [s, ans], cr in assoc(s, :student)) - |> where([s, ans, cr], cr.role == "student") - |> where([s, _], s.assessment_id == ^contest_assessment.id and s.status == "submitted") - |> where( - [_, ans, cr], - fragment( - "?->>'code' like ?", - ans.answer, - "%return%" - ) + if Timex.compare(contest_assessment.close_at, Timex.now()) < 0 do + compile_entries(course_id, contest_assessment, question_id) + else + # contest has not closed, do nothing + {:ok, nil} + end + end + end + + def compile_entries( + course_id, + contest_assessment, + question_id + ) do + # Returns contest submission ids with answers that contain "return" + contest_submission_ids = + Submission + |> join(:inner, [s], ans in assoc(s, :answers)) + |> join(:inner, [s, ans], cr in assoc(s, :student)) + |> where([s, ans, cr], cr.role == "student") + |> where([s, _], s.assessment_id == ^contest_assessment.id and s.status == "submitted") + |> where( + [_, ans, cr], + fragment( + "?->>'code' like ?", + ans.answer, + "%return%" ) - |> select([s, _ans], {s.student_id, s.id}) - |> Repo.all() - |> Enum.into(%{}) + ) + |> select([s, _ans], {s.student_id, s.id}) + |> Repo.all() + |> Enum.into(%{}) - contest_submission_ids_length = Enum.count(contest_submission_ids) + contest_submission_ids_length = Enum.count(contest_submission_ids) - voter_ids = - CourseRegistration - |> where(role: "student", course_id: ^course_id) - |> select([cr], cr.id) - |> Repo.all() + voter_ids = + CourseRegistration + |> where(role: "student", course_id: ^course_id) + |> select([cr], cr.id) + |> Repo.all() - votes_per_user = min(contest_submission_ids_length, 10) + votes_per_user = min(contest_submission_ids_length, 10) - votes_per_submission = - if Enum.empty?(contest_submission_ids) do - 0 - else - trunc(Float.ceil(votes_per_user * length(voter_ids) / contest_submission_ids_length)) - end + votes_per_submission = + if Enum.empty?(contest_submission_ids) do + 0 + else + trunc(Float.ceil(votes_per_user * length(voter_ids) / contest_submission_ids_length)) + end - submission_id_list = - contest_submission_ids - |> Enum.map(fn {_, s_id} -> s_id end) - |> Enum.shuffle() - |> List.duplicate(votes_per_submission) - |> List.flatten() - - {_submission_map, submission_votes_changesets} = - voter_ids - |> Enum.reduce({submission_id_list, []}, fn voter_id, acc -> - {submission_list, submission_votes} = acc - - user_contest_submission_id = Map.get(contest_submission_ids, voter_id) - - {votes, rest} = - submission_list - |> Enum.reduce_while({MapSet.new(), submission_list}, fn s_id, acc -> - {user_votes, submissions} = acc - - max_votes = - if votes_per_user == contest_submission_ids_length and - not is_nil(user_contest_submission_id) do - # no. of submssions is less than 10. Unable to find - votes_per_user - 1 - else - votes_per_user - end - - if MapSet.size(user_votes) < max_votes do - if s_id != user_contest_submission_id and not MapSet.member?(user_votes, s_id) do - new_user_votes = MapSet.put(user_votes, s_id) - new_submissions = List.delete(submissions, s_id) - {:cont, {new_user_votes, new_submissions}} - else - {:cont, {user_votes, submissions}} - end + submission_id_list = + contest_submission_ids + |> Enum.map(fn {_, s_id} -> s_id end) + |> Enum.shuffle() + |> List.duplicate(votes_per_submission) + |> List.flatten() + + {_submission_map, submission_votes_changesets} = + voter_ids + |> Enum.reduce({submission_id_list, []}, fn voter_id, acc -> + {submission_list, submission_votes} = acc + + user_contest_submission_id = Map.get(contest_submission_ids, voter_id) + + {votes, rest} = + submission_list + |> Enum.reduce_while({MapSet.new(), submission_list}, fn s_id, acc -> + {user_votes, submissions} = acc + + max_votes = + if votes_per_user == contest_submission_ids_length and + not is_nil(user_contest_submission_id) do + # no. of submssions is less than 10. Unable to find + votes_per_user - 1 else - {:halt, acc} + votes_per_user end - end) - votes = MapSet.to_list(votes) + if MapSet.size(user_votes) < max_votes do + if s_id != user_contest_submission_id and not MapSet.member?(user_votes, s_id) do + new_user_votes = MapSet.put(user_votes, s_id) + new_submissions = List.delete(submissions, s_id) + {:cont, {new_user_votes, new_submissions}} + else + {:cont, {user_votes, submissions}} + end + else + {:halt, acc} + end + end) - new_submission_votes = - votes - |> Enum.map(fn s_id -> - %SubmissionVotes{voter_id: voter_id, submission_id: s_id, question_id: question_id} - end) - |> Enum.concat(submission_votes) + votes = MapSet.to_list(votes) - {rest, new_submission_votes} - end) + new_submission_votes = + votes + |> Enum.map(fn s_id -> + %SubmissionVotes{ + voter_id: voter_id, + submission_id: s_id, + question_id: question_id + } + end) + |> Enum.concat(submission_votes) - submission_votes_changesets - |> Enum.with_index() - |> Enum.reduce(Multi.new(), fn {changeset, index}, multi -> - Multi.insert(multi, Integer.to_string(index), changeset) + {rest, new_submission_votes} end) - |> Repo.transaction() - end + + submission_votes_changesets + |> Enum.with_index() + |> Enum.reduce(Multi.new(), fn {changeset, index}, multi -> + Multi.insert(multi, Integer.to_string(index), changeset) + end) + |> Repo.transaction() end def update_assessment(id, params) when is_ecto_id(id) do @@ -1125,7 +1201,7 @@ defmodule Cadet.Assessments do """ def update_rolling_contest_leaderboards do # 115 = 2 hours - 5 minutes is default. - if Log.log_execution("update_rolling_contest_leaderboards", Timex.Duration.from_minutes(115)) do + if Log.log_execution("update_rolling_contest_leaderboards", Duration.from_minutes(115)) do Logger.info("Started update_rolling_contest_leaderboards") voting_questions_to_update = fetch_active_voting_questions() @@ -1152,7 +1228,7 @@ defmodule Cadet.Assessments do """ def update_final_contest_leaderboards do # 1435 = 24 hours - 5 minutes - if Log.log_execution("update_final_contest_leaderboards", Timex.Duration.from_minutes(1435)) do + if Log.log_execution("update_final_contest_leaderboards", Duration.from_minutes(1435)) do Logger.info("Started update_final_contest_leaderboards") voting_questions_to_update = fetch_voting_questions_due_yesterday() @@ -1198,7 +1274,12 @@ defmodule Cadet.Assessments do ) |> Repo.all() - entry_scores = map_eligible_votes_to_entry_score(eligible_votes) + token_divider = + Question + |> select([q], q.question["token_divider"]) + |> Repo.get_by(id: contest_voting_question_id) + + entry_scores = map_eligible_votes_to_entry_score(eligible_votes, token_divider) entry_scores |> Enum.map(fn {ans_id, relative_score} -> @@ -1215,7 +1296,7 @@ defmodule Cadet.Assessments do |> Repo.transaction() end - defp map_eligible_votes_to_entry_score(eligible_votes) do + defp map_eligible_votes_to_entry_score(eligible_votes, token_divider) do # converts eligible votes to the {total cumulative score, number of votes, tokens} entry_vote_data = Enum.reduce(eligible_votes, %{}, fn %{ans_id: ans_id, score: score, ans: ans}, tracker -> @@ -1233,17 +1314,17 @@ defmodule Cadet.Assessments do Enum.map( entry_vote_data, fn {ans_id, {sum_of_scores, number_of_voters, tokens}} -> - {ans_id, calculate_formula_score(sum_of_scores, number_of_voters, tokens)} + {ans_id, calculate_formula_score(sum_of_scores, number_of_voters, tokens, token_divider)} end ) end # Calculate the score based on formula - # score(v,t) = v - 2^(t/50) where v is the normalized_voting_score + # score(v,t) = v - 2^(t/token_divider) where v is the normalized_voting_score # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 - defp calculate_formula_score(sum_of_scores, number_of_voters, tokens) do + defp calculate_formula_score(sum_of_scores, number_of_voters, tokens, token_divider) do normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 - normalized_voting_score - :math.pow(2, min(1023.5, tokens / 50)) + normalized_voting_score - :math.pow(2, min(1023.5, tokens / token_divider)) end @doc """ @@ -1260,26 +1341,26 @@ defmodule Cadet.Assessments do {:unauthorized, "Forbidden."}} """ @spec all_submissions_by_grader_for_index(CourseRegistration.t()) :: - {:ok, String.t()} + {:ok, %{:assessments => [any()], :submissions => [any()], :users => [any()]}} def all_submissions_by_grader_for_index( grader = %CourseRegistration{course_id: course_id}, group_only \\ false, - ungraded_only \\ false + _ungraded_only \\ false ) do show_all = not group_only - group_where = + group_filter = if show_all, do: "", else: - "where s.student_id in (select cr.id from course_registrations cr inner join groups g on cr.group_id = g.id where g.leader_id = $2) or s.student_id = $2" - - ungraded_where = - if ungraded_only, - do: "where s.\"gradedCount\" < assts.\"questionCount\"", - else: "" - - params = if show_all, do: [course_id], else: [course_id, grader.id] + "AND s.student_id IN (SELECT cr.id FROM course_registrations AS cr INNER JOIN groups AS g ON cr.group_id = g.id WHERE g.leader_id = #{grader.id}) OR s.student_id = #{grader.id}" + + # TODO: Restore ungraded filtering + # ... or more likely, decouple email logic from this function + # ungraded_where = + # if ungraded_only, + # do: "where s.\"gradedCount\" < assts.\"questionCount\"", + # else: "" # We bypass Ecto here and use a raw query to generate JSON directly from # PostgreSQL, because doing it in Elixir/Erlang is too inefficient. diff --git a/lib/cadet/assessments/question_types/voting_question.ex b/lib/cadet/assessments/question_types/voting_question.ex index d76d65912..95c762fcd 100644 --- a/lib/cadet/assessments/question_types/voting_question.ex +++ b/lib/cadet/assessments/question_types/voting_question.ex @@ -11,14 +11,16 @@ defmodule Cadet.Assessments.QuestionTypes.VotingQuestion do field(:template, :string) field(:contest_number, :string) field(:reveal_hours, :integer) + field(:token_divider, :integer) end - @required_fields ~w(content contest_number reveal_hours)a + @required_fields ~w(content contest_number reveal_hours token_divider)a @optional_fields ~w(prepend template)a def changeset(question, params \\ %{}) do question |> cast(params, @required_fields ++ @optional_fields) |> validate_required(@required_fields) + |> validate_number(:token_divider, greater_than: 0) end end diff --git a/lib/cadet/auth/guardian.ex b/lib/cadet/auth/guardian.ex index b2305c4f0..c387cee22 100644 --- a/lib/cadet/auth/guardian.ex +++ b/lib/cadet/auth/guardian.ex @@ -8,16 +8,24 @@ defmodule Cadet.Auth.Guardian do alias Guardian.DB def subject_for_token(user, _claims) do - {:ok, to_string(user.id)} + {:ok, + URI.encode_query(%{ + id: user.id, + username: user.username, + provider: user.provider + })} end def resource_from_claims(claims) do - user = Accounts.get_user(claims["sub"]) - - if user == nil do - {:error, :not_found} - else - {:ok, user} + case claims["sub"] |> URI.decode_query() |> Map.fetch("id") do + :error -> + {:error, :bad_request} + + {:ok, id} -> + case user = Accounts.get_user(id) do + nil -> {:error, :not_found} + _ -> {:ok, user} + end end end diff --git a/lib/cadet/jobs/xml_parser.ex b/lib/cadet/jobs/xml_parser.ex index 003091b84..fb1742571 100644 --- a/lib/cadet/jobs/xml_parser.ex +++ b/lib/cadet/jobs/xml_parser.ex @@ -259,7 +259,8 @@ defmodule Cadet.Updater.XMLParser do |> xpath( ~x"./VOTING"e, contest_number: ~x"./@assessment_number"s, - reveal_hours: ~x"./@reveal_hours"i + reveal_hours: ~x"./@reveal_hours"i, + token_divider: ~x"./@token_divider"i ) ) end diff --git a/lib/cadet/workers/NotificationWorker.ex b/lib/cadet/workers/NotificationWorker.ex index d96a3df22..609aab13c 100644 --- a/lib/cadet/workers/NotificationWorker.ex +++ b/lib/cadet/workers/NotificationWorker.ex @@ -73,15 +73,10 @@ defmodule Cadet.Workers.NotificationWorker do for avenger_cr <- avengers_crs do avenger = Cadet.Accounts.get_user(avenger_cr.user_id) - ungraded_submissions = - Jason.decode!( - elem( - Cadet.Assessments.all_submissions_by_grader_for_index(avenger_cr, true, true), - 1 - ) - ) + {:ok, %{submissions: ungraded_submissions}} = + Cadet.Assessments.all_submissions_by_grader_for_index(avenger_cr, true, true) - if length(ungraded_submissions) < ungraded_threshold do + if Enum.count(ungraded_submissions) < ungraded_threshold do IO.puts("[AVENGER_BACKLOG] below threshold!") else IO.puts("[AVENGER_BACKLOG] SENDING_OUT") diff --git a/lib/cadet_web/admin_controllers/admin_achievements_controller.ex b/lib/cadet_web/admin_controllers/admin_achievements_controller.ex index 36e85c627..c7248dae6 100644 --- a/lib/cadet_web/admin_controllers/admin_achievements_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_achievements_controller.ex @@ -61,7 +61,7 @@ defmodule CadetWeb.AdminAchievementsController do end swagger_path :update do - put("/admin/achievements/{uuid}") + put("/courses/{course_id}/admin/achievements/{uuid}") summary("Inserts or updates an achievement") @@ -87,7 +87,7 @@ defmodule CadetWeb.AdminAchievementsController do end swagger_path :bulk_update do - put("/admin/achievements") + put("/courses/{course_id}/admin/achievements") summary("Inserts or updates achievements") @@ -108,7 +108,7 @@ defmodule CadetWeb.AdminAchievementsController do end swagger_path :delete do - PhoenixSwagger.Path.delete("/admin/achievements/{uuid}") + PhoenixSwagger.Path.delete("/courses/{course_id}/admin/achievements/{uuid}") summary("Deletes an achievement") security([%{JWT: []}]) diff --git a/lib/cadet_web/admin_controllers/admin_assessments_controller.ex b/lib/cadet_web/admin_controllers/admin_assessments_controller.ex index 33d61384b..2db9d1667 100644 --- a/lib/cadet_web/admin_controllers/admin_assessments_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_assessments_controller.ex @@ -134,7 +134,7 @@ defmodule CadetWeb.AdminAssessmentsController do end swagger_path :index do - get("/admin/users/{courseRegId}/assessments") + get("/courses/{course_id}/admin/users/{courseRegId}/assessments") summary("Fetches assessment overviews of a user") @@ -150,7 +150,7 @@ defmodule CadetWeb.AdminAssessmentsController do end swagger_path :create do - post("/admin/assessments") + post("/courses/{course_id}/admin/assessments") summary("Creates a new assessment or updates an existing assessment") @@ -169,7 +169,7 @@ defmodule CadetWeb.AdminAssessmentsController do end swagger_path :delete do - PhoenixSwagger.Path.delete("/admin/assessments/{assessmentId}") + PhoenixSwagger.Path.delete("/courses/{course_id}/admin/assessments/{assessmentId}") summary("Deletes an assessment") @@ -184,7 +184,7 @@ defmodule CadetWeb.AdminAssessmentsController do end swagger_path :update do - post("/admin/assessments/{assessmentId}") + post("/courses/{course_id}/admin/assessments/{assessmentId}") summary("Updates an assessment") diff --git a/lib/cadet_web/admin_controllers/admin_assets_controller.ex b/lib/cadet_web/admin_controllers/admin_assets_controller.ex index 8fd2f7003..3316cfffa 100644 --- a/lib/cadet_web/admin_controllers/admin_assets_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_assets_controller.ex @@ -71,7 +71,7 @@ defmodule CadetWeb.AdminAssetsController do end swagger_path :index do - get("/admin/assets/{folderName}") + get("/courses/{course_id}/admin/assets/{folderName}") summary("Get a list of all assets in a folder") @@ -89,7 +89,7 @@ defmodule CadetWeb.AdminAssetsController do end swagger_path :delete do - PhoenixSwagger.Path.delete("/admin/assets/{folderName}/{fileName}") + PhoenixSwagger.Path.delete("/courses/{course_id}/admin/assets/{folderName}/{fileName}") summary("Delete a file from an asset folder") @@ -108,7 +108,7 @@ defmodule CadetWeb.AdminAssetsController do end swagger_path :upload do - post("/admin/assets/{folderName}/{fileName}") + post("/courses/{course_id}/admin/assets/{folderName}/{fileName}") summary("Upload a file to an asset folder") diff --git a/lib/cadet_web/admin_controllers/admin_courses_controller.ex b/lib/cadet_web/admin_controllers/admin_courses_controller.ex index c932c67c9..61b97e38d 100644 --- a/lib/cadet_web/admin_controllers/admin_courses_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_courses_controller.ex @@ -91,7 +91,7 @@ defmodule CadetWeb.AdminCoursesController do end swagger_path :update_course_config do - put("/v2/courses/{course_id}/admin/config") + put("/courses/{course_id}/admin/config") summary("Updates the course configuration for the specified course") @@ -117,7 +117,7 @@ defmodule CadetWeb.AdminCoursesController do end swagger_path :update_assessment_configs do - put("/v2/courses/{course_id}/admin/config/assessment_configs") + put("/courses/{course_id}/admin/config/assessment_configs") summary("Updates the assessment configuration for the specified course") diff --git a/lib/cadet_web/admin_controllers/admin_grading_controller.ex b/lib/cadet_web/admin_controllers/admin_grading_controller.ex index 4fcbdc731..beaae173b 100644 --- a/lib/cadet_web/admin_controllers/admin_grading_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_grading_controller.ex @@ -10,11 +10,11 @@ defmodule CadetWeb.AdminGradingController do group = String.to_atom(group) case Assessments.all_submissions_by_grader_for_index(course_reg, group) do - {:ok, submissions} -> + {:ok, view_model} -> conn |> put_status(:ok) |> put_resp_content_type("application/json") - |> text(submissions) + |> render("gradingsummaries.json", view_model) end end @@ -120,7 +120,7 @@ defmodule CadetWeb.AdminGradingController do end swagger_path :index do - get("/admin/grading") + get("/courses/{course_id}/admin/grading") summary("Get a list of all submissions with current user as the grader") @@ -143,7 +143,7 @@ defmodule CadetWeb.AdminGradingController do end swagger_path :unsubmit do - post("/admin/grading/{submissionId}/unsubmit") + post("/courses/{course_id}/admin/grading/{submissionId}/unsubmit") summary("Unsubmit submission. Can only be done by the Avenger of a student") security([%{JWT: []}]) @@ -158,7 +158,7 @@ defmodule CadetWeb.AdminGradingController do end swagger_path :autograde_submission do - post("/admin/grading/{submissionId}/autograde") + post("/courses/{course_id}/admin/grading/{submissionId}/autograde") summary("Force re-autograding of an entire submission") security([%{JWT: []}]) @@ -173,7 +173,7 @@ defmodule CadetWeb.AdminGradingController do end swagger_path :autograde_answer do - post("/admin/grading/{submissionId}/{questionId}/autograde") + post("/courses/{course_id}/admin/grading/{submissionId}/{questionId}/autograde") summary("Force re-autograding of a question in a submission") security([%{JWT: []}]) @@ -189,7 +189,7 @@ defmodule CadetWeb.AdminGradingController do end swagger_path :show do - get("/admin/grading/{submissionId}") + get("/courses/{course_id}/admin/grading/{submissionId}") summary("Get information about a specific submission to be graded") @@ -208,7 +208,7 @@ defmodule CadetWeb.AdminGradingController do end swagger_path :update do - post("/admin/grading/{submissionId}/{questionId}") + post("/courses/{course_id}/admin/grading/{submissionId}/{questionId}") summary("Update marks given to the answer of a particular question in a submission") @@ -230,7 +230,7 @@ defmodule CadetWeb.AdminGradingController do end swagger_path :grading_summary do - get("/admin/grading/summary") + get("/courses/{course_id}/admin/grading/summary") summary("Receives a summary of grading items done by this grader") @@ -312,6 +312,7 @@ defmodule CadetWeb.AdminGradingController do properties do id(:integer, "student id", required: true) name(:string, "student name", required: true) + username(:string, "student username", required: true) groupName(:string, "name of student's group") groupLeaderId(:integer, "user id of group leader") end diff --git a/lib/cadet_web/admin_controllers/admin_stories_controller.ex b/lib/cadet_web/admin_controllers/admin_stories_controller.ex index 08d869245..a6cdd46c0 100644 --- a/lib/cadet_web/admin_controllers/admin_stories_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_stories_controller.ex @@ -53,7 +53,7 @@ defmodule CadetWeb.AdminStoriesController do end swagger_path :create do - post("/v2{course_id}/stories") + post("/courses/{course_id}/admin/stories") summary("Creates a new story") @@ -65,7 +65,7 @@ defmodule CadetWeb.AdminStoriesController do end swagger_path :delete do - PhoenixSwagger.Path.delete("/v2/courses/{course_id}/stories/{storyId}") + PhoenixSwagger.Path.delete("/courses/{course_id}/admin/stories/{storyId}") summary("Delete a story from database by id") @@ -81,7 +81,7 @@ defmodule CadetWeb.AdminStoriesController do end swagger_path :update do - post("/v2/courses/{course_id}/stories/{storyId}") + post("/courses/{course_id}/admin/stories/{storyId}") summary("Update details regarding a story") diff --git a/lib/cadet_web/admin_controllers/admin_user_controller.ex b/lib/cadet_web/admin_controllers/admin_user_controller.ex index e6c99a700..53add9133 100644 --- a/lib/cadet_web/admin_controllers/admin_user_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_user_controller.ex @@ -187,7 +187,7 @@ defmodule CadetWeb.AdminUserController do end swagger_path :index do - get("/v2/courses/{course_id}/admin/users") + get("/courses/{course_id}/admin/users") summary("Returns a list of users in the course owned by the admin") @@ -198,7 +198,7 @@ defmodule CadetWeb.AdminUserController do end swagger_path :combined_total_xp do - get("/v2/courses/{course_id}/admin/users/{course_reg_id}/total_xp") + get("/courses/{course_id}/admin/users/{course_reg_id}/total_xp") summary("Get the specified user's total XP from achievements and assessments") @@ -214,8 +214,8 @@ defmodule CadetWeb.AdminUserController do response(401, "Unauthorised") end - swagger_path :add_users do - put("/v2/courses/{course_id}/admin/users") + swagger_path :upsert_users_and_groups do + put("/courses/{course_id}/admin/users") summary("Adds the list of usernames and roles to the course") security([%{JWT: []}]) @@ -236,7 +236,7 @@ defmodule CadetWeb.AdminUserController do end swagger_path :update_role do - put("/v2/courses/{course_id}/admin/users/role") + put("/courses/{course_id}/admin/users/{course_reg_id}/role") summary("Updates the role of the given user in the the course") security([%{JWT: []}]) @@ -265,7 +265,7 @@ defmodule CadetWeb.AdminUserController do end swagger_path :delete_user do - delete("/v2/courses/{course_id}/admin/users") + delete("/courses/{course_id}/admin/users/{course_reg_id}") summary("Deletes a user from a course") consumes("application/json") diff --git a/lib/cadet_web/admin_views/admin_grading_view.ex b/lib/cadet_web/admin_views/admin_grading_view.ex index bf884596a..8bfd1e3a6 100644 --- a/lib/cadet_web/admin_views/admin_grading_view.ex +++ b/lib/cadet_web/admin_views/admin_grading_view.ex @@ -7,6 +7,94 @@ defmodule CadetWeb.AdminGradingView do render_many(answers, CadetWeb.AdminGradingView, "grading_info.json", as: :answer) end + def render("gradingsummaries.json", %{ + users: users, + assessments: assessments, + submissions: submissions + }) do + for submission <- submissions do + user = users |> Enum.find(&(&1.id == submission.student_id)) + assessment = assessments |> Enum.find(&(&1.id == submission.assessment_id)) + + render( + CadetWeb.AdminGradingView, + "gradingsummary.json", + %{ + user: user, + assessment: assessment, + submission: submission, + unsubmitter: + case submission.unsubmitted_by_id do + nil -> nil + _ -> users |> Enum.find(&(&1.id == submission.unsubmitted_by_id)) + end + } + ) + end + end + + def render("gradingsummary.json", %{ + user: user, + assessment: a, + submission: s, + unsubmitter: unsubmitter + }) do + s + |> transform_map_for_view(%{ + id: :id, + status: :status, + unsubmittedAt: :unsubmitted_at, + xp: :xp, + xpAdjustment: :xp_adjustment, + xpBonus: :xp_bonus, + gradedCount: + &case &1.graded_count do + nil -> 0 + x -> x + end + }) + |> Map.merge(%{ + assessment: + render_one(a, CadetWeb.AdminGradingView, "gradingsummaryassessment.json", as: :assessment), + student: render_one(user, CadetWeb.AdminGradingView, "gradingsummaryuser.json", as: :cr), + unsubmittedBy: + case unsubmitter do + nil -> nil + cr -> transform_map_for_view(cr, %{id: :id, name: & &1.user.name}) + end + }) + end + + def render("gradingsummaryassessment.json", %{assessment: a}) do + %{ + id: a.id, + title: a.title, + assessmentNumber: a.number, + isManuallyGraded: a.config.is_manually_graded, + type: a.config.type, + maxXp: a.questions |> Enum.map(& &1.max_xp) |> Enum.sum(), + questionCount: a.questions |> Enum.count() + } + end + + def render("gradingsummaryuser.json", %{cr: cr}) do + %{ + id: cr.id, + name: cr.user.name, + username: cr.user.username, + groupName: + case cr.group do + nil -> nil + _ -> cr.group.name + end, + groupLeaderId: + case cr.group do + nil -> nil + _ -> cr.group.leader_id + end + } + end + def render("grading_info.json", %{answer: answer}) do transform_map_for_view(answer, %{ student: &extract_student_data(&1.submission.student), @@ -23,11 +111,11 @@ defmodule CadetWeb.AdminGradingView do defp extract_student_data(nil), do: %{} defp extract_student_data(student) do - transform_map_for_view(student, %{name: fn st -> st.user.name end, id: :id}) + transform_map_for_view(student, %{name: fn st -> st.user.name end, id: :id, username: fn st -> st.user.username end}) end defp extract_team_member_data(team_member) do - transform_map_for_view(team_member, %{name: &(&1.student.user.name), id: :id}) + transform_map_for_view(team_member, %{name: &(&1.student.user.name), id: :id, username: &(&1.student.user.username)}) end defp extract_team_data(nil), do: %{} defp extract_team_data(team) do diff --git a/lib/cadet_web/controllers/assessments_controller.ex b/lib/cadet_web/controllers/assessments_controller.ex index d5a4ea5e5..72a2e610a 100644 --- a/lib/cadet_web/controllers/assessments_controller.ex +++ b/lib/cadet_web/controllers/assessments_controller.ex @@ -64,7 +64,7 @@ defmodule CadetWeb.AssessmentsController do end swagger_path :submit do - post("/assessments/{assessmentId}/submit") + post("/courses/{course_id}/assessments/{assessmentId}/submit") summary("Finalise submission for an assessment") security([%{JWT: []}]) @@ -84,7 +84,7 @@ defmodule CadetWeb.AssessmentsController do end swagger_path :index do - get("/assessments") + get("/courses/{course_id}/assessments") summary("Get a list of all assessments") @@ -97,7 +97,7 @@ defmodule CadetWeb.AssessmentsController do end swagger_path :show do - get("/assessments/{assessmentId}") + get("/courses/{course_id}/assessments/{assessmentId}") summary("Get information about one particular assessment") @@ -116,7 +116,7 @@ defmodule CadetWeb.AssessmentsController do end swagger_path :unlock do - post("/assessments/{assessmentId}/unlock") + post("/courses/{course_id}/assessments/{assessmentId}/unlock") summary("Unlocks a password-protected assessment and returns its information") diff --git a/lib/cadet_web/controllers/courses_controller.ex b/lib/cadet_web/controllers/courses_controller.ex index 8aea63a49..8f2cf3e4f 100644 --- a/lib/cadet_web/controllers/courses_controller.ex +++ b/lib/cadet_web/controllers/courses_controller.ex @@ -41,7 +41,7 @@ defmodule CadetWeb.CoursesController do end swagger_path :create do - post("/v2/config/create") + post("/config/create") summary("Creates a new course") @@ -65,8 +65,8 @@ defmodule CadetWeb.CoursesController do end end - swagger_path :get_course_config do - get("/v2/courses/{course_id}/config") + swagger_path :index do + get("/courses/{course_id}/config") summary("Retrieves the course configuration of the specified course") diff --git a/lib/cadet_web/controllers/incentives_controller.ex b/lib/cadet_web/controllers/incentives_controller.ex index 2787f8c26..5e2dccbe6 100644 --- a/lib/cadet_web/controllers/incentives_controller.ex +++ b/lib/cadet_web/controllers/incentives_controller.ex @@ -39,7 +39,7 @@ defmodule CadetWeb.IncentivesController do end swagger_path :index_achievements do - get("/achievements") + get("/courses/{course_id}/achievements") summary("Gets achievements") security([%{JWT: []}]) @@ -49,7 +49,7 @@ defmodule CadetWeb.IncentivesController do end swagger_path :index_goals do - get("/self/goals") + get("/courses/{course_id}/self/goals") summary("Gets goals, including user's progress") security([%{JWT: []}]) @@ -59,7 +59,7 @@ defmodule CadetWeb.IncentivesController do end swagger_path :update_progress do - post("/self/goals/{uuid}/progress") + post("/courses/{course_id}/self/goals/{uuid}/progress") summary("Inserts or updates own goal progress of specifed goal") security([%{JWT: []}]) diff --git a/lib/cadet_web/controllers/notifications_controller.ex b/lib/cadet_web/controllers/notifications_controller.ex index 5989f3336..bdeb5c6e8 100644 --- a/lib/cadet_web/controllers/notifications_controller.ex +++ b/lib/cadet_web/controllers/notifications_controller.ex @@ -39,7 +39,7 @@ defmodule CadetWeb.NotificationsController do end swagger_path :index do - get("/v2/courses/{course_id}/notifications") + get("/courses/{course_id}/notifications") summary("Get the unread notifications belonging to a user") @@ -52,7 +52,7 @@ defmodule CadetWeb.NotificationsController do end swagger_path :acknowledge do - post("/v2/courses/{course_id}/notifications/acknowledge") + post("/courses/{course_id}/notifications/acknowledge") summary("Acknowledge notification(s)") security([%{JWT: []}]) diff --git a/lib/cadet_web/controllers/sourcecast_controller.ex b/lib/cadet_web/controllers/sourcecast_controller.ex index 874717666..d56ecc66a 100644 --- a/lib/cadet_web/controllers/sourcecast_controller.ex +++ b/lib/cadet_web/controllers/sourcecast_controller.ex @@ -34,7 +34,7 @@ defmodule CadetWeb.SourcecastController do # end swagger_path :index do - get("/sourcecast") + get("/courses/{course_id}/sourcecast") description("Lists all sourcecasts") summary("Show all sourcecasts") produces("application/json") diff --git a/lib/cadet_web/controllers/stories_controller.ex b/lib/cadet_web/controllers/stories_controller.ex index a278ee20e..59e729dd6 100644 --- a/lib/cadet_web/controllers/stories_controller.ex +++ b/lib/cadet_web/controllers/stories_controller.ex @@ -11,7 +11,7 @@ defmodule CadetWeb.StoriesController do end swagger_path :index do - get("/v2/courses/{course_id}/stories") + get("/courses/{course_id}/stories") summary("Get a list of all stories") diff --git a/lib/cadet_web/router.ex b/lib/cadet_web/router.ex index 0c0aadc68..b91b52e64 100644 --- a/lib/cadet_web/router.ex +++ b/lib/cadet_web/router.ex @@ -80,6 +80,8 @@ defmodule CadetWeb.Router do post("/assessments/question/:questionid/answerLastModified", AnswerController, :checkLastModified) get("/achievements", IncentivesController, :index_achievements) + get("/self/goals", IncentivesController, :index_goals) + post("/self/goals/:uuid/progress", IncentivesController, :update_progress) get("/stories", StoriesController, :index) @@ -95,14 +97,6 @@ defmodule CadetWeb.Router do get("/team/:assessmentid", TeamController, :index) end - # Authenticated Pages - scope "/v2/courses/:course_id/self", CadetWeb do - pipe_through([:api, :auth, :ensure_auth, :course]) - - get("/goals", IncentivesController, :index_goals) - post("/goals/:uuid/progress", IncentivesController, :update_progress) - end - # Admin pages scope "/v2/courses/:course_id/admin", CadetWeb do pipe_through([:api, :auth, :ensure_auth, :course, :ensure_staff]) @@ -136,6 +130,7 @@ defmodule CadetWeb.Router do get("/users/:course_reg_id/assessments", AdminAssessmentsController, :index) # The admin route for getting assessment information for a specifc user + # TODO: Missing Swagger path get( "/users/:course_reg_id/assessments/:assessmentid", AdminAssessmentsController, @@ -163,9 +158,11 @@ defmodule CadetWeb.Router do post("/stories/:storyid", AdminStoriesController, :update) put("/config", AdminCoursesController, :update_course_config) + # TODO: Missing corresponding Swagger path entry get("/config/assessment_configs", AdminCoursesController, :get_assessment_configs) put("/config/assessment_configs", AdminCoursesController, :update_assessment_configs) + # TODO: Missing corresponding Swagger path entry delete( "/config/assessment_config/:assessment_config_id", AdminCoursesController, diff --git a/mix.exs b/mix.exs index df2b7cec6..992f71aed 100644 --- a/mix.exs +++ b/mix.exs @@ -53,8 +53,8 @@ defmodule Cadet.Mixfile do [ {:arc, "~> 0.11"}, {:arc_ecto, "~> 0.11"}, - {:corsica, "~> 1.1"}, - {:csv, "~> 2.3"}, + {:corsica, "~> 2.1"}, + {:csv, "~> 3.2"}, {:ecto_enum, "~> 1.0"}, {:ex_aws, "~> 2.1", override: true}, {:ex_aws_lambda, "~> 2.0"}, @@ -85,7 +85,7 @@ defmodule Cadet.Mixfile do # notifiations system dependencies {:phoenix_html, "~> 3.0"}, {:bamboo, "~> 2.3.0"}, - {:bamboo_ses, "~> 0.3.0"}, + {:bamboo_ses, "~> 0.4.1"}, {:bamboo_phoenix, "~> 1.0.0"}, {:oban, "~> 2.13"}, diff --git a/mix.lock b/mix.lock index 45a64308d..c80106212 100644 --- a/mix.lock +++ b/mix.lock @@ -4,30 +4,32 @@ "artificery": {:hex, :artificery, "0.4.3", "0bc4260f988dcb9dda4b23f9fc3c6c8b99a6220a331534fdf5bf2fd0d4333b02", [:mix], [], "hexpm", "12e95333a30e20884e937abdbefa3e7f5e05609c2ba8cf37b33f000b9ffc0504"}, "bamboo": {:hex, :bamboo, "2.3.0", "d2392a2cabe91edf488553d3c70638b532e8db7b76b84b0a39e3dfe492ffd6fc", [:mix], [{:hackney, ">= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.4 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dd0037e68e108fd04d0e8773921512c940e35d981e097b5793543e3b2f9cd3f6"}, "bamboo_phoenix": {:hex, :bamboo_phoenix, "1.0.0", "f3cc591ffb163ed0bf935d256f1f4645cd870cf436545601215745fb9cc9953f", [:mix], [{:bamboo, ">= 2.0.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.3.0", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "6db88fbb26019c84a47994bb2bd879c0887c29ce6c559bc6385fd54eb8b37dee"}, - "bamboo_ses": {:hex, :bamboo_ses, "0.3.1", "3c172fc5bf2bbb1f9eec632750496ae1e6468cec4c2f0ac2a6b04351a674e2f2", [:mix], [{:bamboo, "~> 2.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:ex_aws, "~> 2.4.1", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "0.2.2", [hex: :mail, repo: "hexpm", optional: false]}], "hexpm", "7e67997479115501da674627b15322a570b41042fc0031be8a5c80e734354c26"}, + "bamboo_ses": {:hex, :bamboo_ses, "0.4.2", "e148a0ae17f8223b830029c2e81b2ba18220aa7378531ef1f50c4212fbd9ddb1", [:mix], [{:bamboo, "~> 2.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:ex_aws, "~> 2.4.1", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:gen_smtp, "~> 1.2.0", [hex: :gen_smtp, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "feb609b57316d335b217937f66cfc7c1ebe37ec481bebe97fcd5da5f31171808"}, "blankable": {:hex, :blankable, "1.0.0", "89ab564a63c55af117e115144e3b3b57eb53ad43ba0f15553357eb283e0ed425", [:mix], [], "hexpm", "7cf11aac0e44f4eedbee0c15c1d37d94c090cb72a8d9fddf9f7aec30f9278899"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, - "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, + "castore": {:hex, :castore, "1.0.4", "ff4d0fb2e6411c0479b1d965a814ea6d00e51eb2f58697446e9c41a97d940b28", [:mix], [], "hexpm", "9418c1b8144e11656f0be99943db4caf04612e3eaecefb5dae9a2a87565584f8"}, + "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "configparser_ex": {:hex, :configparser_ex, "4.0.0", "17e2b831cfa33a08c56effc610339b2986f0d82a9caa0ed18880a07658292ab6", [:mix], [], "hexpm", "02e6d1a559361a063cba7b75bc3eb2d6ad7e62730c551cc4703541fd11e65e5b"}, "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, - "corsica": {:hex, :corsica, "1.2.0", "5774cb77fd1d66ab89ffc2f04b2249f8e386bc37790a9f4bf101330ca247c02d", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c71f870555ce7a3eded55bbe937234cc48c546e73ce75745df9f59531687a759"}, - "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, + "corsica": {:hex, :corsica, "2.1.3", "dccd094ffce38178acead9ae743180cdaffa388f35f0461ba1e8151d32e190e6", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "616c08f61a345780c2cf662ff226816f04d8868e12054e68963e95285b5be8bc"}, + "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, - "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, - "credo": {:hex, :credo, "1.6.4", "ddd474afb6e8c240313f3a7b0d025cc3213f0d171879429bf8535d7021d9ad78", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "c28f910b61e1ff829bffa056ef7293a8db50e87f2c57a9b5c3f57eee124536b7"}, - "crontab": {:hex, :crontab, "1.1.11", "4028ced51b813a5061f85b689d4391ef0c27550c8ab09aaf139e4295c3d93ea4", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "ecb045f9ac14a3e2990e54368f70cdb6e2f2abafc5bc329d6c31f0c74b653787"}, - "csv": {:hex, :csv, "2.4.1", "50e32749953b6bf9818dbfed81cf1190e38cdf24f95891303108087486c5925e", [:mix], [{:parallel_stream, "~> 1.0.4", [hex: :parallel_stream, repo: "hexpm", optional: false]}], "hexpm", "54508938ac67e27966b10ef49606e3ad5995d665d7fc2688efb3eab1307c9079"}, - "db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"}, + "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, + "credo": {:hex, :credo, "1.7.1", "6e26bbcc9e22eefbff7e43188e69924e78818e2fe6282487d0703652bc20fd62", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e9871c6095a4c0381c89b6aa98bc6260a8ba6addccf7f6a53da8849c748a58a2"}, + "crontab": {:hex, :crontab, "1.1.13", "3bad04f050b9f7f1c237809e42223999c150656a6b2afbbfef597d56df2144c5", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "d67441bec989640e3afb94e123f45a2bc42d76e02988c9613885dc3d01cf7085"}, + "csv": {:hex, :csv, "3.2.1", "6d401f1ed33acb2627682a9ab6021e96d33ca6c1c6bccc243d8f7e2197d032f5", [:mix], [], "hexpm", "8f55a0524923ae49e97ff2642122a2ce7c61e159e7fe1184670b2ce847aee6c8"}, + "db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"}, "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, - "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, + "dialyxir": {:hex, :dialyxir, "1.4.2", "764a6e8e7a354f0ba95d58418178d486065ead1f69ad89782817c296d0d746a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "516603d8067b2fd585319e4b13d3674ad4f314a5902ba8130cd97dc902ce6bbd"}, "distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm", "bbc7008b0161a6f130d8d903b5b3232351fccc9c31a991f8fcbf2a12ace22995"}, - "ecto": {:hex, :ecto, "3.8.4", "e06b8b87e62b27fea17fd2ff6041572ddd10339fd16cdf58446e402c6c90a74b", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f9244288b8d42db40515463a008cf3f4e0e564bb9c249fe87bf28a6d79fe82d4"}, + "ecto": {:hex, :ecto, "3.11.1", "4b4972b717e7ca83d30121b12998f5fcdc62ba0ed4f20fd390f16f3270d85c3e", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebd3d3772cd0dfcd8d772659e41ed527c28b2a8bde4b00fe03e0463da0f1983b"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, - "ecto_sql": {:hex, :ecto_sql, "3.8.3", "a7d22c624202546a39d615ed7a6b784580391e65723f2d24f65941b4dd73d471", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.8.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "348cb17fb9e6daf6f251a87049eafcb57805e2892e5e6a0f5dea0985d367329b"}, + "ecto_sql": {:hex, :ecto_sql, "3.11.1", "e9abf28ae27ef3916b43545f9578b4750956ccea444853606472089e7d169470", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ce14063ab3514424276e7e360108ad6c2308f6d88164a076aac8a387e1fea634"}, + "eiconv": {:hex, :eiconv, "1.0.0", "ee1e47ee37799a05beff7a68d61f63cccc93101833c4fb94b454c23b12a21629", [:rebar3], [], "hexpm", "8c80851decf72fc4571a70278d7932e9a87437770322077ecf797533fbb792cd"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, - "ex_aws": {:hex, :ex_aws, "2.4.2", "d2686c34b69287cc8dd7629e70131aec05fef3cd3eae13698c9422933f7bc9ee", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0a2c07bd1541b0bef315f67e050d3cb9f947ab1a281896a8c35e3ee4976889f6"}, + "ex_aws": {:hex, :ex_aws, "2.5.0", "1785e69350b16514c1049330537c7da10039b1a53e1d253bbd703b135174aec3", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "971b86e5495fc0ae1c318e35e23f389e74cf322f2c02d34037c6fc6d405006f1"}, "ex_aws_lambda": {:hex, :ex_aws_lambda, "2.1.0", "f28bffae6dde34ba17ef815ef50a82a1e7f3af26d36fe31dbda3a7657e0de449", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "25608630b8b45fe22b0237696662f88be724bc89f77ee42708a5871511f531a0"}, "ex_aws_s3": {:hex, :ex_aws_s3, "2.3.3", "61412e524616ea31d3f31675d8bc4c73f277e367dee0ae8245610446f9b778aa", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "0044f0b6f9ce925666021eafd630de64c2b3404d79c85245cc7c8a9a32d7f104"}, "ex_aws_secretsmanager": {:hex, :ex_aws_secretsmanager, "2.0.0", "deff8c12335f0160882afeb9687e55a97fddcd7d9a82fc3a6fbb270797374773", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "8b2838af536c32263ff797012b29e87bad73ef34f43cfa60ebca8e84576f6d45"}, @@ -36,22 +38,24 @@ "ex_machina": {:hex, :ex_machina, "2.7.0", "b792cc3127fd0680fecdb6299235b4727a4944a09ff0fa904cc639272cd92dc7", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "419aa7a39bde11894c87a615c4ecaa52d8f107bbdd81d810465186f783245bf8"}, "ex_utils": {:hex, :ex_utils, "0.1.7", "2c133e0bcdc49a858cf8dacf893308ebc05bc5fba501dc3d2935e65365ec0bf3", [:mix], [], "hexpm", "66d4fe75285948f2d1e69c2a5ddd651c398c813574f8d36a9eef11dc20356ef6"}, "exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], [], "hexpm", "1222419f706e01bfa1095aec9acf6421367dcfab798a6f67c54cf784733cd6b5"}, - "excoveralls": {:hex, :excoveralls, "0.16.1", "0bd42ed05c7d2f4d180331a20113ec537be509da31fed5c8f7047ce59ee5a7c5", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dae763468e2008cf7075a64cb1249c97cb4bc71e236c5c2b5e5cdf1cfa2bf138"}, + "excoveralls": {:hex, :excoveralls, "0.18.0", "b92497e69465dc51bc37a6422226ee690ab437e4c06877e836f1c18daeb35da9", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1109bb911f3cb583401760be49c02cbbd16aed66ea9509fc5479335d284da60b"}, "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm", "32e95820a97cffea67830e91514a2ad53b888850442d6d395f53a1ac60c82e07"}, - "exvcr": {:hex, :exvcr, "0.13.3", "fcd5f54ea0ebd41db7fe16701f3c67871d1b51c3c104ab88f11135a173d47134", [:mix], [{:exactor, "~> 2.2", [hex: :exactor, repo: "hexpm", optional: false]}, {:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.1", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "db61057447388b7adc4443a55047d11d09acc75eeb5548507c775a8402e02689"}, + "expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"}, + "exvcr": {:hex, :exvcr, "0.15.0", "432a4f4b94494f996c96dd2b9b9d3306b70db269ddbdeb9e324a4371f62ce32d", [:mix], [{:exactor, "~> 2.2", [hex: :exactor, repo: "hexpm", optional: false]}, {:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:finch, "~> 0.16", [hex: :finch, repo: "hexpm", optional: true]}, {:httpoison, "~> 1.0 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.1", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "8b7e451f5fd37d1dc1252d08e55291fcb80b55b00cfd84ea41bf64be23cb142c"}, "faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, - "gen_stage": {:hex, :gen_stage, "1.1.2", "b1656cd4ba431ed02c5656fe10cb5423820847113a07218da68eae5d6a260c23", [:mix], [], "hexpm", "9e39af23140f704e2b07a3e29d8f05fd21c2aaf4088ff43cb82be4b9e3148d02"}, - "gettext": {:hex, :gettext, "0.19.1", "564953fd21f29358e68b91634799d9d26989f8d039d7512622efb3c3b1c97892", [:mix], [], "hexpm", "10c656c0912b8299adba9b061c06947511e3f109ab0d18b44a866a4498e77222"}, + "gen_smtp": {:hex, :gen_smtp, "1.2.0", "9cfc75c72a8821588b9b9fe947ae5ab2aed95a052b81237e0928633a13276fd3", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "5ee0375680bca8f20c4d85f58c2894441443a743355430ff33a783fe03296779"}, + "gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"}, + "gettext": {:hex, :gettext, "0.22.2", "6bfca374de34ecc913a28ba391ca184d88d77810a3e427afa8454a71a51341ac", [:mix], [{:expo, "~> 0.4.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "8a2d389673aea82d7eae387e6a2ccc12660610080ae7beb19452cfdc1ec30f60"}, "git_hooks": {:hex, :git_hooks, "0.7.3", "09489e94d88dfc767662e22aff2b6208bd7cf555a19dd0e1477cca4683ce0701", [:mix], [{:blankable, "~> 1.0.0", [hex: :blankable, repo: "hexpm", optional: false]}, {:recase, "~> 0.7.0", [hex: :recase, repo: "hexpm", optional: false]}], "hexpm", "d6ddedeb4d3a8602bc3f84e087a38f6150a86d9e790628ed8bc70e6d90681659"}, - "guardian": {:hex, :guardian, "2.2.4", "3dafdc19665411c96b2796d184064d691bc08813a132da5119e39302a252b755", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "6f83d4309c16ec2469da8606bb2a9815512cc2fac1595ad34b79940a224eb110"}, + "guardian": {:hex, :guardian, "2.3.2", "78003504b987f2b189d76ccf9496ceaa6a454bb2763627702233f31eb7212881", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "b189ff38cd46a22a8a824866a6867ca8722942347f13c33f7d23126af8821b52"}, "guardian_db": {:hex, :guardian_db, "2.1.0", "ec95a9d99cdd1e550555d09a7bb4a340d8887aad0697f594590c2fd74be02426", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:guardian, "~> 1.0 or ~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "f8e7d543ac92c395f3a7fd5acbe6829faeade57d688f7562e2f0fca8f94a0d70"}, - "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, + "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~>2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "inch_ex": {:hex, :inch_ex, "2.1.0-rc.1", "7642a8902c0d2ed5d9b5754b2fc88fedf630500d630fc03db7caca2e92dedb36", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4ceee988760f9382d1c1d0b93ea5875727f6071693e89a0a3c49c456ef1be75d"}, - "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, - "jose": {:hex, :jose, "1.11.2", "f4c018ccf4fdce22c71e44d471f15f723cb3efab5d909ab2ba202b5bf35557b3", [:mix, :rebar3], [], "hexpm", "98143fbc48d55f3a18daba82d34fe48959d44538e9697c08f34200fa5f0947d2"}, + "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "jose": {:hex, :jose, "1.11.6", "613fda82552128aa6fb804682e3a616f4bc15565a048dabd05b1ebd5827ed965", [:mix, :rebar3], [], "hexpm", "6275cb75504f9c1e60eeacb771adfeee4905a9e182103aa59b53fed651ff9738"}, "jsx": {:hex, :jsx, "3.1.0", "d12516baa0bb23a59bb35dccaf02a1bd08243fcbb9efe24f2d9d056ccff71268", [:rebar3], [], "hexpm", "0c5cc8fdc11b53cc25cf65ac6705ad39e54ecc56d1c22e4adb8f5a53fb9427f3"}, "mail": {:hex, :mail, "0.2.2", "b1d31beaa2a7b23d7b84b2794f037ef4dfdaba9e66d877142bedbaf0625b9c16", [:mix], [], "hexpm", "1c9d31548a60c44ded1806369e07a7dd4d05737eb47fa3238bbf2436b3da8a32"}, "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, @@ -59,32 +63,36 @@ "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, - "mock": {:hex, :mock, "0.3.7", "75b3bbf1466d7e486ea2052a73c6e062c6256fb429d6797999ab02fa32f29e03", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "4da49a4609e41fd99b7836945c26f373623ea968cfb6282742bcb94440cf7e5c"}, - "oban": {:hex, :oban, "2.14.1", "99e28a814ca9faa759cd3f88d9adc56eb5dd0b8d4a5dabb8d2e989cb57c86f52", [:mix], [{:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6c368b5face9b1e96ba42a1d39710c5193f4b38b62c8aeb651e37897aa3feecd"}, + "mock": {:hex, :mock, "0.3.8", "7046a306b71db2488ef54395eeb74df0a7f335a7caca4a3d3875d1fc81c884dd", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "7fa82364c97617d79bb7d15571193fc0c4fe5afd0c932cef09426b3ee6fe2022"}, + "oban": {:hex, :oban, "2.17.1", "42d6221a1c17b63d81c19e3bad9ea82b59e39c47c1f9b7670ee33628569a449b", [:mix], [{:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c02686ada7979b00e259c0efbafeae2749f8209747b3460001fe695c5bdbeee6"}, "openid_connect": {:hex, :openid_connect, "0.2.2", "c05055363330deab39ffd89e609db6b37752f255a93802006d83b45596189c0b", [:mix], [{:httpoison, "~> 1.2", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "735769b6d592124b58edd0582554ce638524c0214cd783d8903d33357d74cc13"}, - "parallel_stream": {:hex, :parallel_stream, "1.0.6", "b967be2b23f0f6787fab7ed681b4c45a215a81481fb62b01a5b750fa8f30f76c", [:mix], [], "hexpm", "639b2e8749e11b87b9eb42f2ad325d161c170b39b288ac8d04c4f31f8f0823eb"}, - "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, - "phoenix": {:hex, :phoenix, "1.6.10", "7a9e8348c5c62e7fd2f74a1884b88d98251f87186a430048bfbdbab3e3f46736", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "08cf70d42f61dd0ea381805bac3cddef57b7b92ade5acc6f6036aa25ecaca9a2"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, - "phoenix_html": {:hex, :phoenix_html, "3.3.0", "bf451c71ebdaac8d2f40d3b703435e819ccfbb9ff243140ca3bd10c155f134cc", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "272c5c1533499f0132309936c619186480bafcc2246588f99a69ce85095556ef"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, + "parallel_stream": {:hex, :parallel_stream, "1.1.0", "f52f73eb344bc22de335992377413138405796e0d0ad99d995d9977ac29f1ca9", [:mix], [], "hexpm", "684fd19191aedfaf387bbabbeb8ff3c752f0220c8112eb907d797f4592d6e871"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, + "phoenix": {:hex, :phoenix, "1.7.10", "02189140a61b2ce85bb633a9b6fd02dff705a5f1596869547aeb2b2b95edd729", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "cf784932e010fd736d656d7fead6a584a4498efefe5b8227e9f383bf15bb79d0"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.3", "86e9878f833829c3f66da03d75254c155d91d72a201eb56ae83482328dc7ca93", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d36c401206f3011fefd63d04e8ef626ec8791975d9d107f9a0817d426f61ac07"}, + "phoenix_html": {:hex, :phoenix_html, "3.3.3", "380b8fb45912b5638d2f1d925a3771b4516b9a78587249cabe394e0a5d579dc9", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "923ebe6fec6e2e3b3e569dfbdc6560de932cd54b000ada0208b5f45024bdd76c"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "0.19.3", "3918c1b34df8ac71a9a636806ba5b7f053349a0392b312e16f35b0bf4d070aab", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "545626887948495fd8ea23d83b75bd7aaf9dc4221563e158d2c4b52ea1dd7e00"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_swagger": {:hex, :phoenix_swagger, "0.8.3", "298d6204802409d3b0b4fc1013873839478707cf3a62532a9e10fec0e26d0e37", [:mix], [{:ex_json_schema, "~> 0.7.1", [hex: :ex_json_schema, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "3bc0fa9f5b679b8a61b90a52b2c67dd932320e9a84a6f91a4af872a0ab367337"}, - "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, - "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, - "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, - "postgrex": {:hex, :postgrex, "0.16.3", "fac79a81a9a234b11c44235a4494d8565303fa4b9147acf57e48978a074971db", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "aeaae1d2d1322da4e5fe90d241b0a564ce03a3add09d7270fb85362166194590"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.3", "32de561eefcefa951aead30a1f94f1b5f0379bc9e340bb5c667f65f1edfa4326", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "16f4b6588a4152f3cc057b9d0c0ba7e82ee23afa65543da535313ad8d25d8e2c"}, + "phoenix_view": {:hex, :phoenix_view, "2.0.3", "4d32c4817fce933693741deeb99ef1392619f942633dde834a5163124813aad3", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "cd34049af41be2c627df99cd4eaa71fc52a328c0c3d8e7d4aa28f880c30e7f64"}, + "plug": {:hex, :plug, "1.15.1", "b7efd81c1a1286f13efb3f769de343236bd8b7d23b4a9f40d3002fc39ad8f74c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "459497bd94d041d98d948054ec6c0b76feacd28eec38b219ca04c0de13c79d30"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, + "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, + "postgrex": {:hex, :postgrex, "0.17.4", "5777781f80f53b7c431a001c8dad83ee167bcebcf3a793e3906efff680ab62b3", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "6458f7d5b70652bc81c3ea759f91736c16a31be000f306d3c64bcdfe9a18b3cc"}, "quantum": {:hex, :quantum, "3.5.0", "8d2c5ba68c55991e8975aca368e3ab844ba01f4b87c4185a7403280e2c99cf34", [:mix], [{:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.14 or ~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.2", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "cab737d1d9779f43cb1d701f46dd05ea58146fd96238d91c9e0da662c1982bb6"}, "que": {:hex, :que, "0.10.1", "788ed0ec92ed69bdf9cfb29bf41a94ca6355b8d44959bd0669cf706e557ac891", [:mix], [{:ex_utils, "~> 0.1.6", [hex: :ex_utils, repo: "hexpm", optional: false]}, {:memento, "~> 0.3.0", [hex: :memento, repo: "hexpm", optional: false]}], "hexpm", "a737b365253e75dbd24b2d51acc1d851049e87baae08cd0c94e2bc5cd65088d5"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "recase": {:hex, :recase, "0.7.0", "3f2f719f0886c7a3b7fe469058ec539cb7bbe0023604ae3bce920e186305e5ae", [:mix], [], "hexpm", "36f5756a9f552f4a94b54a695870e32f4e72d5fad9c25e61bc4a3151c08a4e0c"}, - "sentry": {:hex, :sentry, "8.0.6", "c8de1bf0523bc120ec37d596c55260901029ecb0994e7075b0973328779ceef7", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.3", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "051a2d0472162f3137787c7c9d6e6e4ef239de9329c8c45b1f1bf1e9379e1883"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, - "sweet_xml": {:hex, :sweet_xml, "0.7.3", "debb256781c75ff6a8c5cbf7981146312b66f044a2898f453709a53e5031b45b", [:mix], [], "hexpm", "e110c867a1b3fe74bfc7dd9893aa851f0eed5518d0d7cad76d7baafd30e4f5ba"}, + "sentry": {:hex, :sentry, "8.1.0", "8d235b62fce5f8e067ea1644e30939405b71a5e1599d9529ff82899d11d03f2b", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.3", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "f9fc7641ef61e885510f5e5963c2948b9de1de597c63f781e9d3d6c9c8681ab4"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "sweet_xml": {:hex, :sweet_xml, "0.7.4", "a8b7e1ce7ecd775c7e8a65d501bc2cd933bff3a9c41ab763f5105688ef485d08", [:mix], [], "hexpm", "e7c4b0bdbf460c928234951def54fe87edf1a170f6896675443279e2dbeba167"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "telemetry_registry": {:hex, :telemetry_registry, "0.3.0", "6768f151ea53fc0fbca70dbff5b20a8d663ee4e0c0b2ae589590e08658e76f1e", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "492e2adbc609f3e79ece7f29fec363a97a2c484ac78a83098535d6564781e917"}, "timex": {:hex, :timex, "3.7.8", "0e6e8bf7c0aba95f1e13204889b2446e7a5297b1c8e408f15ab58b2c8dc85f81", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8f3b8edc5faab5205d69e5255a1d64a83b190bab7f16baa78aefcb897cf81435"}, "tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.5", "9dfeee8269b27e958a65b3e235b7e447769f66b5b5925385f5a569269164a210", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b977ba4a01918acbf77045ff88de7f6972c2a009213c515a445c48f224ffce9"}, "xml_builder": {:hex, :xml_builder, "2.2.0", "cc5f1eeefcfcde6e90a9b77fb6c490a20bc1b856a7010ce6396f6da9719cbbab", [:mix], [], "hexpm", "9d66d52fb917565d358166a4314078d39ef04d552904de96f8e73f68f64a62c9"}, } diff --git a/priv/repo/migrations/20231105164101_create_submissions_assessment_index.exs b/priv/repo/migrations/20231105164101_create_submissions_assessment_index.exs new file mode 100644 index 000000000..04016dcb1 --- /dev/null +++ b/priv/repo/migrations/20231105164101_create_submissions_assessment_index.exs @@ -0,0 +1,11 @@ +defmodule Cadet.Repo.Migrations.CreateSubmissionsAssessmentIndex do + use Ecto.Migration + + def up do + create(index(:submissions, [:assessment_id])) + end + + def down do + drop(index(:submissions, [:assessment_id])) + end +end diff --git a/test/cadet/assessments/assessments_test.exs b/test/cadet/assessments/assessments_test.exs index 319f3aba4..3575768ff 100644 --- a/test/cadet/assessments/assessments_test.exs +++ b/test/cadet/assessments/assessments_test.exs @@ -77,7 +77,8 @@ defmodule Cadet.AssessmentsTest do question: %{ content: Faker.Pokemon.name(), contest_number: assessment.number, - reveal_hours: 48 + reveal_hours: 48, + token_divider: 50 } }, assessment.id @@ -147,16 +148,27 @@ defmodule Cadet.AssessmentsTest do end describe "contest voting" do - test "inserts votes into submission_votes table" do - contest_question = insert(:programming_question) - contest_assessment = contest_question.assessment - course = contest_question.assessment.course + test "inserts votes into submission_votes table if contest has closed" do + course = insert(:course) + config = insert(:assessment_config) + # contest assessment that has closed + closed_contest_assessment = + insert(:assessment, + is_published: true, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: -1), + course: course, + config: config + ) + + contest_question = insert(:programming_question, assessment: closed_contest_assessment) voting_assessment = insert(:assessment, %{course: course}) question = insert(:voting_question, %{ assessment: voting_assessment, - question: build(:voting_question_content, contest_number: contest_assessment.number) + question: + build(:voting_question_content, contest_number: closed_contest_assessment.number) }) students = @@ -202,7 +214,250 @@ defmodule Cadet.AssessmentsTest do # students with own contest submissions will vote for 5 entries # students without own contest submissin will vote for 6 entries - assert length(Repo.all(SubmissionVotes, question_id: question.id)) == 6 * 5 + 6 + assert SubmissionVotes |> where(question_id: ^question.id) |> Repo.all() |> length() == + 6 * 5 + 6 + end + + test "does not insert entries for voting if contest is still open" do + course = insert(:course) + config = insert(:assessment_config) + # contest assessment that is still open + open_contest_assessment = + insert(:assessment, + is_published: true, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: 1), + course: course, + config: config + ) + + contest_question = insert(:programming_question, assessment: open_contest_assessment) + voting_assessment = insert(:assessment, %{course: course}) + + question = + insert(:voting_question, %{ + assessment: voting_assessment, + question: + build(:voting_question_content, contest_number: open_contest_assessment.number) + }) + + students = + insert_list(6, :course_registration, %{ + role: :student, + course: course + }) + + Enum.map(students, fn student -> + submission = + insert(:submission, + student: student, + assessment: contest_question.assessment, + status: "submitted" + ) + + insert(:answer, + answer: %{code: "return 2;"}, + submission: submission, + question: contest_question + ) + end) + + Assessments.insert_voting(course.id, contest_question.assessment.number, question.id) + + # No entries should be released for students to vote on while the contest is still open + assert SubmissionVotes |> where(question_id: ^question.id) |> Repo.all() |> length() == 0 + end + + test "function that checks for closed contests and releases entries into voting pool" do + course = insert(:course) + config = insert(:assessment_config) + # contest assessment that has closed + closed_contest_assessment = + insert(:assessment, + is_published: true, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: -1), + course: course, + config: config + ) + + # contest assessment that is still open + open_contest_assessment = + insert(:assessment, + is_published: true, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: 1), + course: course, + config: config + ) + + # contest assessment that is closed but insert_voting has already been done + compiled_contest_assessment = + insert(:assessment, + is_published: true, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: -1), + course: course, + config: config + ) + + closed_contest_question = + insert(:programming_question, assessment: closed_contest_assessment) + + open_contest_question = insert(:programming_question, assessment: open_contest_assessment) + + compiled_contest_question = + insert(:programming_question, assessment: compiled_contest_assessment) + + closed_voting_assessment = insert(:assessment, %{course: course}) + open_voting_assessment = insert(:assessment, %{course: course}) + compiled_voting_assessment = insert(:assessment, %{course: course}) + # voting assessment that references an invalid contest number + invalid_voting_assessment = insert(:assessment, %{course: course}) + + closed_question = + insert(:voting_question, %{ + assessment: closed_voting_assessment, + question: + build(:voting_question_content, contest_number: closed_contest_assessment.number) + }) + + open_question = + insert(:voting_question, %{ + assessment: open_voting_assessment, + question: + build(:voting_question_content, contest_number: open_contest_assessment.number) + }) + + compiled_question = + insert(:voting_question, %{ + assessment: compiled_voting_assessment, + question: + build(:voting_question_content, contest_number: compiled_contest_assessment.number) + }) + + invalid_question = + insert(:voting_question, %{ + assessment: invalid_voting_assessment, + question: build(:voting_question_content, contest_number: "test_invalid") + }) + + students = + insert_list(10, :course_registration, %{ + role: :student, + course: course + }) + + first_four = Enum.slice(students, 0..3) + last_six = Enum.slice(students, 4..9) + + Enum.map(first_four, fn student -> + submission = + insert(:submission, + student: student, + assessment: compiled_contest_question.assessment, + status: "submitted" + ) + + insert(:answer, + answer: %{code: "return 2;"}, + submission: submission, + question: compiled_contest_question + ) + end) + + # Only the compiled_assessment has already released entries into voting pool + Assessments.insert_voting( + course.id, + compiled_contest_question.assessment.number, + compiled_question.id + ) + + assert SubmissionVotes |> where(question_id: ^closed_question.id) |> Repo.all() |> length() == + 0 + + assert SubmissionVotes |> where(question_id: ^open_question.id) |> Repo.all() |> length() == + 0 + + assert SubmissionVotes + |> where(question_id: ^compiled_question.id) + |> Repo.all() + |> length() == 4 * 3 + 6 * 4 + + assert SubmissionVotes |> where(question_id: ^invalid_question.id) |> Repo.all() |> length() == + 0 + + Enum.map(students, fn student -> + submission = + insert(:submission, + student: student, + assessment: closed_contest_question.assessment, + status: "submitted" + ) + + insert(:answer, + answer: %{code: "return 2;"}, + submission: submission, + question: closed_contest_question + ) + end) + + Enum.map(students, fn student -> + submission = + insert(:submission, + student: student, + assessment: open_contest_question.assessment, + status: "submitted" + ) + + insert(:answer, + answer: %{code: "return 2;"}, + submission: submission, + question: open_contest_question + ) + end) + + Enum.map(last_six, fn student -> + submission = + insert(:submission, + student: student, + assessment: compiled_contest_question.assessment, + status: "submitted" + ) + + insert(:answer, + answer: %{code: "return 2;"}, + submission: submission, + question: compiled_contest_question + ) + end) + + # fetching all unassigned voting questions should only yield open and closed questions + unassigned_voting_questions = Assessments.fetch_unassigned_voting_questions() + assert Enum.count(unassigned_voting_questions) == 2 + + unassigned_voting_question_ids = + Enum.map(unassigned_voting_questions, fn q -> q.question_id end) + + assert closed_question.id in unassigned_voting_question_ids + assert open_question.id in unassigned_voting_question_ids + + Assessments.update_final_contest_entries() + + # only the closed_contest should have been updated + assert SubmissionVotes |> where(question_id: ^closed_question.id) |> Repo.all() |> length() == + 10 * 9 + + assert SubmissionVotes |> where(question_id: ^open_question.id) |> Repo.all() |> length() == + 0 + + assert SubmissionVotes + |> where(question_id: ^compiled_question.id) + |> Repo.all() + |> length() == 4 * 3 + 6 * 4 + + assert SubmissionVotes |> where(question_id: ^invalid_question.id) |> Repo.all() |> length() == + 0 end test "create voting parameters with invalid contest number" do @@ -225,9 +480,19 @@ defmodule Cadet.AssessmentsTest do end test "deletes submission_votes when assessment is deleted" do - contest_question = insert(:programming_question) - course = contest_question.assessment.course - config = contest_question.assessment.config + course = insert(:course) + config = insert(:assessment_config) + # contest assessment that has closed + contest_assessment = + insert(:assessment, + is_published: true, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: -1), + course: course, + config: config + ) + + contest_question = insert(:programming_question, assessment: contest_assessment) voting_assessment = insert(:assessment, %{course: course, config: config}) question = insert(:voting_question, assessment: voting_assessment) students = insert_list(5, :course_registration, %{role: :student, course: course}) @@ -253,6 +518,65 @@ defmodule Cadet.AssessmentsTest do Assessments.delete_assessment(voting_assessment.id) refute Repo.exists?(SubmissionVotes, question_id: question.id) end + + test "does not delete contest assessment if referencing voting assessment is present" do + course = insert(:course) + config = insert(:assessment_config) + + contest_assessment = + insert(:assessment, + is_published: true, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: -1), + course: course, + config: config, + number: "test" + ) + + voting_assessment = insert(:assessment, %{course: course, config: config}) + + # insert voting question that references the contest assessment + _voting_question = + insert(:voting_question, %{ + assessment: voting_assessment, + question: build(:voting_question_content, contest_number: contest_assessment.number) + }) + + error_message = {:bad_request, "Contest voting for this contest is still up"} + + assert {:error, ^error_message} = Assessments.delete_assessment(contest_assessment.id) + # deletion should fail + assert Assessment |> where(id: ^contest_assessment.id) |> Repo.exists?() + end + + test "deletes contest assessment if voting assessment references same number but different course" do + course_1 = insert(:course) + course_2 = insert(:course) + config = insert(:assessment_config) + + contest_assessment = + insert(:assessment, + is_published: true, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: -1), + course: course_1, + config: config, + number: "test" + ) + + voting_assessment = insert(:assessment, %{course: course_2, config: config}) + + # insert voting question from a different course that references the same contest number + _voting_question = + insert(:voting_question, %{ + assessment: voting_assessment, + question: build(:voting_question_content, contest_number: contest_assessment.number) + }) + + assert {:ok, _} = Assessments.delete_assessment(contest_assessment.id) + # deletion should succeed + refute Assessment |> where(id: ^contest_assessment.id) |> Repo.exists?() + end end describe "contest voting leaderboard utility functions" do @@ -323,13 +647,13 @@ defmodule Cadet.AssessmentsTest do top_x_ans = Assessments.fetch_top_relative_score_answers(question_id, 5) - assert get_answer_relative_scores(top_x_ans) == expected_top_relative_scores(5) + assert get_answer_relative_scores(top_x_ans) == expected_top_relative_scores(5, 50) x = 3 top_x_ans = Assessments.fetch_top_relative_score_answers(question_id, x) # verify that top x ans are queried correctly - assert get_answer_relative_scores(top_x_ans) == expected_top_relative_scores(3) + assert get_answer_relative_scores(top_x_ans) == expected_top_relative_scores(3, 50) end end @@ -563,7 +887,7 @@ defmodule Cadet.AssessmentsTest do assert get_answer_relative_scores( Assessments.fetch_top_relative_score_answers(yesterday_question.id, 5) - ) == expected_top_relative_scores(5) + ) == expected_top_relative_scores(5, 50) end test "update_rolling_contest_leaderboards correcly updates leaderboards which voting is active", @@ -585,7 +909,7 @@ defmodule Cadet.AssessmentsTest do assert get_answer_relative_scores( Assessments.fetch_top_relative_score_answers(current_question.id, 5) - ) == expected_top_relative_scores(5) + ) == expected_top_relative_scores(5, 50) end end @@ -1384,11 +1708,11 @@ defmodule Cadet.AssessmentsTest do questions |> Enum.map(fn q -> q.id end) |> Enum.sort() end - defp expected_top_relative_scores(top_x) do + defp expected_top_relative_scores(top_x, token_divider) do # "return 0;" in the factory has 3 token 10..0 |> Enum.to_list() - |> Enum.map(fn score -> 10 * score - :math.pow(2, 3 / 50) end) + |> Enum.map(fn score -> 10 * score - :math.pow(2, 3 / token_divider) end) |> Enum.take(top_x) end end diff --git a/test/cadet/assessments/question_test.exs b/test/cadet/assessments/question_test.exs index 7a77f38fb..34381a5f1 100644 --- a/test/cadet/assessments/question_test.exs +++ b/test/cadet/assessments/question_test.exs @@ -39,7 +39,8 @@ defmodule Cadet.Assessments.QuestionTest do question: %{ content: Faker.Pokemon.name(), contest_number: assessment.number, - reveal_hours: 48 + reveal_hours: 48, + token_divider: 50 } } diff --git a/test/cadet/assessments/question_types/voting_question_test.exs b/test/cadet/assessments/question_types/voting_question_test.exs index b3fd8949e..d714b849b 100644 --- a/test/cadet/assessments/question_types/voting_question_test.exs +++ b/test/cadet/assessments/question_types/voting_question_test.exs @@ -9,7 +9,8 @@ defmodule Cadet.Assessments.QuestionTypes.VotingQuestionTest do %{ content: "content", contest_number: "C4", - reveal_hours: 48 + reveal_hours: 48, + token_divider: 50 }, :valid ) @@ -22,6 +23,26 @@ defmodule Cadet.Assessments.QuestionTypes.VotingQuestionTest do }, :invalid ) + + assert_changeset( + %{ + content: "content", + contest_number: "C3", + reveal_hours: 48, + token_divider: -1 + }, + :invalid + ) + + assert_changeset( + %{ + content: "content", + contest_number: "C6", + reveal_hours: 48, + token_divider: 0 + }, + :invalid + ) end end end diff --git a/test/cadet/auth/guardian_test.exs b/test/cadet/auth/guardian_test.exs index 483adf091..a3b12182e 100644 --- a/test/cadet/auth/guardian_test.exs +++ b/test/cadet/auth/guardian_test.exs @@ -6,23 +6,37 @@ defmodule Cadet.Auth.GuardianTest do test "token subject is user id" do user = insert(:user) - assert Guardian.subject_for_token(user, nil) == {:ok, to_string(user.id)} + + assert Guardian.subject_for_token(user, nil) == + {:ok, + URI.encode_query(%{ + id: user.id, + username: user.username, + provider: user.provider + })} end test "get user from claims" do user = insert(:user) good_claims = %{ - "sub" => to_string(user.id) + # Username and provider are only used for microservices + # The main backend only checks the user ID + "sub" => URI.encode_query(%{id: user.id}) + } + + bad_claims_user_not_found = %{ + "sub" => URI.encode_query(%{id: 2000}) } - bad_claims = %{ - "sub" => "2000" + bad_claims_bad_sub = %{ + "sub" => "bad" } assert Guardian.resource_from_claims(good_claims) == {:ok, remove_preload(user, :latest_viewed_course)} - assert Guardian.resource_from_claims(bad_claims) == {:error, :not_found} + assert Guardian.resource_from_claims(bad_claims_user_not_found) == {:error, :not_found} + assert Guardian.resource_from_claims(bad_claims_bad_sub) == {:error, :bad_request} end end diff --git a/test/cadet/email_test.exs b/test/cadet/email_test.exs index 462daad65..2ee826979 100644 --- a/test/cadet/email_test.exs +++ b/test/cadet/email_test.exs @@ -24,10 +24,8 @@ defmodule Cadet.EmailTest do avenger_user = insert(:user, %{email: "test@gmail.com"}) avenger = insert(:course_registration, %{user: avenger_user, role: :staff}) - ungraded_submissions = - Jason.decode!( - elem(Cadet.Assessments.all_submissions_by_grader_for_index(avenger, true, true), 1) - ) + {:ok, %{submissions: ungraded_submissions}} = + Cadet.Assessments.all_submissions_by_grader_for_index(avenger, true, true) email = Email.avenger_backlog_email("avenger_backlog", avenger_user, ungraded_submissions) diff --git a/test/cadet/jobs/notification_worker/notification_worker_test.exs b/test/cadet/jobs/notification_worker/notification_worker_test.exs index 41606d4ce..626c46cac 100644 --- a/test/cadet/jobs/notification_worker/notification_worker_test.exs +++ b/test/cadet/jobs/notification_worker/notification_worker_test.exs @@ -18,10 +18,8 @@ defmodule Cadet.NotificationWorker.NotificationWorkerTest do submission = List.first(List.first(data.mcq_answers)).submission # setup for avenger backlog - ungraded_submissions = - Jason.decode!( - elem(Cadet.Assessments.all_submissions_by_grader_for_index(avenger_cr, true, true), 1) - ) + {:ok, %{submissions: ungraded_submissions}} = + Cadet.Assessments.all_submissions_by_grader_for_index(avenger_cr, true, true) Repo.update_all(NotificationType, set: [is_enabled: true]) Repo.update_all(NotificationConfig, set: [is_enabled: true]) diff --git a/test/cadet_web/admin_controllers/admin_grading_controller_test.exs b/test/cadet_web/admin_controllers/admin_grading_controller_test.exs index 2543e945d..933d1a245 100644 --- a/test/cadet_web/admin_controllers/admin_grading_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_grading_controller_test.exs @@ -122,6 +122,7 @@ defmodule CadetWeb.AdminGradingControllerTest do "id" => submission.id, "participant" => %{ "name" => submission.student.user.name, + "username" => submission.student.user.username, "id" => submission.student.id, "groupName" => submission.student.group.name, "groupLeaderId" => submission.student.group.leader_id @@ -132,7 +133,8 @@ defmodule CadetWeb.AdminGradingControllerTest do "maxXp" => 5000, "id" => mission.id, "title" => mission.title, - "questionCount" => 5 + "questionCount" => 5, + "assessmentNumber" => mission.number }, "status" => Atom.to_string(submission.status), "gradedCount" => 5, @@ -185,6 +187,7 @@ defmodule CadetWeb.AdminGradingControllerTest do "id" => submission.id, "participant" => %{ "name" => submission.student.user.name, + "username" => submission.student.user.username, "id" => submission.student.id, "groupName" => submission.student.group.name, "groupLeaderId" => submission.student.group.leader_id @@ -195,7 +198,8 @@ defmodule CadetWeb.AdminGradingControllerTest do "maxXp" => 5000, "id" => mission.id, "title" => mission.title, - "questionCount" => 5 + "questionCount" => 5, + "assessmentNumber" => mission.number }, "status" => Atom.to_string(submission.status), "gradedCount" => 5, @@ -291,6 +295,7 @@ defmodule CadetWeb.AdminGradingControllerTest do }, "student" => %{ "name" => &1.submission.student.user.name, + "username" => &1.submission.student.user.username, "id" => &1.submission.student.id }, "team" => %{} @@ -339,6 +344,7 @@ defmodule CadetWeb.AdminGradingControllerTest do }, "student" => %{ "name" => &1.submission.student.user.name, + "username" => &1.submission.student.user.username, "id" => &1.submission.student.id }, "team" => %{} @@ -382,6 +388,7 @@ defmodule CadetWeb.AdminGradingControllerTest do }, "student" => %{ "name" => &1.submission.student.user.name, + "username" => &1.submission.student.user.username, "id" => &1.submission.student.id }, "team" => %{}, @@ -785,6 +792,7 @@ defmodule CadetWeb.AdminGradingControllerTest do "id" => submission.id, "participant" => %{ "name" => submission.student.user.name, + "username" => submission.student.user.username, "id" => submission.student.id, "groupName" => submission.student.group.name, "groupLeaderId" => submission.student.group.leader_id @@ -795,7 +803,8 @@ defmodule CadetWeb.AdminGradingControllerTest do "maxXp" => 5000, "id" => mission.id, "title" => mission.title, - "questionCount" => 5 + "questionCount" => 5, + "assessmentNumber" => mission.number }, "status" => Atom.to_string(submission.status), "gradedCount" => 5, @@ -828,6 +837,7 @@ defmodule CadetWeb.AdminGradingControllerTest do "id" => submission.id, "participant" => %{ "name" => submission.student.user.name, + "username" => submission.student.user.username, "id" => submission.student.id, "groupName" => submission.student.group.name, "groupLeaderId" => submission.student.group.leader_id @@ -838,7 +848,8 @@ defmodule CadetWeb.AdminGradingControllerTest do "maxXp" => 5000, "id" => mission.id, "title" => mission.title, - "questionCount" => 5 + "questionCount" => 5, + "assessmentNumber" => mission.number }, "status" => Atom.to_string(submission.status), "gradedCount" => 5, @@ -934,6 +945,7 @@ defmodule CadetWeb.AdminGradingControllerTest do }, "student" => %{ "name" => &1.submission.student.user.name, + "username" => &1.submission.student.user.username, "id" => &1.submission.student.id }, "team" => %{} @@ -982,6 +994,7 @@ defmodule CadetWeb.AdminGradingControllerTest do }, "student" => %{ "name" => &1.submission.student.user.name, + "username" => &1.submission.student.user.username, "id" => &1.submission.student.id }, "team" => %{} @@ -1025,6 +1038,7 @@ defmodule CadetWeb.AdminGradingControllerTest do }, "student" => %{ "name" => &1.submission.student.user.name, + "username" => &1.submission.student.user.username, "id" => &1.submission.student.id }, "team" => %{}, diff --git a/test/cadet_web/admin_controllers/admin_user_controller_test.exs b/test/cadet_web/admin_controllers/admin_user_controller_test.exs index 1ff900295..786210dec 100644 --- a/test/cadet_web/admin_controllers/admin_user_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_user_controller_test.exs @@ -12,7 +12,7 @@ defmodule CadetWeb.AdminUserControllerTest do test "swagger" do assert is_map(AdminUserController.swagger_definitions()) assert is_map(AdminUserController.swagger_path_index(nil)) - assert is_map(AdminUserController.swagger_path_add_users(nil)) + assert is_map(AdminUserController.swagger_path_upsert_users_and_groups(nil)) assert is_map(AdminUserController.swagger_path_update_role(nil)) assert is_map(AdminUserController.swagger_path_delete_user(nil)) assert is_map(AdminUserController.swagger_path_combined_total_xp(nil)) diff --git a/test/cadet_web/controllers/courses_controller_test.exs b/test/cadet_web/controllers/courses_controller_test.exs index 0cea1fd35..1055045dd 100644 --- a/test/cadet_web/controllers/courses_controller_test.exs +++ b/test/cadet_web/controllers/courses_controller_test.exs @@ -10,7 +10,7 @@ defmodule CadetWeb.CoursesControllerTest do test "swagger" do CoursesController.swagger_definitions() - CoursesController.swagger_path_get_course_config(nil) + CoursesController.swagger_path_index(nil) CoursesController.swagger_path_create(nil) end diff --git a/test/factories/assessments/question_factory.ex b/test/factories/assessments/question_factory.ex index 9836d1047..cfd088dd1 100644 --- a/test/factories/assessments/question_factory.ex +++ b/test/factories/assessments/question_factory.ex @@ -93,7 +93,8 @@ defmodule Cadet.Assessments.QuestionFactory do prepend: Faker.Pokemon.location(), template: Faker.Lorem.Shakespeare.as_you_like_it(), contest_number: contest_assessment.number, - reveal_hours: 48 + reveal_hours: 48, + token_divider: 50 } } end @@ -106,7 +107,8 @@ defmodule Cadet.Assessments.QuestionFactory do prepend: Faker.Pokemon.location(), template: Faker.Lorem.Shakespeare.as_you_like_it(), contest_number: contest_assessment.number, - reveal_hours: 48 + reveal_hours: 48, + token_divider: 50 } end end diff --git a/test/support/xml_generator.ex b/test/support/xml_generator.ex index e028b31b7..8782d32a4 100644 --- a/test/support/xml_generator.ex +++ b/test/support/xml_generator.ex @@ -157,7 +157,8 @@ defmodule Cadet.Test.XMLGenerator do voting_field = voting(%{ reveal_hours: question.question.reveal_hours, - assessment_number: question.question.contest_number + assessment_number: question.question.contest_number, + token_divider: question.question.token_divider }) [ @@ -167,7 +168,7 @@ defmodule Cadet.Test.XMLGenerator do end defp voting(raw_attr) do - {"VOTING", map_permit_keys(raw_attr, ~w(assessment_number reveal_hours)a)} + {"VOTING", map_permit_keys(raw_attr, ~w(assessment_number reveal_hours token_divider)a)} end defp deployment(raw_attrs, children) do From 07685ae16d84c35070b97f6c0b552a668665e378 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Thu, 4 Jan 2024 17:00:34 +0800 Subject: [PATCH 068/128] Merge conflict --- lib/cadet/assessments/assessments.ex | 22 ++++++++++++++++++---- mix.lock | 8 ++++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 94ad5bad5..8ab8d274e 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -1340,21 +1340,35 @@ defmodule Cadet.Assessments do The return value is {:ok, submissions} if no errors, else it is {:error, {:unauthorized, "Forbidden."}} """ + # @spec all_submissions_by_grader_for_index(CourseRegistration.t()) :: + # {:ok, %{:assessments => [any()], :submissions => [any()], :users => [any()]}} @spec all_submissions_by_grader_for_index(CourseRegistration.t()) :: - {:ok, %{:assessments => [any()], :submissions => [any()], :users => [any()]}} + {:ok, String.t()} def all_submissions_by_grader_for_index( grader = %CourseRegistration{course_id: course_id}, group_only \\ false, - _ungraded_only \\ false + ungraded_only \\ false ) do show_all = not group_only - group_filter = + # group_filter = + # if show_all, + # do: "", + # else: + # "AND s.student_id IN (SELECT cr.id FROM course_registrations AS cr INNER JOIN groups AS g ON cr.group_id = g.id WHERE g.leader_id = #{grader.id}) OR s.student_id = #{grader.id}" + + group_where = if show_all, do: "", else: - "AND s.student_id IN (SELECT cr.id FROM course_registrations AS cr INNER JOIN groups AS g ON cr.group_id = g.id WHERE g.leader_id = #{grader.id}) OR s.student_id = #{grader.id}" + "where s.student_id in (select cr.id from course_registrations cr inner join groups g on cr.group_id = g.id where g.leader_id = $2) or s.student_id = $2" + + ungraded_where = + if ungraded_only, + do: "where s.\"gradedCount\" < assts.\"questionCount\"", + else: "" + params = if show_all, do: [course_id], else: [course_id, grader.id] # TODO: Restore ungraded filtering # ... or more likely, decouple email logic from this function # ungraded_where = diff --git a/mix.lock b/mix.lock index c80106212..51af0f5ca 100644 --- a/mix.lock +++ b/mix.lock @@ -6,7 +6,7 @@ "bamboo_phoenix": {:hex, :bamboo_phoenix, "1.0.0", "f3cc591ffb163ed0bf935d256f1f4645cd870cf436545601215745fb9cc9953f", [:mix], [{:bamboo, ">= 2.0.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.3.0", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "6db88fbb26019c84a47994bb2bd879c0887c29ce6c559bc6385fd54eb8b37dee"}, "bamboo_ses": {:hex, :bamboo_ses, "0.4.2", "e148a0ae17f8223b830029c2e81b2ba18220aa7378531ef1f50c4212fbd9ddb1", [:mix], [{:bamboo, "~> 2.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:ex_aws, "~> 2.4.1", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:gen_smtp, "~> 1.2.0", [hex: :gen_smtp, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "feb609b57316d335b217937f66cfc7c1ebe37ec481bebe97fcd5da5f31171808"}, "blankable": {:hex, :blankable, "1.0.0", "89ab564a63c55af117e115144e3b3b57eb53ad43ba0f15553357eb283e0ed425", [:mix], [], "hexpm", "7cf11aac0e44f4eedbee0c15c1d37d94c090cb72a8d9fddf9f7aec30f9278899"}, - "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, + "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, "castore": {:hex, :castore, "1.0.4", "ff4d0fb2e6411c0479b1d965a814ea6d00e51eb2f58697446e9c41a97d940b28", [:mix], [], "hexpm", "9418c1b8144e11656f0be99943db4caf04612e3eaecefb5dae9a2a87565584f8"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, @@ -17,7 +17,7 @@ "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, - "credo": {:hex, :credo, "1.7.1", "6e26bbcc9e22eefbff7e43188e69924e78818e2fe6282487d0703652bc20fd62", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e9871c6095a4c0381c89b6aa98bc6260a8ba6addccf7f6a53da8849c748a58a2"}, + "credo": {:hex, :credo, "1.7.2", "fdee3a7cb553d8f2e773569181f0a4a2bb7d192e27e325404cc31b354f59d68c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dd15d6fbc280f6cf9b269f41df4e4992dee6615939653b164ef951f60afcb68e"}, "crontab": {:hex, :crontab, "1.1.13", "3bad04f050b9f7f1c237809e42223999c150656a6b2afbbfef597d56df2144c5", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "d67441bec989640e3afb94e123f45a2bc42d76e02988c9613885dc3d01cf7085"}, "csv": {:hex, :csv, "3.2.1", "6d401f1ed33acb2627682a9ab6021e96d33ca6c1c6bccc243d8f7e2197d032f5", [:mix], [], "hexpm", "8f55a0524923ae49e97ff2642122a2ce7c61e159e7fe1184670b2ce847aee6c8"}, "db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"}, @@ -43,7 +43,7 @@ "expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"}, "exvcr": {:hex, :exvcr, "0.15.0", "432a4f4b94494f996c96dd2b9b9d3306b70db269ddbdeb9e324a4371f62ce32d", [:mix], [{:exactor, "~> 2.2", [hex: :exactor, repo: "hexpm", optional: false]}, {:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:finch, "~> 0.16", [hex: :finch, repo: "hexpm", optional: true]}, {:httpoison, "~> 1.0 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.1", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "8b7e451f5fd37d1dc1252d08e55291fcb80b55b00cfd84ea41bf64be23cb142c"}, "faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"}, - "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, "gen_smtp": {:hex, :gen_smtp, "1.2.0", "9cfc75c72a8821588b9b9fe947ae5ab2aed95a052b81237e0928633a13276fd3", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "5ee0375680bca8f20c4d85f58c2894441443a743355430ff33a783fe03296779"}, "gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"}, "gettext": {:hex, :gettext, "0.22.2", "6bfca374de34ecc913a28ba391ca184d88d77810a3e427afa8454a71a51341ac", [:mix], [{:expo, "~> 0.4.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "8a2d389673aea82d7eae387e6a2ccc12660610080ae7beb19452cfdc1ec30f60"}, @@ -76,7 +76,7 @@ "phoenix_swagger": {:hex, :phoenix_swagger, "0.8.3", "298d6204802409d3b0b4fc1013873839478707cf3a62532a9e10fec0e26d0e37", [:mix], [{:ex_json_schema, "~> 0.7.1", [hex: :ex_json_schema, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "3bc0fa9f5b679b8a61b90a52b2c67dd932320e9a84a6f91a4af872a0ab367337"}, "phoenix_template": {:hex, :phoenix_template, "1.0.3", "32de561eefcefa951aead30a1f94f1b5f0379bc9e340bb5c667f65f1edfa4326", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "16f4b6588a4152f3cc057b9d0c0ba7e82ee23afa65543da535313ad8d25d8e2c"}, "phoenix_view": {:hex, :phoenix_view, "2.0.3", "4d32c4817fce933693741deeb99ef1392619f942633dde834a5163124813aad3", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "cd34049af41be2c627df99cd4eaa71fc52a328c0c3d8e7d4aa28f880c30e7f64"}, - "plug": {:hex, :plug, "1.15.1", "b7efd81c1a1286f13efb3f769de343236bd8b7d23b4a9f40d3002fc39ad8f74c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "459497bd94d041d98d948054ec6c0b76feacd28eec38b219ca04c0de13c79d30"}, + "plug": {:hex, :plug, "1.15.2", "94cf1fa375526f30ff8770837cb804798e0045fd97185f0bb9e5fcd858c792a3", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02731fa0c2dcb03d8d21a1d941bdbbe99c2946c0db098eee31008e04c6283615"}, "plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, "postgrex": {:hex, :postgrex, "0.17.4", "5777781f80f53b7c431a001c8dad83ee167bcebcf3a793e3906efff680ab62b3", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "6458f7d5b70652bc81c3ea759f91736c16a31be000f306d3c64bcdfe9a18b3cc"}, From e8be3501585a9945769f65623c1f61b21b232a0e Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Mon, 8 Jan 2024 18:34:55 +0800 Subject: [PATCH 069/128] Retrieve Team Submission Details --- lib/cadet/assessments/assessments.ex | 206 ++++++++---------- .../admin_views/admin_grading_view.ex | 21 +- 2 files changed, 113 insertions(+), 114 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 8ab8d274e..ed47ef08e 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -1340,35 +1340,22 @@ defmodule Cadet.Assessments do The return value is {:ok, submissions} if no errors, else it is {:error, {:unauthorized, "Forbidden."}} """ - # @spec all_submissions_by_grader_for_index(CourseRegistration.t()) :: - # {:ok, %{:assessments => [any()], :submissions => [any()], :users => [any()]}} @spec all_submissions_by_grader_for_index(CourseRegistration.t()) :: - {:ok, String.t()} + {:ok, %{:assessments => [any()], :submissions => [any()], :users => [any()], + :teams => [any()], :team_members => [any()]}} def all_submissions_by_grader_for_index( grader = %CourseRegistration{course_id: course_id}, group_only \\ false, - ungraded_only \\ false + _ungraded_only \\ false ) do show_all = not group_only - # group_filter = - # if show_all, - # do: "", - # else: - # "AND s.student_id IN (SELECT cr.id FROM course_registrations AS cr INNER JOIN groups AS g ON cr.group_id = g.id WHERE g.leader_id = #{grader.id}) OR s.student_id = #{grader.id}" - - group_where = + group_filter = if show_all, do: "", else: - "where s.student_id in (select cr.id from course_registrations cr inner join groups g on cr.group_id = g.id where g.leader_id = $2) or s.student_id = $2" - - ungraded_where = - if ungraded_only, - do: "where s.\"gradedCount\" < assts.\"questionCount\"", - else: "" + "AND s.student_id IN (SELECT cr.id FROM course_registrations AS cr INNER JOIN groups AS g ON cr.group_id = g.id WHERE g.leader_id = #{grader.id}) OR s.student_id = #{grader.id}" - params = if show_all, do: [course_id], else: [course_id, grader.id] # TODO: Restore ungraded filtering # ... or more likely, decouple email logic from this function # ungraded_where = @@ -1379,104 +1366,97 @@ defmodule Cadet.Assessments do # We bypass Ecto here and use a raw query to generate JSON directly from # PostgreSQL, because doing it in Elixir/Erlang is too inefficient. - case Repo.query( - """ - SELECT json_agg(q)::TEXT FROM ( - SELECT - s.id, - s.status, - s."unsubmittedAt", - s.xp, - s."xpAdjustment", - s."xpBonus", - s."gradedCount", - assts.jsn as assessment, - CASE - WHEN s.student_id IS NOT NULL THEN students.jsn - ELSE to_json(team) - END AS participant, - unsubmitters.jsn as "unsubmittedBy" - FROM ( - SELECT - s.id, - s.student_id, - s.team_id, - s.assessment_id, - s.status, - s.unsubmitted_at as "unsubmittedAt", - s.unsubmitted_by_id, - sum(ans.xp) as xp, - sum(ans.xp_adjustment) as "xpAdjustment", - s.xp_bonus as "xpBonus", - count(ans.id) FILTER (WHERE ans.grader_id IS NOT NULL) as "gradedCount" - FROM submissions s - LEFT JOIN answers ans ON s.id = ans.submission_id - #{group_where} - GROUP BY s.id - ) s - INNER JOIN ( - SELECT - a.id, a."questionCount", to_json(a) as jsn - FROM ( - SELECT - a.id, - a.title, - bool_or(ac.is_manually_graded) as "isManuallyGraded", - max(ac.type) as "type", - sum(q.max_xp) as "maxXp", - count(q.id) as "questionCount" - FROM assessments a - LEFT JOIN questions q ON a.id = q.assessment_id - INNER JOIN assessment_configs ac ON ac.id = a.config_id - WHERE a.course_id = $1 - GROUP BY a.id - ) a - ) assts ON assts.id = s.assessment_id + submissions = + case Repo.query(""" + SELECT + s.id, + s.status, + s.unsubmitted_at, + s.unsubmitted_by_id, + s_ans.xp, + s_ans.xp_adjustment, + s.xp_bonus, + s_ans.graded_count, + s.student_id, + s.team_id, + s.assessment_id + FROM + submissions AS s LEFT JOIN ( SELECT - cr.id, to_json(cr) as jsn - FROM ( - SELECT - cr.id, - u.name as "name", - g.name as "groupName", - g.leader_id as "groupLeaderId" - FROM course_registrations cr - LEFT JOIN groups g ON g.id = cr.group_id - INNER JOIN users u ON u.id = cr.user_id - ) cr - ) students ON students.id = s.student_id - LEFT JOIN ( - SELECT - t.id, - to_json(t) as jsn, - array_agg(cr.id) as student_ids, - array_agg(u.name) as student_names - FROM teams t - LEFT JOIN team_members tm ON t.id = tm.team_id - LEFT JOIN course_registrations cr ON cr.id = tm.student_id - LEFT JOIN users u ON u.id = cr.user_id - GROUP BY t.id - ) team ON team.id = s.team_id - LEFT JOIN ( + ans.submission_id, + SUM(ans.xp) AS xp, + SUM(ans.xp_adjustment) AS xp_adjustment, + COUNT(ans.id) FILTER ( + WHERE + ans.grader_id IS NOT NULL + ) AS graded_count + FROM + answers AS ans + GROUP BY + ans.submission_id + ) AS s_ans ON s_ans.submission_id = s.id + WHERE + s.assessment_id IN ( SELECT - cr.id, to_json(cr) as jsn - FROM ( - SELECT - cr.id, - u.name - FROM course_registrations cr - INNER JOIN users u ON u.id = cr.user_id - ) cr - ) unsubmitters ON s.unsubmitted_by_id = unsubmitters.id - #{ungraded_where} - ) q - """, - params - ) do - {:ok, %{rows: [[nil]]}} -> {:ok, "[]"} - {:ok, %{rows: [[json]]}} -> {:ok, json} - end + id + FROM + assessments + WHERE + assessments.course_id = #{course_id} + ) #{group_filter}; + """) do + {:ok, %{columns: columns, rows: result}} -> + result + |> Enum.map( + &(columns + |> Enum.map(fn c -> String.to_atom(c) end) + |> Enum.zip(&1) + |> Enum.into(%{})) + ) + end + + {:ok, generate_grading_summary_view_model(submissions, course_id)} + end + + defp generate_grading_summary_view_model(submissions, course_id) do + users = + CourseRegistration + |> where([cr], cr.course_id == ^course_id) + |> join(:inner, [cr], u in assoc(cr, :user)) + |> join(:left, [cr, u], g in assoc(cr, :group)) + |> preload([cr, u, g], user: u, group: g) + |> Repo.all() + + assessment_ids = submissions |> Enum.map(& &1.assessment_id) |> Enum.uniq() + + assessments = + Assessment + |> where([a], a.id in ^assessment_ids) + |> join(:left, [a], q in assoc(a, :questions)) + |> join(:inner, [a], ac in assoc(a, :config)) + |> preload([a, q, ac], questions: q, config: ac) + |> Repo.all() + + team_ids = submissions |> Enum.map(& &1.team_id) |> Enum.uniq() + + teams = + Team + |> where([t], t.id in ^team_ids) + |> Repo.all() + + team_members = + TeamMember + |> where([tm], tm.team_id in ^team_ids) + |> Repo.all() + + %{ + users: users, + assessments: assessments, + submissions: submissions, + teams: teams, + team_members: team_members + } end @spec get_answers_in_submission(integer() | String.t()) :: diff --git a/lib/cadet_web/admin_views/admin_grading_view.ex b/lib/cadet_web/admin_views/admin_grading_view.ex index 8bfd1e3a6..85b732fdd 100644 --- a/lib/cadet_web/admin_views/admin_grading_view.ex +++ b/lib/cadet_web/admin_views/admin_grading_view.ex @@ -10,11 +10,18 @@ defmodule CadetWeb.AdminGradingView do def render("gradingsummaries.json", %{ users: users, assessments: assessments, - submissions: submissions + submissions: submissions, + teams: teams, + team_members: team_members }) do for submission <- submissions do user = users |> Enum.find(&(&1.id == submission.student_id)) assessment = assessments |> Enum.find(&(&1.id == submission.assessment_id)) + team = teams |> Enum.find(&(&1.id == submission.team_id)) + team_members = team_members |> Enum.filter(&(&1.team_id == submission.team_id)) + team_member_users = team_members |> Enum.map(fn team_member -> + users |> Enum.find(&(&1.id == team_member.student_id)) + end) render( CadetWeb.AdminGradingView, @@ -23,6 +30,8 @@ defmodule CadetWeb.AdminGradingView do user: user, assessment: assessment, submission: submission, + team: team, + team_members: team_member_users, unsubmitter: case submission.unsubmitted_by_id do nil -> nil @@ -37,6 +46,8 @@ defmodule CadetWeb.AdminGradingView do user: user, assessment: a, submission: s, + team: team, + team_members: team_members, unsubmitter: unsubmitter }) do s @@ -57,6 +68,7 @@ defmodule CadetWeb.AdminGradingView do assessment: render_one(a, CadetWeb.AdminGradingView, "gradingsummaryassessment.json", as: :assessment), student: render_one(user, CadetWeb.AdminGradingView, "gradingsummaryuser.json", as: :cr), + team: render_one(team, CadetWeb.AdminGradingView, "gradingsummaryteam.json", as: :team, assigns: %{team_members: team_members}), unsubmittedBy: case unsubmitter do nil -> nil @@ -77,6 +89,13 @@ defmodule CadetWeb.AdminGradingView do } end + def render("gradingsummaryteam.json", %{team: team, assigns: %{team_members: team_members}}) do + %{ + id: team.id, + team_members: render_many(team_members, CadetWeb.AdminGradingView, "gradingsummaryuser.json", as: :cr) + } + end + def render("gradingsummaryuser.json", %{cr: cr}) do %{ id: cr.id, From dd413608a153aed253d6383dc20944545f3ee43a Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Thu, 11 Jan 2024 11:07:40 +0800 Subject: [PATCH 070/128] Fix failed test cases --- lib/cadet/assessments/assessments.ex | 18 ++++++++++++++--- lib/cadet/assessments/submission.ex | 7 ++++--- test/cadet/assessments/submission_test.exs | 11 +++++++--- .../admin_assessments_controller_test.exs | 4 ++-- .../admin_grading_controller_test.exs | 20 +++++++++++-------- 5 files changed, 41 insertions(+), 19 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index ed47ef08e..fe500574e 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -841,9 +841,21 @@ defmodule Cadet.Assessments do where: tm.student_id == ^cr_id, limit: 1 ) - case Repo.one(query) do - nil -> {:error, :team_not_found} - team -> {:ok, team} + assessment_team_size = + Repo.one( + from(a in Assessment, where: a.id == ^assessment_id, select: %{max_team_size: a.max_team_size}) + ) + |> Map.get(:max_team_size, 0) + + case assessment_team_size > 1 do + true -> + case Repo.one(query) do + nil -> {:error, :team_not_found} + team -> {:ok, team} + end + # team is nil for individual assessments + false -> + {:ok, nil} end end diff --git a/lib/cadet/assessments/submission.ex b/lib/cadet/assessments/submission.ex index 7bb6e472f..6d16666ff 100644 --- a/lib/cadet/assessments/submission.ex +++ b/lib/cadet/assessments/submission.ex @@ -24,7 +24,7 @@ defmodule Cadet.Assessments.Submission do belongs_to(:unsubmitted_by, CourseRegistration) has_many(:answers, Answer, on_delete: :delete_all) - has_one(:notification, Notification, on_delete: :delete_all) + # has_one(:notification, Notification, on_delete: :delete_all) timestamps() end @@ -34,17 +34,18 @@ defmodule Cadet.Assessments.Submission do :status ] - @optional_fields ~w(xp_bonus unsubmitted_by_id unsubmitted_at)a + @optional_fields ~w(xp_bonus unsubmitted_by_id unsubmitted_at student_id team_id)a def changeset(submission, params) do submission |> cast(params, @required_fields ++ @optional_fields) |> validate_number(:xp_bonus, greater_than_or_equal_to: 0) |> add_belongs_to_id_from_model([:team, :student, :assessment, :unsubmitted_by], params) - |> validate_xor_relationship() + |> validate_xor_relationship |> validate_required(@required_fields) |> foreign_key_constraint(:assessment_id) |> foreign_key_constraint(:unsubmitted_by_id) + |> foreign_key_constraint(:student_id) end diff --git a/test/cadet/assessments/submission_test.exs b/test/cadet/assessments/submission_test.exs index bcfcc4a78..43190c4fe 100644 --- a/test/cadet/assessments/submission_test.exs +++ b/test/cadet/assessments/submission_test.exs @@ -10,15 +10,20 @@ defmodule Cadet.Assessments.SubmissionTest do config = insert(:assessment_config, %{course: course}) assessment = insert(:assessment, %{config: config, course: course}) student = insert(:course_registration, %{course: course, role: :student}) - + team = nil valid_params = %{student_id: student.id, assessment_id: assessment.id} - {:ok, %{assessment: assessment, student: student, valid_params: valid_params}} + {:ok, %{assessment: assessment, student: student, team: team, valid_params: valid_params}} end describe "Changesets" do test "valid params", %{valid_params: params} do - assert_changeset_db(params, :valid) + IO.inspect("params") + IO.inspect(params) + + params + # |> Map.put(:team_id, nil) + |> assert_changeset_db(:valid) end test "converts valid params with models into ids", %{assessment: assessment, student: student} do diff --git a/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs b/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs index 85792f1e1..a7c91ce4e 100644 --- a/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs @@ -83,7 +83,7 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do "type" => &1.config.type, "isManuallyGraded" => &1.config.is_manually_graded, "coverImage" => &1.cover_picture, - "maxTeamSize" => 0, + "maxTeamSize" => 1, "maxXp" => 4800, "status" => get_assessment_status(view_as, &1), "private" => false, @@ -130,7 +130,7 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do "type" => &1.config.type, "isManuallyGraded" => &1.config.is_manually_graded, "coverImage" => &1.cover_picture, - "maxTeamSize" => 0, + "maxTeamSize" => 1, "maxXp" => 4800, "status" => get_assessment_status(view_as, &1), "private" => false, diff --git a/test/cadet_web/admin_controllers/admin_grading_controller_test.exs b/test/cadet_web/admin_controllers/admin_grading_controller_test.exs index 933d1a245..a4cb37f45 100644 --- a/test/cadet_web/admin_controllers/admin_grading_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_grading_controller_test.exs @@ -120,7 +120,7 @@ defmodule CadetWeb.AdminGradingControllerTest do "xpAdjustment" => -2500, "xpBonus" => 100, "id" => submission.id, - "participant" => %{ + "student" => %{ "name" => submission.student.user.name, "username" => submission.student.user.username, "id" => submission.student.id, @@ -139,7 +139,8 @@ defmodule CadetWeb.AdminGradingControllerTest do "status" => Atom.to_string(submission.status), "gradedCount" => 5, "unsubmittedBy" => nil, - "unsubmittedAt" => nil + "unsubmittedAt" => nil, + "team" => nil } end) @@ -185,7 +186,7 @@ defmodule CadetWeb.AdminGradingControllerTest do "xpAdjustment" => -2500, "xpBonus" => 100, "id" => submission.id, - "participant" => %{ + "student" => %{ "name" => submission.student.user.name, "username" => submission.student.user.username, "id" => submission.student.id, @@ -204,7 +205,8 @@ defmodule CadetWeb.AdminGradingControllerTest do "status" => Atom.to_string(submission.status), "gradedCount" => 5, "unsubmittedBy" => nil, - "unsubmittedAt" => nil + "unsubmittedAt" => nil, + "team" => nil } end) @@ -790,7 +792,7 @@ defmodule CadetWeb.AdminGradingControllerTest do "xpAdjustment" => -2500, "xpBonus" => 100, "id" => submission.id, - "participant" => %{ + "student" => %{ "name" => submission.student.user.name, "username" => submission.student.user.username, "id" => submission.student.id, @@ -809,7 +811,8 @@ defmodule CadetWeb.AdminGradingControllerTest do "status" => Atom.to_string(submission.status), "gradedCount" => 5, "unsubmittedBy" => nil, - "unsubmittedAt" => nil + "unsubmittedAt" => nil, + "team" => nil } end) @@ -835,7 +838,7 @@ defmodule CadetWeb.AdminGradingControllerTest do "xpAdjustment" => -2500, "xpBonus" => 100, "id" => submission.id, - "participant" => %{ + "student" => %{ "name" => submission.student.user.name, "username" => submission.student.user.username, "id" => submission.student.id, @@ -854,7 +857,8 @@ defmodule CadetWeb.AdminGradingControllerTest do "status" => Atom.to_string(submission.status), "gradedCount" => 5, "unsubmittedBy" => nil, - "unsubmittedAt" => nil + "unsubmittedAt" => nil, + "team" => nil } end) From 93d68c5de3792a3de944f147c83470444dea73d7 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 12 Jan 2024 14:55:37 +0800 Subject: [PATCH 071/128] Add test cases --- test/cadet/assessments/assessment_test.exs | 19 +++++++++++++++++++ test/cadet/assessments/submission_test.exs | 4 ---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/test/cadet/assessments/assessment_test.exs b/test/cadet/assessments/assessment_test.exs index 5c52e2ed7..e7416e995 100644 --- a/test/cadet/assessments/assessment_test.exs +++ b/test/cadet/assessments/assessment_test.exs @@ -126,5 +126,24 @@ defmodule Cadet.Assessments.AssessmentTest do assert changeset.errors == [{:open_at, {"Open date must be before close date", []}}] refute changeset.valid? end + + test "invalid changeset with invalid team size", %{ + course1: course1, + config1: config1, + } do + assert_changeset( + %{ + config_id: config1.id, + course_id: course1.id, + title: "mission", + number: "M#{Enum.random(0..10)}", + max_team_size: -1, + open_at: Timex.now() |> Timex.to_unix() |> Integer.to_string(), + close_at: Timex.now() |> Timex.shift(days: 7) |> Timex.to_unix() |> Integer.to_string() + }, + :valid + ) + + end end end diff --git a/test/cadet/assessments/submission_test.exs b/test/cadet/assessments/submission_test.exs index 43190c4fe..9e99acf77 100644 --- a/test/cadet/assessments/submission_test.exs +++ b/test/cadet/assessments/submission_test.exs @@ -18,11 +18,7 @@ defmodule Cadet.Assessments.SubmissionTest do describe "Changesets" do test "valid params", %{valid_params: params} do - IO.inspect("params") - IO.inspect(params) - params - # |> Map.put(:team_id, nil) |> assert_changeset_db(:valid) end From b154ab7ce44107321eafa01c8dca1bc559d421c5 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Mon, 15 Jan 2024 17:41:36 +0800 Subject: [PATCH 072/128] Team Member Factory --- test/factories/accounts/team_member_factory.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/factories/accounts/team_member_factory.ex b/test/factories/accounts/team_member_factory.ex index 2c799affd..4e48c18e0 100644 --- a/test/factories/accounts/team_member_factory.ex +++ b/test/factories/accounts/team_member_factory.ex @@ -10,6 +10,8 @@ defmodule Cadet.Accounts.TeamMemberFactory do def team_member_factory do %TeamMember{ + student: build(:course_registration), + team: build(:team) } end From 4eb59dad3e5d7a4e73817faa709505a9079ec09c Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Mon, 15 Jan 2024 17:51:16 +0800 Subject: [PATCH 073/128] add team tests --- test/cadet/accounts/teams_test.exs | 289 +++++++++++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 test/cadet/accounts/teams_test.exs diff --git a/test/cadet/accounts/teams_test.exs b/test/cadet/accounts/teams_test.exs new file mode 100644 index 000000000..a728d82ad --- /dev/null +++ b/test/cadet/accounts/teams_test.exs @@ -0,0 +1,289 @@ +defmodule Cadet.Accounts.TeamTest do + use Cadet.DataCase + alias Cadet.Accounts.{Teams, Team, TeamMember, User, CourseRegistration, CourseRegistrations, Notification} + alias Cadet.Assessments.{Assessment, Submission} + alias Cadet.Repo + + setup do + user1 = insert(:user, %{name: "user 1"}) + user2 = insert(:user, %{name: "user 2"}) + user3 = insert(:user, %{name: "user 3"}) + course1 = insert(:course, %{course_short_name: "course 1"}) + course2 = insert(:course, %{course_short_name: "course 2"}) + assessment1 = insert(:assessment, %{title: "A1", max_team_size: 3, course: course1}) + assessment2 = insert(:assessment, %{title: "A2", max_team_size: 2, course: course1}) + + + {:ok, %{ + user1: user1, + user2: user2, + user3: user3, + course1: course1, + course2: course2, + assessment1: assessment1, + assessment2: assessment2 + }} + end + + test "creating a new team with valid attributes", %{user1: user1, user2: user2, assessment1: assessment1, course1: course1} do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + attrs = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id}], + ] + } + + assert {:ok, team} = Teams.create_team(attrs) + + team_members = TeamMember + |> where([tm], tm.team_id == ^team.id) + |> Repo.all() + + assert length(team_members) == 2 + + end + + test "creating a new team with duplicate students in the one row", %{user1: user1, assessment1: assessment1, course1: course1} do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + attrs = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id},%{"userId" => course_reg1.id}], + ] + } + + result = Teams.create_team(attrs) + assert result == {:error, {:conflict, "One or more students appear multiple times in a team!"}} + end + + test "creating a new team with duplicate students across the teams but not in one row", %{user1: user1, user2: user2, user3: user3, assessment1: assessment1, course1: course1} do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + {:ok, course_reg3} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user3.id, + course_id: course1.id, + role: :student + }) + attrs = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id}], + [%{"userId" => course_reg2.id},%{"userId" => course_reg3.id}], + ] + } + + result = Teams.create_team(attrs) + assert result == {:error, {:conflict, "One or more students appear multiple times in a team!"}} + end + + test "creating a team with students already in another team for the same assessment", %{user1: user1, user2: user2, user3: user3, assessment1: assessment1, course1: course1} do + + {:ok, course_reg1} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user1.id, course_id: course1.id, role: :student}) + {:ok, course_reg2} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user2.id, course_id: course1.id, role: :student}) + {:ok, course_reg3} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user3.id, course_id: course1.id, role: :student}) + + attrs_valid = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id}], + ] + } + + assert {:ok, team} = Teams.create_team(attrs_valid) + + attrs_invalid = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id},%{"userId" => course_reg3.id}], + ] + } + + result = Teams.create_team(attrs_invalid) + assert result == {:error, {:conflict, "One or more students already in a team for this assessment!"}} + + end + + + test "creating a team with students exceeding the maximum team size", %{user1: user1, user2: user2, user3: user3, assessment2: assessment2, course1: course1} do + + {:ok, course_reg1} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user1.id, course_id: course1.id, role: :student}) + {:ok, course_reg2} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user2.id, course_id: course1.id, role: :student}) + {:ok, course_reg3} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user3.id, course_id: course1.id, role: :student}) + + attrs_invalid = %{ + "assessment_id" => assessment2.id, + "student_ids" => [ + [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id},%{"userId" => course_reg3.id}], + ] + } + + result = Teams.create_team(attrs_invalid) + assert result == {:error, {:conflict, "One or more teams exceed the maximum team size!"}} + + end + + test "inserting a team with non-exisiting student", %{user1: user1, user2: user2, assessment1: assessment1, course1: course1} do + + {:ok, course_reg1} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user1.id, course_id: course1.id, role: :student}) + {:ok, course_reg2} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user2.id, course_id: course1.id, role: :student}) + + attrs_invalid = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id},%{"userId" => 99999}], + ] + } + + result = Teams.create_team(attrs_invalid) + assert result == {:error, {:conflict, "One or more students not enrolled in this course!"}} + end + + test "inserting a team with an exisiting student but not enrolled in this course", %{user1: user1, user2: user2, assessment1: assessment1, course1: course1, course2: course2} do + + {:ok, course_reg1} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user1.id, course_id: course1.id, role: :student}) + {:ok, course_reg2} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user2.id, course_id: course2.id, role: :student}) + + attrs_invalid = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id}], + ] + } + + result = Teams.create_team(attrs_invalid) + assert result == {:error, {:conflict, "One or more students not enrolled in this course!"}} + end + + test "update an existing team with valid new team members", %{user1: user1, user2: user2, user3: user3, assessment1: assessment1, course1: course1} do + {:ok, course_reg1} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user1.id, course_id: course1.id, role: :student}) + {:ok, course_reg2} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user2.id, course_id: course1.id, role: :student}) + {:ok, course_reg3} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user3.id, course_id: course1.id, role: :student}) + + attrs = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id}], + ] + } + new_ids = [[%{"userId" => course_reg1.id},%{"userId" => course_reg2.id},%{"userId" => course_reg3.id}]] + assert {:ok, team} = Teams.create_team(attrs) + team = Repo.preload(team, :team_members) + assert {:ok, team} = Teams.update_team(team, team.assessment_id, new_ids) + + team_members = TeamMember + |> where([tm], tm.team_id == ^team.id) + |> Repo.all() + + assert length(team_members) == 3 + end + + test "update an existing team with new team members who are already in another team", %{user1: user1, user2: user2, user3: user3, assessment1: assessment1, course1: course1} do + {:ok, course_reg1} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user1.id, course_id: course1.id, role: :student}) + {:ok, course_reg2} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user2.id, course_id: course1.id, role: :student}) + {:ok, course_reg3} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user3.id, course_id: course1.id, role: :student}) + + attrs1 = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id}], + ] + } + + attrs2 = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg3.id}], + ] + } + new_ids = [[%{"userId" => course_reg1.id},%{"userId" => course_reg2.id},%{"userId" => course_reg3.id}]] + assert {:ok, team1} = Teams.create_team(attrs1) + assert {:ok, team2} = Teams.create_team(attrs2) + team1 = Repo.preload(team1, :team_members) + + result = Teams.update_team(team1, team1.assessment_id, new_ids) + assert result == {:error, {:conflict, "One or more students are already in another team for the same assessment!"}} + end + + test "delete an existing team", %{user1: user1, user2: user2, user3: user3, assessment1: assessment1, course1: course1} do + {:ok, course_reg1} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user1.id, course_id: course1.id, role: :student}) + {:ok, course_reg2} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user2.id, course_id: course1.id, role: :student}) + {:ok, course_reg3} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user3.id, course_id: course1.id, role: :student}) + + attrs = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id},%{"userId" => course_reg3.id}], + ] + } + + assert {:ok, team} = Teams.create_team(attrs) + + assert {:ok, deleted_team} = Teams.delete_team(team) + assert deleted_team.id == team.id + + team_members = TeamMember + |> where([tm], tm.team_id == ^team.id) + |> Repo.all() + assert length(team_members) == 0 + + submissions = Submission + |> where([s], s.team_id == ^team.id) + |> Repo.all() + assert length(submissions) == 0 + end + + test "delete an existing team with submission", %{user1: user1, user2: user2, user3: user3, assessment1: assessment1, course1: course1} do + {:ok, course_reg1} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user1.id, course_id: course1.id, role: :student}) + {:ok, course_reg2} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user2.id, course_id: course1.id, role: :student}) + {:ok, course_reg3} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user3.id, course_id: course1.id, role: :student}) + + attrs = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id},%{"userId" => course_reg3.id}], + ] + } + + assert {:ok, team} = Teams.create_team(attrs) + submission = %Submission{ + team_id: team.id, + assessment_id: assessment1.id, + status: :submitted + } + + {:ok, inserted_submission} = Repo.insert(submission) + + result = Teams.delete_team(team) + assert result == {:error, {:conflict, "This team has submitted their answers! Unable to delete the team!"}} + + end +end \ No newline at end of file From 0f1b347c6df969d3c70f03d63f433b79f3ba56db Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Mon, 15 Jan 2024 19:07:01 +0800 Subject: [PATCH 074/128] Modify Submission Factory to include Team Submission --- test/factories/assessments/submission_factory.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/test/factories/assessments/submission_factory.ex b/test/factories/assessments/submission_factory.ex index 971345a93..da94d87b2 100644 --- a/test/factories/assessments/submission_factory.ex +++ b/test/factories/assessments/submission_factory.ex @@ -10,6 +10,7 @@ defmodule Cadet.Assessments.SubmissionFactory do def submission_factory do %Submission{ student: build(:course_registration, %{role: :student}), + team: nil, assessment: build(:assessment) } end From 58f86af2303a89b0cb18c9e4eaf1ffabbc0e534f Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Mon, 15 Jan 2024 19:07:13 +0800 Subject: [PATCH 075/128] Write test for Team Members --- test/cadet/accounts/team_members_test.exs | 37 +++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 test/cadet/accounts/team_members_test.exs diff --git a/test/cadet/accounts/team_members_test.exs b/test/cadet/accounts/team_members_test.exs new file mode 100644 index 000000000..855c572cb --- /dev/null +++ b/test/cadet/accounts/team_members_test.exs @@ -0,0 +1,37 @@ +defmodule Cadet.Accounts.TeamMemberTest do + use Cadet.DataCase, async: true + + alias Cadet.Accounts.{TeamMember, Team, CourseRegistration} + alias Cadet.Repo + + @valid_attrs %{student_id: 1, team_id: 1} + + describe "changeset/2" do + test "creates a valid changeset with valid attributes" do + team_member = %TeamMember{} + changeset = TeamMember.changeset(team_member, @valid_attrs) + assert changeset.valid? + end + + test "returns an error when required fields are missing" do + team_member = %TeamMember{} + changeset = TeamMember.changeset(team_member, %{}) + refute changeset.valid? + assert {:error, changeset} = Repo.insert(changeset) + end + + test "returns an error when the team_id foreign key constraint is violated" do + team_member = %TeamMember{} + changeset = TeamMember.changeset(team_member, %{student_id: 1}) + refute changeset.valid? + assert {:error, changeset} = Repo.insert(changeset) + end + + test "returns an error when the student_id foreign key constraint is violated" do + team_member = %TeamMember{} + changeset = TeamMember.changeset(team_member, %{team_id: 1}) + refute changeset.valid? + assert {:error, changeset} = Repo.insert(changeset) + end + end +end \ No newline at end of file From 7c4aa702e835f90657f91e2cc5415ebaefef209a Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Mon, 15 Jan 2024 19:07:29 +0800 Subject: [PATCH 076/128] Increase COV for Notification Test --- test/cadet/accounts/notification_test.exs | 106 +++++++++++++++++++--- 1 file changed, 93 insertions(+), 13 deletions(-) diff --git a/test/cadet/accounts/notification_test.exs b/test/cadet/accounts/notification_test.exs index ba65bc7f2..35344e384 100644 --- a/test/cadet/accounts/notification_test.exs +++ b/test/cadet/accounts/notification_test.exs @@ -1,5 +1,5 @@ defmodule Cadet.Accounts.NotificationTest do - alias Cadet.Accounts.{Notification, Notifications} + alias Cadet.Accounts.{Notification, Notifications, TeamMember} use Cadet.ChangesetCase, entity: Notification @@ -11,7 +11,12 @@ defmodule Cadet.Accounts.NotificationTest do student_user = insert(:user) avenger = insert(:course_registration, %{user: avenger_user, role: :staff}) student = insert(:course_registration, %{user: student_user, role: :student}) - submission = insert(:submission, %{student: student, assessment: assessment}) + individual_submission = insert(:submission, %{student: student, assessment: assessment}) + + team = insert(:team) + _team_member1 = insert(:team_member, %{team: team}) + _team_member2 = insert(:team_member, %{team: team}) + team_submission = insert(:submission, %{team: team, assessment: assessment, student: nil}) valid_params_for_student = %{ type: :new, @@ -27,7 +32,7 @@ defmodule Cadet.Accounts.NotificationTest do role: avenger.role, course_reg_id: avenger.id, assessment_id: assessment.id, - submission_id: submission.id + submission_id: individual_submission.id } {:ok, @@ -35,7 +40,9 @@ defmodule Cadet.Accounts.NotificationTest do assessment: assessment, avenger: avenger, student: student, - submission: submission, + team: team, + individual_submission: individual_submission, + team_submission: team_submission, valid_params_for_student: valid_params_for_student, valid_params_for_avenger: valid_params_for_avenger }} @@ -106,7 +113,7 @@ defmodule Cadet.Accounts.NotificationTest do assessment: assessment, avenger: avenger, student: student, - submission: submission + individual_submission: individual_submission } do params_student = %{ type: :new, @@ -122,7 +129,7 @@ defmodule Cadet.Accounts.NotificationTest do role: avenger.role, course_reg_id: avenger.id, assessment_id: assessment.id, - submission_id: submission.id + submission_id: individual_submission.id } assert {:ok, _} = Notifications.write(params_student) @@ -133,7 +140,7 @@ defmodule Cadet.Accounts.NotificationTest do assessment: assessment, avenger: avenger, student: student, - submission: submission + individual_submission: individual_submission } do params_student = %{ type: :new, @@ -149,7 +156,7 @@ defmodule Cadet.Accounts.NotificationTest do role: avenger.role, course_reg_id: avenger.id, assessment_id: assessment.id, - submission_id: submission.id + submission_id: individual_submission.id } Notifications.write(params_student) @@ -170,7 +177,7 @@ defmodule Cadet.Accounts.NotificationTest do from(n in Notification, where: n.type == ^:submitted and n.course_reg_id == ^avenger.id and - n.submission_id == ^submission.id + n.submission_id == ^individual_submission.id ) ) end @@ -277,12 +284,41 @@ defmodule Cadet.Accounts.NotificationTest do assert %{type: :submitted} = notification end + test "receives notification when submitted [team submission]" do + assessment = insert(:assessment, %{is_published: true}) + avenger = insert(:course_registration, %{role: :staff}) + group = insert(:group, %{leader: avenger}) + team = insert(:team) + team_submission = insert(:submission, %{team: team, assessment: assessment, student: nil}) + + Enum.each(1..2, fn _ -> + student = insert(:course_registration, %{role: :student, group: group}) + insert(:team_member, %{team: team, student: student}) + end) + + Notifications.write_notification_when_student_submits(team_submission) + + team_members = Repo.all(from tm in TeamMember, where: tm.team_id == ^team.id, preload: :student) + students = Enum.map(team_members, & &1.student) + + Enum.each(students, fn student -> + notification = + Repo.get_by(Notification, + course_reg_id: student.id, + type: :submitted, + submission_id: team_submission.id + ) + + assert notification == nil + end) + end + test "receives notification when autograded", %{ assessment: assessment, student: student, - submission: submission + individual_submission: individual_submission } do - Notifications.write_notification_when_graded(submission.id, :autograded) + Notifications.write_notification_when_graded(individual_submission.id, :autograded) notification = Repo.get_by(Notification, @@ -294,12 +330,34 @@ defmodule Cadet.Accounts.NotificationTest do assert %{type: :autograded} = notification end + test "receives notification when autograded [team submission]", %{ + assessment: assessment, + team: team, + team_submission: team_submission + } do + Notifications.write_notification_when_graded(team_submission.id, :autograded) + + team_members = Repo.all(from tm in TeamMember, where: tm.team_id == ^team.id, preload: :student) + students = Enum.map(team_members, & &1.student) + + Enum.each(students, fn student -> + notification = + Repo.get_by(Notification, + course_reg_id: student.id, + type: :autograded, + assessment_id: assessment.id + ) + + assert %{type: :autograded} = notification + end) + end + test "receives notification when manually graded", %{ assessment: assessment, student: student, - submission: submission + individual_submission: individual_submission } do - Notifications.write_notification_when_graded(submission.id, :graded) + Notifications.write_notification_when_graded(individual_submission.id, :graded) notification = Repo.get_by(Notification, @@ -311,6 +369,28 @@ defmodule Cadet.Accounts.NotificationTest do assert %{type: :graded} = notification end + test "receives notification when maunally graded [team submission]", %{ + assessment: assessment, + team: team, + team_submission: team_submission + } do + Notifications.write_notification_when_graded(team_submission.id, :graded) + + team_members = Repo.all(from tm in TeamMember, where: tm.team_id == ^team.id, preload: :student) + students = Enum.map(team_members, & &1.student) + + Enum.each(students, fn student -> + notification = + Repo.get_by(Notification, + course_reg_id: student.id, + type: :graded, + assessment_id: assessment.id + ) + + assert %{type: :graded} = notification + end) + end + test "every student receives notifications when a new assessment is published", %{ assessment: assessment, student: student From bd627b564bab515489dfb0df994b08ad16a6f9d4 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Mon, 15 Jan 2024 19:20:03 +0800 Subject: [PATCH 077/128] Prepend unused variable with underscore --- .../cadet/jobs/notification_worker/notification_worker_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/cadet/jobs/notification_worker/notification_worker_test.exs b/test/cadet/jobs/notification_worker/notification_worker_test.exs index 626c46cac..715491e20 100644 --- a/test/cadet/jobs/notification_worker/notification_worker_test.exs +++ b/test/cadet/jobs/notification_worker/notification_worker_test.exs @@ -13,7 +13,7 @@ defmodule Cadet.NotificationWorker.NotificationWorkerTest do avenger_cr = assessments.course_regs.avenger1_cr # setup for assessment submission - asssub_ntype = Cadet.Notifications.get_notification_type_by_name!("ASSESSMENT SUBMISSION") + _asssub_ntype = Cadet.Notifications.get_notification_type_by_name!("ASSESSMENT SUBMISSION") {_name, data} = Enum.at(assessments.assessments, 0) submission = List.first(List.first(data.mcq_answers)).submission From 30718dc1997824b9638f8d96e451255a27733571 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Mon, 15 Jan 2024 19:20:18 +0800 Subject: [PATCH 078/128] Answer View Test --- test/cadet_web/views/answer_view_test.exs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 test/cadet_web/views/answer_view_test.exs diff --git a/test/cadet_web/views/answer_view_test.exs b/test/cadet_web/views/answer_view_test.exs new file mode 100644 index 000000000..13488ab80 --- /dev/null +++ b/test/cadet_web/views/answer_view_test.exs @@ -0,0 +1,15 @@ +defmodule CadetWeb.AnswerViewTest do + use CadetWeb.ConnCase, async: true + + alias CadetWeb.AnswerView + + @lastModified ~U[2022-01-01T00:00:00Z] + + describe "render/2" do + test "renders last modified timestamp as JSON" do + json = AnswerView.render("lastModified.json", %{lastModified: @lastModified}) + + assert json[:lastModified] == @lastModified + end + end +end \ No newline at end of file From 25711369c0fc0eb8d48221ddf235e592db7e98a8 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Mon, 15 Jan 2024 19:20:24 +0800 Subject: [PATCH 079/128] Team View Test --- test/cadet_web/views/team_view_test.exs | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 test/cadet_web/views/team_view_test.exs diff --git a/test/cadet_web/views/team_view_test.exs b/test/cadet_web/views/team_view_test.exs new file mode 100644 index 000000000..7bbf9c9e7 --- /dev/null +++ b/test/cadet_web/views/team_view_test.exs @@ -0,0 +1,27 @@ +defmodule CadetWeb.TeamViewTest do + use CadetWeb.ConnCase, async: true + + alias CadetWeb.TeamView + + @teamFormationOverview %{ + teamId: 1, + assessmentId: 2, + assessmentName: "Test Assessment", + assessmentType: "Test Type", + studentIds: [1, 2, 3], + studentNames: ["Alice", "Bob", "Charlie"] + } + + describe "render/2" do + test "renders team formation overview as JSON" do + json = TeamView.render("index.json", %{teamFormationOverview: @teamFormationOverview}) + + assert json[:teamId] == @teamFormationOverview.teamId + assert json[:assessmentId] == @teamFormationOverview.assessmentId + assert json[:assessmentName] == @teamFormationOverview.assessmentName + assert json[:assessmentType] == @teamFormationOverview.assessmentType + assert json[:studentIds] == @teamFormationOverview.studentIds + assert json[:studentNames] == @teamFormationOverview.studentNames + end + end +end \ No newline at end of file From b0c02944b77ab1bb666711cfad64f0dda20a55d0 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Tue, 16 Jan 2024 01:58:25 +0800 Subject: [PATCH 080/128] Update Admin Teams Controller --- .../admin_teams_controller.ex | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/lib/cadet_web/admin_controllers/admin_teams_controller.ex b/lib/cadet_web/admin_controllers/admin_teams_controller.ex index bcdb2426a..b81676359 100644 --- a/lib/cadet_web/admin_controllers/admin_teams_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_teams_controller.ex @@ -48,6 +48,10 @@ defmodule CadetWeb.AdminTeamsController do end end + def create(conn, %{"course_id" => course_id, "team" => team_params}) do + create(conn, %{"team" => team_params}) + end + def update(conn, %{"teamId" => teamId, "assessmentId" => assessmentId, "student_ids" => student_ids}) do team = Team |> Repo.get!(teamId) @@ -66,21 +70,33 @@ defmodule CadetWeb.AdminTeamsController do end end - def delete(conn, %{"teamId" => team_id}) do - team = Repo.get!(Team, team_id) + def update(conn, %{"course_id" => course_id, "teamId" => teamId, "assessmentId" => assessmentId, "student_ids" => student_ids}) do + update(conn, %{"teamId" => teamId, "assessmentId" => assessmentId, "student_ids" => student_ids}) + end - case Teams.delete_team(team) do - {:error, {status, error_message}} -> - conn - |> put_status(status) - |> text(error_message) - {:ok, _} -> - text(conn, "Team deleted successfully.") - {:error, _changeset} -> - text(conn, "Error deleting the team.") + def delete(conn, %{"teamId" => team_id}) do + team = Repo.get(Team, team_id) + + if team do + case Teams.delete_team(team) do + {:error, {status, error_message}} -> + conn + |> put_status(status) + |> text(error_message) + {:ok, _} -> + text(conn, "Team deleted successfully.") + end + else + conn + |> put_status(:not_found) + |> text("Team not found!") end end + def delete(conn, %{"course_id" => course_id, "teamid" => team_id}) do + delete(conn, %{"teamId" => team_id}) + end + swagger_path :index do get("/admin/teams") From 4da966eae8bb227570ed71871dc396635abc4091 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Tue, 16 Jan 2024 01:58:35 +0800 Subject: [PATCH 081/128] Admin Teams Controller Test --- .../admin_teams_controller_test.exs | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 test/cadet_web/admin_controllers/admin_teams_controller_test.exs diff --git a/test/cadet_web/admin_controllers/admin_teams_controller_test.exs b/test/cadet_web/admin_controllers/admin_teams_controller_test.exs new file mode 100644 index 000000000..5707222b2 --- /dev/null +++ b/test/cadet_web/admin_controllers/admin_teams_controller_test.exs @@ -0,0 +1,269 @@ +defmodule CadetWeb.AdminTeamsControllerTest do + use CadetWeb.ConnCase + + alias Cadet.Repo + alias Cadet.Courses.Course + alias CadetWeb.AdminTeamsController + + test "swagger" do + AdminTeamsController.swagger_definitions() + AdminTeamsController.swagger_path_index(nil) + AdminTeamsController.swagger_path_create(nil) + AdminTeamsController.swagger_path_update(nil) + AdminTeamsController.swagger_path_delete(nil) + end + + describe "GET /admin/teams" do + test "unauthenticated", %{conn: conn} do + course = insert(:course) + conn = get(conn, build_url(course.id)) + assert response(conn, 401) =~ "Unauthorised" + end + + @tag authenticate: :student + test "Forbidden", %{conn: conn} do + course_id = conn.assigns.course_id + conn = get(conn, build_url(course_id)) + assert response(conn, 403) =~ "Forbidden" + end + + @tag authenticate: :staff + test "returns a list of teams", %{conn: conn} do + course_id = conn.assigns.course_id + team = insert(:team) + _team_member1 = insert(:team_member, %{team: team}) + _team_member2 = insert(:team_member, %{team: team}) + + conn = get(conn, build_url(course_id)) + assert response(conn, 200) + + response_body = conn.resp_body |> Jason.decode!() + assert Enum.any?(response_body, fn team_map -> team_map["teamId"] == team.id end) + end + end + + describe "POST /admin/teams" do + test "unauthenticated", %{conn: conn} do + course = insert(:course) + conn = post(conn, build_url(course.id), %{}) + assert response(conn, 401) =~ "Unauthorised" + end + + @tag authenticate: :student + test "Forbidden", %{conn: conn} do + course = insert(:course) + conn = post(conn, build_url(course.id), %{}) + assert response(conn, 403) =~ "Forbidden" + end + + @tag authenticate: :staff + test "creates a new team", %{conn: conn} do + course_id = conn.assigns.course_id + course = Repo.get(Course, course_id) + assessment = insert(:assessment, %{course: course, max_team_size: 2}) + student1 = insert(:course_registration, %{course: course}) + student2 = insert(:course_registration, %{course: course}) + team_params = %{"team" => + %{ + "assessment_id" => assessment.id, + # student_ids is a list of lists of maps where each map is a student with attributes + # userId where this userId is the CourseRegistration.id + "student_ids" => [[%{userId: student1.id}, %{userId: student2.id}]] + } + } + conn = post(conn, build_url(course_id), team_params) + assert response(conn, 201) =~ "Teams created successfully." + end + + @tag authenticate: :staff + test "creates an invalid team with duplicate members", %{conn: conn} do + course_id = conn.assigns.course_id + course = Repo.get(Course, course_id) + assessment = insert(:assessment, %{course: course, max_team_size: 2}) + student1 = insert(:course_registration, %{course: course}) + team_params = %{"team" => + %{ + "assessment_id" => assessment.id, + # student_ids is a list of lists of maps where each map is a student with attributes + # userId where this userId is the CourseRegistration.id + "student_ids" => [[%{userId: student1.id}, %{userId: student1.id}]] + } + } + conn = post(conn, build_url(course_id), team_params) + assert response(conn, 409) =~ "One or more students appear multiple times in a team!" + end + + @tag authenticate: :staff + test "creates an invalid team which exceeds max team size", %{conn: conn} do + course_id = conn.assigns.course_id + course = Repo.get(Course, course_id) + assessment = insert(:assessment, %{course: course, max_team_size: 2}) + student1 = insert(:course_registration, %{course: course}) + student2 = insert(:course_registration, %{course: course}) + student3 = insert(:course_registration, %{course: course}) + team_params = %{"team" => + %{ + "assessment_id" => assessment.id, + # student_ids is a list of lists of maps where each map is a student with attributes + # userId where this userId is the CourseRegistration.id + "student_ids" => [[%{userId: student1.id}, %{userId: student2.id}, %{userId: student3.id}]] + } + } + conn = post(conn, build_url(course_id), team_params) + assert response(conn, 409) =~ "One or more teams exceed the maximum team size!" + end + + @tag authenticate: :staff + test "creates an invalid team where student not enrolled in course", %{conn: conn} do + course_id = conn.assigns.course_id + course = Repo.get(Course, course_id) + assessment = insert(:assessment, %{course: course, max_team_size: 2}) + student1 = insert(:course_registration, %{course: course}) + student2 = insert(:course_registration) + team_params = %{"team" => + %{ + "assessment_id" => assessment.id, + # student_ids is a list of lists of maps where each map is a student with attributes + # userId where this userId is the CourseRegistration.id + "student_ids" => [[%{userId: student1.id}, %{userId: student2.id}]] + } + } + conn = post(conn, build_url(course_id), team_params) + assert response(conn, 409) =~ "One or more students not enrolled in this course!" + end + + @tag authenticate: :staff + test "creates an invalid team where student already has a team for this assessment", %{conn: conn} do + course_id = conn.assigns.course_id + course = Repo.get(Course, course_id) + assessment = insert(:assessment, %{course: course, max_team_size: 2}) + student1 = insert(:course_registration, %{course: course}) + student2 = insert(:course_registration, %{course: course}) + student3 = insert(:course_registration, %{course: course}) + team = insert(:team, %{assessment: assessment}) + _team_member1 = insert(:team_member, %{team: team, student: student1}) + _team_member2 = insert(:team_member, %{team: team, student: student2}) + team_params = %{"team" => + %{ + "assessment_id" => assessment.id, + # student_ids is a list of lists of maps where each map is a student with attributes + # userId where this userId is the CourseRegistration.id + "student_ids" => [[%{userId: student1.id}, %{userId: student3.id}]] + } + } + conn = post(conn, build_url(course_id), team_params) + assert response(conn, 409) =~ "One or more students already in a team for this assessment!" + end + end + + describe "PUT /admin/teams/{teamId}" do + test "unauthenticated", %{conn: conn} do + course = insert(:course) + conn = put(conn, build_url(course.id, 1), %{}) + assert response(conn, 401) =~ "Unauthorised" + end + + @tag authenticate: :student + test "Forbidden", %{conn: conn} do + course = insert(:course) + conn = put(conn, build_url(course.id, 1), %{}) + assert response(conn, 403) =~ "Forbidden" + end + + @tag authenticate: :staff + test "updates a team", %{conn: conn} do + course_id = conn.assigns.course_id + course = Repo.get(Course, course_id) + assessment = insert(:assessment, %{course: course, max_team_size: 2}) + student1 = insert(:course_registration, %{course: course}) + student2 = insert(:course_registration, %{course: course}) + student3 = insert(:course_registration, %{course: course}) + team = insert(:team, %{assessment: assessment}) + _team_member1 = insert(:team_member, %{team: team, student: student1}) + _team_member2 = insert(:team_member, %{team: team, student: student2}) + _team_member3 = insert(:team_member, %{team: team, student: student3}) + updated_team_params = %{ + "course_id" => course.id, + "teamId" => team.id, + "assessmentId" => assessment.id, + "student_ids" => [[%{userId: student1.id}, %{userId: student3.id}]] + } + conn = put(conn, build_url(course_id, team.id), updated_team_params) + assert response(conn, 200) =~ "Teams updated successfully." + end + + @tag authenticate: :staff + test "updates a team which exceeds max team size", %{conn: conn} do + course_id = conn.assigns.course_id + course = Repo.get(Course, course_id) + assessment = insert(:assessment, %{course: course, max_team_size: 2}) + student1 = insert(:course_registration, %{course: course}) + student2 = insert(:course_registration, %{course: course}) + student3 = insert(:course_registration, %{course: course}) + team1 = insert(:team, %{assessment: assessment}) + _team_member1 = insert(:team_member, %{team: team1, student: student1}) + _team_member2 = insert(:team_member, %{team: team1, student: student2}) + team2 = insert(:team, %{assessment: assessment}) + updated_team_params = %{ + "course_id" => course.id, + "teamId" => team2.id, + "assessmentId" => assessment.id, + "student_ids" => [[%{userId: student1.id}, %{userId: student3.id}]] + } + conn = put(conn, build_url(course_id, team2.id), updated_team_params) + assert response(conn, 409) =~ "One or more students are already in another team for the same assessment!" + end + end + + describe "DELETE /admin/teams/{teamId}" do + test "unauthenticated", %{conn: conn} do + course = insert(:course) + team = insert(:team) + conn = delete(conn, build_url(course.id, team.id)) + assert response(conn, 401) =~ "Unauthorised" + end + + @tag authenticate: :student + test "Forbidden", %{conn: conn} do + course = insert(:course) + team = insert(:team) + conn = delete(conn, build_url(course.id, team.id)) + assert response(conn, 403) =~ "Forbidden" + end + + @tag authenticate: :staff + test "deletes a team", %{conn: conn} do + course_id = conn.assigns.course_id + team = insert(:team) + conn = delete(conn, build_url(course_id, team.id)) + assert response(conn, 200) =~ "Team deleted successfully." + end + + @tag authenticate: :staff + test "delete a team that does not exist", %{conn: conn} do + course_id = conn.assigns.course_id + conn = delete(conn, build_url(course_id, -1)) + assert response(conn, 404) =~ "Team not found!" + end + + @tag authenticate: :staff + test "delete a team that has already submitted answers", %{conn: conn} do + course_id = conn.assigns.course_id + course = Repo.get(Course, course_id) + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{is_published: true, course: course, config: config, max_team_size: 2}) + student1 = insert(:course_registration, %{course: course}) + student2 = insert(:course_registration, %{course: course}) + team = insert(:team, %{assessment: assessment}) + _team_member1 = insert(:team_member, %{team: team, student: student1}) + _team_member2 = insert(:team_member, %{team: team, student: student2}) + _submission = insert(:submission, %{assessment: assessment, team: team, student: nil, status: :submitted}) + conn = delete(conn, build_url(course_id, team.id)) + assert response(conn, 409) =~ "This team has submitted their answers! Unable to delete the team!" + end + end + + defp build_url(course_id), do: "/v2/courses/#{course_id}/admin/teams/" + defp build_url(course_id, team_id), + do: "#{build_url(course_id)}#{team_id}" +end From 893c22645cea5fb0e618c9fb9401c69208c34db3 Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Wed, 17 Jan 2024 10:39:15 +0800 Subject: [PATCH 082/128] Clean up test cases --- test/cadet/accounts/teams_test.exs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/cadet/accounts/teams_test.exs b/test/cadet/accounts/teams_test.exs index a728d82ad..80b06b12d 100644 --- a/test/cadet/accounts/teams_test.exs +++ b/test/cadet/accounts/teams_test.exs @@ -1,7 +1,7 @@ defmodule Cadet.Accounts.TeamTest do use Cadet.DataCase - alias Cadet.Accounts.{Teams, Team, TeamMember, User, CourseRegistration, CourseRegistrations, Notification} - alias Cadet.Assessments.{Assessment, Submission} + alias Cadet.Accounts.{Teams, TeamMember, CourseRegistrations} + alias Cadet.Assessments.{Submission} alias Cadet.Repo setup do @@ -118,7 +118,7 @@ defmodule Cadet.Accounts.TeamTest do ] } - assert {:ok, team} = Teams.create_team(attrs_valid) + assert {:ok, _team} = Teams.create_team(attrs_valid) attrs_invalid = %{ "assessment_id" => assessment1.id, @@ -226,7 +226,7 @@ defmodule Cadet.Accounts.TeamTest do } new_ids = [[%{"userId" => course_reg1.id},%{"userId" => course_reg2.id},%{"userId" => course_reg3.id}]] assert {:ok, team1} = Teams.create_team(attrs1) - assert {:ok, team2} = Teams.create_team(attrs2) + assert {:ok, _team2} = Teams.create_team(attrs2) team1 = Repo.preload(team1, :team_members) result = Teams.update_team(team1, team1.assessment_id, new_ids) @@ -280,7 +280,7 @@ defmodule Cadet.Accounts.TeamTest do status: :submitted } - {:ok, inserted_submission} = Repo.insert(submission) + {:ok, _inserted_submission} = Repo.insert(submission) result = Teams.delete_team(team) assert result == {:error, {:conflict, "This team has submitted their answers! Unable to delete the team!"}} From 97f156bd3490fe2d2aff9f67765136e23801642e Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Thu, 18 Jan 2024 09:18:42 +0800 Subject: [PATCH 083/128] Improve test coverage for teams --- test/cadet/accounts/teams_test.exs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/test/cadet/accounts/teams_test.exs b/test/cadet/accounts/teams_test.exs index 80b06b12d..77aafbbe6 100644 --- a/test/cadet/accounts/teams_test.exs +++ b/test/cadet/accounts/teams_test.exs @@ -1,7 +1,7 @@ defmodule Cadet.Accounts.TeamTest do use Cadet.DataCase alias Cadet.Accounts.{Teams, TeamMember, CourseRegistrations} - alias Cadet.Assessments.{Submission} + alias Cadet.Assessments.{Submission, Answer} alias Cadet.Repo setup do @@ -246,6 +246,17 @@ defmodule Cadet.Accounts.TeamTest do } assert {:ok, team} = Teams.create_team(attrs) + submission = insert(:submission, %{ + team: team, + student: nil, + assessment: assessment1 + }) + + submission_id = submission.id + + _answer = %Answer{ + submission_id: submission_id + } assert {:ok, deleted_team} = Teams.delete_team(team) assert deleted_team.id == team.id @@ -259,6 +270,11 @@ defmodule Cadet.Accounts.TeamTest do |> where([s], s.team_id == ^team.id) |> Repo.all() assert length(submissions) == 0 + + answers = Answer + |> where(submission_id: ^submission_id) + |> Repo.all() + assert length(answers) == 0 end test "delete an existing team with submission", %{user1: user1, user2: user2, user3: user3, assessment1: assessment1, course1: course1} do From 88a1e2f51e239abc90d1fbea1be83a05d0852cf5 Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Thu, 18 Jan 2024 12:32:20 +0800 Subject: [PATCH 084/128] Improve test coverage for team controller --- .../controllers/teams_controller_test.exs | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 test/cadet_web/controllers/teams_controller_test.exs diff --git a/test/cadet_web/controllers/teams_controller_test.exs b/test/cadet_web/controllers/teams_controller_test.exs new file mode 100644 index 000000000..687cf83ca --- /dev/null +++ b/test/cadet_web/controllers/teams_controller_test.exs @@ -0,0 +1,95 @@ +defmodule CadetWeb.TeamsControllerTest do + use CadetWeb.ConnCase + + import Ecto.Query + + alias Cadet.Repo + alias Cadet.Accounts.{User, CourseRegistration, Team, Teams, TeamMembers} + alias Cadet.Courses.Course + alias CadetWeb.TeamController + setup do + Cadet.Test.Seeds.assessments() + end + test "swagger" do + TeamController.swagger_path_index(nil) + end + + + describe "GET /v2/admin/teams" do + @tag authenticate: :student + test "unauthorized with student", %{conn: conn} do + course = insert(:course) + conn = get(conn, build_url_get(course.id)) + assert response(conn, 403) == "Forbidden" + end + + @tag authenticate: :admin + test "authorized with zero team", %{conn: conn} do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + # assessment = insert(:assessment, %{course: course}) + conn = get(conn, build_url_get(course.id)) + assert response(conn, 200) == "[]" + end + + @tag authenticate: :admin + test "authorized with multiple teams", %{conn: conn} do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + assessment = insert(:assessment, %{course: course}) + team = insert(:team, %{assessment: assessment}) + conn = get(conn, build_url_get(course.id)) + + teamFormationOverview = %{ + teamId: team.id, + assessmentId: assessment.id, + assessmentName: assessment.title, + assessmentType: assessment.config.type, + studentIds: [], + studentNames: [], + } + assert response(conn, 200) == "[#{Jason.encode!(teamFormationOverview)}]" + end + + + end + + describe "GET /v2/courses/:course_id/team/:assessment_id" do + @tag authenticate: :admin + test "team not found", %{conn: conn} do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + assessment = insert(:assessment, %{course: course}) + conn = get(conn, build_url_get_by_assessment(course.id, assessment.id)) + assert response(conn, 200) == "Team is not found!" + end + + @tag authenticate: :admin + test "team(s) found", %{conn: conn} do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + cr = conn.assigns[:test_cr] + cr1 = insert(:course_registration, %{course: course, role: :student}) + cr2 = insert(:course_registration, %{course: course, role: :student}) + assessment = insert(:assessment, %{course: course}) + teammember1 = insert(:team_member, %{student: cr1}) + teammember2 = insert(:team_member, %{student: cr2}) + teammember3 = insert(:team_member, %{student: cr}) + team = insert(:team, %{assessment: assessment, team_members: [teammember1, teammember2, teammember3]}) + + conn = get(conn, build_url_get_by_assessment(course.id, assessment.id)) + + teamFormationOverview = %{ + teamId: team.id, + assessmentId: assessment.id, + assessmentName: assessment.title, + assessmentType: assessment.config.type, + studentIds: [cr1.user.id,cr2.user.id,cr.user.id], + studentNames: [cr1.user.name, cr2.user.name, cr.user.name], + } + assert response(conn, 200) == "#{Jason.encode!(teamFormationOverview)}" + end + end + defp build_url_get(course_id), do: "/v2/courses/#{course_id}/admin/teams" + defp build_url_get_by_assessment(course_id, assessment_id), do: "/v2/courses/#{course_id}/team/#{assessment_id}" +end \ No newline at end of file From 7284d2f877d7175cef48774d662cb79b2944d42f Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Thu, 18 Jan 2024 17:01:17 +0800 Subject: [PATCH 085/128] Improve submission test coverage --- lib/cadet/assessments/submission.ex | 10 +++--- test/cadet/assessments/submission_test.exs | 37 ++++++++++++++++++++-- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/lib/cadet/assessments/submission.ex b/lib/cadet/assessments/submission.ex index 6d16666ff..8b9acb8e0 100644 --- a/lib/cadet/assessments/submission.ex +++ b/lib/cadet/assessments/submission.ex @@ -52,15 +52,17 @@ defmodule Cadet.Assessments.Submission do defp validate_xor_relationship(changeset) do case {get_field(changeset, :student_id), get_field(changeset, :team_id)} do {nil, nil} -> - add_error(changeset, :student_id, "either student or team_id must be present") - |> add_error(changeset, :team_id, "either student_id or team must be present") + changeset + |> add_error(:student_id, "either student or team_id must be present") + |> add_error(:team_id, "either student_id or team must be present") {nil, _} -> changeset {_, nil} -> changeset {_student, _team} -> - add_error(changeset, :student_id, "student and team_id cannot be present at the same time") - |> add_error(changeset, :team_id, "student_id and team cannot be present at the same time") + changeset + |> add_error(:student_id, "student and team_id cannot be present at the same time") + |> add_error(:team_id, "student_id and team cannot be present at the same time") end end end diff --git a/test/cadet/assessments/submission_test.exs b/test/cadet/assessments/submission_test.exs index 9e99acf77..fff7cc313 100644 --- a/test/cadet/assessments/submission_test.exs +++ b/test/cadet/assessments/submission_test.exs @@ -9,11 +9,25 @@ defmodule Cadet.Assessments.SubmissionTest do course = insert(:course) config = insert(:assessment_config, %{course: course}) assessment = insert(:assessment, %{config: config, course: course}) + team_assessment = insert(:assessment, %{config: config, course: course}) student = insert(:course_registration, %{course: course, role: :student}) - team = nil + student1 = insert(:course_registration, %{course: course, role: :student}) + student2 = insert(:course_registration, %{course: course, role: :student}) + + teammember1 = insert(:team_member, %{student: student1}) + teammember2 = insert(:team_member, %{student: student2}) + team = insert(:team, %{assessment: team_assessment, team_members: [teammember1, teammember2]}) + valid_params = %{student_id: student.id, assessment_id: assessment.id} + valid_params_with_team = %{student_id: nil, team_id: team.id, assessment_id: assessment.id} + invalid_params_without_both = %{student_id: nil, team_id: nil, assessment_id: assessment.id} + invalid_params_with_both = %{student_id: student1.id, team_id: team.id, assessment_id: assessment.id} - {:ok, %{assessment: assessment, student: student, team: team, valid_params: valid_params}} + {:ok, %{assessment: assessment, student: student, team: team, + valid_params: valid_params, + valid_params_with_team: valid_params_with_team, + invalid_params_without_both: invalid_params_without_both, + invalid_params_with_both: invalid_params_with_both}} end describe "Changesets" do @@ -50,5 +64,24 @@ defmodule Cadet.Assessments.SubmissionTest do |> Map.put(:student_id, new_student.id) |> assert_changeset_db(:invalid) end + + test "invalid changeset with only team", %{ + valid_params_with_team: params + } do + assert_changeset_db(params, :valid) + end + + + test "invalid changeset without team and student", %{ + invalid_params_without_both: params + } do + assert_changeset_db(params, :invalid) + end + + test "invalid changeset with both team and student", %{ + invalid_params_with_both: params + } do + assert_changeset_db(params, :invalid) + end end end From a011725a4d6f4e25f0a10eedcf6db23a0024c0d2 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Thu, 18 Jan 2024 18:44:10 +0800 Subject: [PATCH 086/128] Remove unrecheable code --- lib/cadet/accounts/teams.ex | 3 --- lib/cadet_web/controllers/answer_controller.ex | 5 ----- 2 files changed, 8 deletions(-) diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index 45d72de7b..d4e03dcd1 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -218,9 +218,6 @@ defmodule Cadet.Accounts.Teams do update_team_members(updated_team, student_ids, team_id) {:ok, updated_team} - - error -> - error end end end diff --git a/lib/cadet_web/controllers/answer_controller.ex b/lib/cadet_web/controllers/answer_controller.ex index 0450b5b82..c23341711 100644 --- a/lib/cadet_web/controllers/answer_controller.ex +++ b/lib/cadet_web/controllers/answer_controller.ex @@ -62,11 +62,6 @@ defmodule CadetWeb.AnswerController do conn |> put_status(:forbidden) |> text("Assessment not open") - - {:error, {status, message}} -> - conn - |> put_status(status) - |> text(message) end end From 197e7f886fe5794b51fa3f62ddb7326d150f3f85 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Thu, 18 Jan 2024 18:44:34 +0800 Subject: [PATCH 087/128] Empty Guardian Test --- test/cadet/auth/empty_guardian_test.exs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 test/cadet/auth/empty_guardian_test.exs diff --git a/test/cadet/auth/empty_guardian_test.exs b/test/cadet/auth/empty_guardian_test.exs new file mode 100644 index 000000000..b76c2af98 --- /dev/null +++ b/test/cadet/auth/empty_guardian_test.exs @@ -0,0 +1,24 @@ +defmodule Cadet.Auth.EmptyGuardianTest do + use ExUnit.Case + alias Cadet.Auth.EmptyGuardian + + describe "config/1" do + test "returns default value for allowed_drift" do + assert EmptyGuardian.config(:allowed_drift) == 10_000 + end + + test "returns nil for other keys" do + assert EmptyGuardian.config(:other_key) == nil + end + end + + describe "config/2" do + test "returns default value for allowed_drift regardless of second argument" do + assert EmptyGuardian.config(:allowed_drift, :default) == 10_000 + end + + test "returns second argument for other keys" do + assert EmptyGuardian.config(:other_key, :default) == :default + end + end +end \ No newline at end of file From 52f6792fb064a9f01e01611ac444b902fd25b281 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Thu, 18 Jan 2024 18:44:44 +0800 Subject: [PATCH 088/128] Answer Controller Test --- .../controllers/answer_controller_test.exs | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/test/cadet_web/controllers/answer_controller_test.exs b/test/cadet_web/controllers/answer_controller_test.exs index bb1f4654a..6a5e1fe09 100644 --- a/test/cadet_web/controllers/answer_controller_test.exs +++ b/test/cadet_web/controllers/answer_controller_test.exs @@ -327,6 +327,94 @@ defmodule CadetWeb.AnswerControllerTest do assert is_nil(get_answer_value(unpublished_question, unpublished_assessment, course_reg)) end + @tag authenticate: :student + test "check last modified false", %{conn: conn, programming_question: programming_question} do + course_id = conn.assigns.course_id + + question_id = programming_question.id + last_modified_at = DateTime.to_iso8601(DateTime.utc_now()) + + check_last_modified_conn = + post(conn, "/v2/courses/#{course_id}/assessments/question/#{question_id}/answerLastModified/", %{ + lastModifiedAt: last_modified_at + }) + + assert response(check_last_modified_conn, 200) =~ "{\"lastModified\":false}" + end + + @tag authenticate: :student + test "check last modified true", %{conn: conn, assessment: assessment, programming_question: programming_question} do + course_id = conn.assigns.course_id + last_modified_at = DateTime.to_iso8601(DateTime.utc_now()) + question_id = programming_question.id + submission = insert(:submission, %{assessment: assessment, student: conn.assigns.test_cr}) + + _answer = insert(:answer, %{question: programming_question, last_modified_at: last_modified_at, submission: submission}) + + + check_last_modified_conn = + post(conn, "/v2/courses/#{course_id}/assessments/question/#{question_id}/answerLastModified/", %{ + lastModifiedAt: last_modified_at + }) + + assert response(check_last_modified_conn, 200) =~ "{\"lastModified\":true}" + end + + # @tag authenticate: :student + # test "check last modified, invalid params", %{conn: conn, assessment: assessment, mcq_question: mcq_question} do + # course_reg = conn.assigns.test_cr + # course_id = conn.assigns.course_id + + # question_id = mcq_question.id + # invalid_last_modified_at = "invalid_timestamp" + + # check_last_modified_conn = + # post(conn, "/v2/courses/#{course_id}/assessments/question/#{question_id}/answerLastModified", %{ + # lastModifiedAt: invalid_last_modified_at + # }) + + # assert response(check_last_modified_conn, 400) == "Invalid parameters" + # end + + @tag authenticate: :student + test "check last modified, missing question is unsuccessful", %{conn: conn} do + course_id = conn.assigns.course_id + question_id = -1 + last_modified_at = DateTime.to_iso8601(DateTime.utc_now()) + + check_last_modified_conn = + post(conn, "/v2/courses/#{course_id}/assessments/question/#{question_id}/answerLastModified", %{ + lastModifiedAt: last_modified_at + }) + + assert response(check_last_modified_conn, 404) == "Question not found" + end + + @tag authenticate: :student + test "check last modified, not open submission is unsuccessful", %{conn: conn} do + course_id = conn.assigns.course_id + last_modified_at = DateTime.to_iso8601(DateTime.utc_now()) + + before_open_at_assessment = + insert(:assessment, %{ + open_at: Timex.shift(Timex.now(), days: 5), + close_at: Timex.shift(Timex.now(), days: 10) + }) + + before_open_at_question = insert(:programming_question, %{assessment: before_open_at_assessment}) + + _unpublished_conn = + post(conn, build_url(course_id, before_open_at_question.id), %{answer: 5}) + + question_id = before_open_at_question.id + check_last_modified_conn = + post(conn, "/v2/courses/#{course_id}/assessments/question/#{question_id}/answerLastModified", %{ + lastModifiedAt: last_modified_at + }) + + assert response(check_last_modified_conn, 403) == "Assessment not open" + end + defp build_url(course_id, question_id) do "/v2/courses/#{course_id}/assessments/question/#{question_id}/answer/" end From b4b9f5a7beaf571b0dc1d7a15a5814cb97cd4ff3 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Thu, 18 Jan 2024 18:44:51 +0800 Subject: [PATCH 089/128] Notification Test --- test/cadet/accounts/notification_test.exs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/cadet/accounts/notification_test.exs b/test/cadet/accounts/notification_test.exs index 35344e384..eb6ea0ce4 100644 --- a/test/cadet/accounts/notification_test.exs +++ b/test/cadet/accounts/notification_test.exs @@ -330,6 +330,23 @@ defmodule Cadet.Accounts.NotificationTest do assert %{type: :autograded} = notification end + test "no notification when no submission", %{ + assessment: assessment, + student: student, + individual_submission: individual_submission + } do + Notifications.write_notification_when_graded(-1, :autograded) + + notification = + Repo.get_by(Notification, + course_reg_id: student.id, + type: :autograded, + assessment_id: assessment.id + ) + + assert notification == nil + end + test "receives notification when autograded [team submission]", %{ assessment: assessment, team: team, From ecc2fe060766b8c3fcc5ca56af4a00f71e37b8e7 Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Fri, 19 Jan 2024 08:20:39 +0800 Subject: [PATCH 090/128] improve test coverage for assessments --- lib/cadet/assessments/assessments.ex | 2 +- test/cadet/assessments/assessments_test.exs | 44 +++++++++++++++++++++ test/cadet/assessments/submission_test.exs | 2 +- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index fe500574e..b7a947c85 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -940,7 +940,7 @@ defmodule Cadet.Assessments do {:status, :submitted} <- {:status, submission.status}, {:allowed_to_unsubmit?, true} <- {:allowed_to_unsubmit?, - role == :admin or bypass or + role == :admin or bypass or is_nil(submission.student_id) or Cadet.Accounts.Query.avenger_of?(cr, submission.student_id)} do Multi.new() |> Multi.run( diff --git a/test/cadet/assessments/assessments_test.exs b/test/cadet/assessments/assessments_test.exs index 3575768ff..a4cec756b 100644 --- a/test/cadet/assessments/assessments_test.exs +++ b/test/cadet/assessments/assessments_test.exs @@ -147,6 +147,50 @@ defmodule Cadet.AssessmentsTest do assert Repo.get(Question, question.id) == nil end + describe "team assessments" do + test "cannot answer questions without a team" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{config: config, course: course, max_team_size: 10}) + question = insert(:question, %{assessment: assessment}) + student = insert(:course_registration, %{course: course, role: :student}) + + assert Assessments.answer_question(question, student, "answer", false) == {:error, {:bad_request, "Your existing Team has been deleted!"}} + end + + test "assessments with questions and answers" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{config: config, course: course, max_team_size: 10}) + student = insert(:course_registration, %{course: course, role: :student}) + + assert {:ok, _} = Assessments.assessment_with_questions_and_answers(assessment, student) + + end + @tag authenticate: :staff + test "unsubmit team assessment" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + team_assessment = insert(:assessment, %{config: config, course: course, max_team_size: 10, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: +5), + is_published: true}) + group = insert(:group, %{name: "group"}) + avenger = insert(:course_registration, %{course: course, role: :staff, group: group}) + + student1 = insert(:course_registration, %{course: course, role: :student, group: group}) + student2 = insert(:course_registration, %{course: course, role: :student, group: group}) + teammember1 = insert(:team_member, %{student: student1}) + teammember2 = insert(:team_member, %{student: student2}) + team = insert(:team, %{assessment: team_assessment, team_members: [teammember1, teammember2]}) + question = insert(:question, %{assessment: team_assessment}) + submission = insert(:submission, %{assessment: team_assessment, team: team, student: nil, status: :submitted}) + answer = insert(:answer, submission: submission, question: question, answer: %{code: "f => f(f);"}) + + assert {:ok, _} = Assessments.unsubmit_submission(submission.id, avenger) + end + end + describe "contest voting" do test "inserts votes into submission_votes table if contest has closed" do course = insert(:course) diff --git a/test/cadet/assessments/submission_test.exs b/test/cadet/assessments/submission_test.exs index fff7cc313..40afaff17 100644 --- a/test/cadet/assessments/submission_test.exs +++ b/test/cadet/assessments/submission_test.exs @@ -65,7 +65,7 @@ defmodule Cadet.Assessments.SubmissionTest do |> assert_changeset_db(:invalid) end - test "invalid changeset with only team", %{ + test "valid changeset with only team", %{ valid_params_with_team: params } do assert_changeset_db(params, :valid) From 230eb86d9c62c000faea2b7f225188e6597bd38b Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Fri, 19 Jan 2024 11:13:25 +0800 Subject: [PATCH 091/128] Improve test coverage for assessments --- lib/cadet/assessments/assessments.ex | 4 +- test/cadet/assessments/assessments_test.exs | 115 ++++++++++++++++-- .../controllers/teams_controller_test.exs | 2 +- 3 files changed, 110 insertions(+), 11 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index b7a947c85..81c066ba0 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -54,7 +54,7 @@ defmodule Cadet.Assessments do else Submission |> where(assessment_id: ^id) - |> delete_submission_assocation(id) + |> delete_submission_association(id) Question |> where(assessment_id: ^id) @@ -73,7 +73,7 @@ defmodule Cadet.Assessments do |> Repo.delete_all() end - defp delete_submission_assocation(submissions, assessment_id) do + defp delete_submission_association(submissions, assessment_id) do submissions |> Repo.all() |> Enum.each(fn submission -> diff --git a/test/cadet/assessments/assessments_test.exs b/test/cadet/assessments/assessments_test.exs index a4cec756b..a152b0c74 100644 --- a/test/cadet/assessments/assessments_test.exs +++ b/test/cadet/assessments/assessments_test.exs @@ -4,7 +4,7 @@ defmodule Cadet.AssessmentsTest do import Cadet.{Factory, TestEntityHelper} alias Cadet.Assessments - alias Cadet.Assessments.{Assessment, Question, SubmissionVotes} + alias Cadet.Assessments.{Assessment, Question, SubmissionVotes, Submission} test "create assessments of all types" do course = insert(:course) @@ -122,16 +122,54 @@ defmodule Cadet.AssessmentsTest do assert assessment.is_published == true end - test "update assessment" do - course = insert(:course) - config = insert(:assessment_config, %{course: course}) - assessment = insert(:assessment, %{title: "assessment", course: course, config: config}) + describe "Update assessments" do + test "update assessment" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{title: "assessment", course: course, config: config}) + + Assessments.update_assessment(assessment.id, %{title: "changed_assessment"}) - Assessments.update_assessment(assessment.id, %{title: "changed_assessment"}) + assessment = Repo.get(Assessment, assessment.id) - assessment = Repo.get(Assessment, assessment.id) + assert assessment.title == "changed_assessment" + end + + test "update grading info for assessment" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{config: config, course: course, + # open_at: Timex.shift(Timex.now(), days: -5), + # close_at: Timex.shift(Timex.now(), hours: +5), + is_published: false}) - assert assessment.title == "changed_assessment" + student = insert(:course_registration, %{course: course, role: :student}) + question = insert(:question, %{assessment: assessment}) + submission = insert(:submission, %{assessment: assessment, team: nil, student: student, status: :attempting}) + + assert {:error, {:unauthorized, "User is not permitted to grade."}} = Assessments.update_grading_info(%{submission: submission, question: question}, %{}, student) + end + + test "force update assessment with invalid params" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{config: config, course: course, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: +5), + is_published: true}) + question1 = insert(:question, %{assessment: assessment, type: :programming}) + question2 = insert(:question, %{assessment: assessment, type: :programming}) + assessment_params = %{ + number: assessment.number, + course_id: course.id + } + question_params = %{ + assessment: assessment, + type: :programming + } + + assert {:error, "Question count is different"} = Assessments.insert_or_update_assessments_and_questions(assessment_params, question_params, true) + end end test "update question" do @@ -158,6 +196,22 @@ defmodule Cadet.AssessmentsTest do assert Assessments.answer_question(question, student, "answer", false) == {:error, {:bad_request, "Your existing Team has been deleted!"}} end + test "answer questions with a team" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{config: config, course: course, max_team_size: 10}) + question = insert(:question, %{assessment: assessment, type: :programming}) + student1 = insert(:course_registration, %{course: course, role: :student}) + student2 = insert(:course_registration, %{course: course, role: :student}) + teammember1 = insert(:team_member, %{student: student1}) + teammember2 = insert(:team_member, %{student: student2}) + team = insert(:team, %{assessment: assessment, team_members: [teammember1, teammember2]}) + submission = insert(:submission, %{assessment: assessment, team: team, student: nil, status: :attempting}) + answer = insert(:answer, submission: submission, question: question, answer: %{code: "f => f(f);"}) + + assert Assessments.answer_question(question, student1, "answer", false) == {:ok, nil} + end + test "assessments with questions and answers" do course = insert(:course) config = insert(:assessment_config, %{course: course}) @@ -165,8 +219,34 @@ defmodule Cadet.AssessmentsTest do student = insert(:course_registration, %{course: course, role: :student}) assert {:ok, _} = Assessments.assessment_with_questions_and_answers(assessment, student) + end + test "create empty submission for team assessment" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + team_assessment = insert(:assessment, %{config: config, course: course, max_team_size: 10, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: +5), + is_published: true}) + group = insert(:group, %{name: "group"}) + + student1 = insert(:course_registration, %{course: course, role: :student, group: group}) + student2 = insert(:course_registration, %{course: course, role: :student, group: group}) + teammember1 = insert(:team_member, %{student: student1}) + teammember2 = insert(:team_member, %{student: student2}) + team = insert(:team, %{assessment: team_assessment, team_members: [teammember1, teammember2]}) + question = insert(:question, %{assessment: team_assessment, type: :programming}) + + assert {:ok, _} = Assessments.answer_question(question, student1, "answer", false) + + submission = Submission + |> where([s], s.team_id == ^team.id) + |> Repo.all() + + assert length(submission) == 1 end + + @tag authenticate: :staff test "unsubmit team assessment" do course = insert(:course) @@ -189,6 +269,25 @@ defmodule Cadet.AssessmentsTest do assert {:ok, _} = Assessments.unsubmit_submission(submission.id, avenger) end + + @tag authenticate: :staff + test "delete team assessment with associating submission" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{config: config, course: course, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: +5), + is_published: true}) + + student = insert(:course_registration, %{course: course, role: :student}) + question = insert(:question, %{assessment: assessment}) + submission = insert(:submission, %{assessment: assessment, team: nil, student: student, status: :attempting}) + answer = insert(:answer, submission: submission, question: question, answer: %{code: "f => f(f);"}) + + assert {:ok, _} = Assessments.delete_assessment(assessment.id) + end + + end describe "contest voting" do diff --git a/test/cadet_web/controllers/teams_controller_test.exs b/test/cadet_web/controllers/teams_controller_test.exs index 687cf83ca..cbb3bf2cb 100644 --- a/test/cadet_web/controllers/teams_controller_test.exs +++ b/test/cadet_web/controllers/teams_controller_test.exs @@ -4,7 +4,7 @@ defmodule CadetWeb.TeamsControllerTest do import Ecto.Query alias Cadet.Repo - alias Cadet.Accounts.{User, CourseRegistration, Team, Teams, TeamMembers} + alias Cadet.Accounts.{CourseRegistration} alias Cadet.Courses.Course alias CadetWeb.TeamController setup do From 25b6d6c8cf88aad0a2e66b75267e6e7754ccaadc Mon Sep 17 00:00:00 2001 From: Lu Yiting Date: Fri, 19 Jan 2024 16:34:53 +0800 Subject: [PATCH 092/128] Clean up test cases --- lib/cadet/assessments/assessments.ex | 2 - .../admin_grading_controller.ex | 4 +- .../admin_teams_controller.ex | 9 --- test/cadet/accounts/notification_test.exs | 3 +- test/cadet/accounts/team_members_test.exs | 8 +- test/cadet/assessments/assessments_test.exs | 74 ++++++++++++++++--- .../controllers/teams_controller_test.exs | 3 - 7 files changed, 69 insertions(+), 34 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 81c066ba0..863b619f0 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -1804,7 +1804,6 @@ defmodule Cadet.Assessments do |> Repo.insert() |> case do {:ok, submission} -> {:ok, submission} - {:error, _} -> {:error, :race_condition} end _ -> %Submission{} @@ -1812,7 +1811,6 @@ defmodule Cadet.Assessments do |> Repo.insert() |> case do {:ok, submission} -> {:ok, submission} - {:error, _} -> {:error, :race_condition} end end end diff --git a/lib/cadet_web/admin_controllers/admin_grading_controller.ex b/lib/cadet_web/admin_controllers/admin_grading_controller.ex index beaae173b..dd464c1c1 100644 --- a/lib/cadet_web/admin_controllers/admin_grading_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_grading_controller.ex @@ -54,8 +54,8 @@ defmodule CadetWeb.AdminGradingController do ) do {:ok, _} -> text(conn, "OK") - :ok -> - text(conn, "OK") + # :ok -> + # text(conn, "OK") {:error, {status, message}} -> conn diff --git a/lib/cadet_web/admin_controllers/admin_teams_controller.ex b/lib/cadet_web/admin_controllers/admin_teams_controller.ex index b81676359..4dd7243df 100644 --- a/lib/cadet_web/admin_controllers/admin_teams_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_teams_controller.ex @@ -48,10 +48,6 @@ defmodule CadetWeb.AdminTeamsController do end end - def create(conn, %{"course_id" => course_id, "team" => team_params}) do - create(conn, %{"team" => team_params}) - end - def update(conn, %{"teamId" => teamId, "assessmentId" => assessmentId, "student_ids" => student_ids}) do team = Team |> Repo.get!(teamId) @@ -70,13 +66,8 @@ defmodule CadetWeb.AdminTeamsController do end end - def update(conn, %{"course_id" => course_id, "teamId" => teamId, "assessmentId" => assessmentId, "student_ids" => student_ids}) do - update(conn, %{"teamId" => teamId, "assessmentId" => assessmentId, "student_ids" => student_ids}) - end - def delete(conn, %{"teamId" => team_id}) do team = Repo.get(Team, team_id) - if team do case Teams.delete_team(team) do {:error, {status, error_message}} -> diff --git a/test/cadet/accounts/notification_test.exs b/test/cadet/accounts/notification_test.exs index eb6ea0ce4..f89fa6384 100644 --- a/test/cadet/accounts/notification_test.exs +++ b/test/cadet/accounts/notification_test.exs @@ -332,8 +332,7 @@ defmodule Cadet.Accounts.NotificationTest do test "no notification when no submission", %{ assessment: assessment, - student: student, - individual_submission: individual_submission + student: student } do Notifications.write_notification_when_graded(-1, :autograded) diff --git a/test/cadet/accounts/team_members_test.exs b/test/cadet/accounts/team_members_test.exs index 855c572cb..8c13b9eff 100644 --- a/test/cadet/accounts/team_members_test.exs +++ b/test/cadet/accounts/team_members_test.exs @@ -1,7 +1,7 @@ defmodule Cadet.Accounts.TeamMemberTest do use Cadet.DataCase, async: true - alias Cadet.Accounts.{TeamMember, Team, CourseRegistration} + alias Cadet.Accounts.{TeamMember} alias Cadet.Repo @valid_attrs %{student_id: 1, team_id: 1} @@ -17,21 +17,21 @@ defmodule Cadet.Accounts.TeamMemberTest do team_member = %TeamMember{} changeset = TeamMember.changeset(team_member, %{}) refute changeset.valid? - assert {:error, changeset} = Repo.insert(changeset) + assert {:error, _changeset} = Repo.insert(changeset) end test "returns an error when the team_id foreign key constraint is violated" do team_member = %TeamMember{} changeset = TeamMember.changeset(team_member, %{student_id: 1}) refute changeset.valid? - assert {:error, changeset} = Repo.insert(changeset) + assert {:error, _changeset} = Repo.insert(changeset) end test "returns an error when the student_id foreign key constraint is violated" do team_member = %TeamMember{} changeset = TeamMember.changeset(team_member, %{team_id: 1}) refute changeset.valid? - assert {:error, changeset} = Repo.insert(changeset) + assert {:error, _changeset} = Repo.insert(changeset) end end end \ No newline at end of file diff --git a/test/cadet/assessments/assessments_test.exs b/test/cadet/assessments/assessments_test.exs index a152b0c74..e4cdaec63 100644 --- a/test/cadet/assessments/assessments_test.exs +++ b/test/cadet/assessments/assessments_test.exs @@ -138,10 +138,7 @@ defmodule Cadet.AssessmentsTest do test "update grading info for assessment" do course = insert(:course) config = insert(:assessment_config, %{course: course}) - assessment = insert(:assessment, %{config: config, course: course, - # open_at: Timex.shift(Timex.now(), days: -5), - # close_at: Timex.shift(Timex.now(), hours: +5), - is_published: false}) + assessment = insert(:assessment, %{config: config, course: course, is_published: false}) student = insert(:course_registration, %{course: course, role: :student}) question = insert(:question, %{assessment: assessment}) @@ -157,8 +154,6 @@ defmodule Cadet.AssessmentsTest do open_at: Timex.shift(Timex.now(), days: -5), close_at: Timex.shift(Timex.now(), hours: +5), is_published: true}) - question1 = insert(:question, %{assessment: assessment, type: :programming}) - question2 = insert(:question, %{assessment: assessment, type: :programming}) assessment_params = %{ number: assessment.number, course_id: course.id @@ -207,7 +202,7 @@ defmodule Cadet.AssessmentsTest do teammember2 = insert(:team_member, %{student: student2}) team = insert(:team, %{assessment: assessment, team_members: [teammember1, teammember2]}) submission = insert(:submission, %{assessment: assessment, team: team, student: nil, status: :attempting}) - answer = insert(:answer, submission: submission, question: question, answer: %{code: "f => f(f);"}) + _answer = insert(:answer, submission: submission, question: question, answer: %{code: "f => f(f);"}) assert Assessments.answer_question(question, student1, "answer", false) == {:ok, nil} end @@ -221,6 +216,48 @@ defmodule Cadet.AssessmentsTest do assert {:ok, _} = Assessments.assessment_with_questions_and_answers(assessment, student) end + test "overdue assessments with questions and answers" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{ + config: config, + course: course, + max_team_size: 10, + open_at: Timex.shift(Timex.now(), days: -15), + close_at: Timex.shift(Timex.now(), days: -5), + is_published: true, + password: "123" + }) + student = insert(:course_registration, %{course: course, role: :student}) + + assert {:ok, _} = Assessments.assessment_with_questions_and_answers(assessment, student, "123") + end + + test "team assessments with questions and answers" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{ + config: config, + course: course, + max_team_size: 10, + open_at: Timex.shift(Timex.now(), days: -15), + close_at: Timex.shift(Timex.now(), days: +5), + is_published: true + }) + group = insert(:group, %{name: "group"}) + student1 = insert(:course_registration, %{course: course, role: :student, group: group}) + student2 = insert(:course_registration, %{course: course, role: :student, group: group}) + + teammember1 = insert(:team_member, %{student: student1}) + teammember2 = insert(:team_member, %{student: student2}) + team = insert(:team, %{assessment: assessment, team_members: [teammember1, teammember2]}) + submission = insert(:submission, %{assessment: assessment, team: team, student: nil, status: :submitted}) + + assert {:ok, _} = Assessments.assessment_with_questions_and_answers(assessment, student1) + assert submission.id == Assessments.get_submission(assessment.id, student1).id + end + + test "create empty submission for team assessment" do course = insert(:course) config = insert(:assessment_config, %{course: course}) @@ -263,9 +300,7 @@ defmodule Cadet.AssessmentsTest do teammember1 = insert(:team_member, %{student: student1}) teammember2 = insert(:team_member, %{student: student2}) team = insert(:team, %{assessment: team_assessment, team_members: [teammember1, teammember2]}) - question = insert(:question, %{assessment: team_assessment}) submission = insert(:submission, %{assessment: team_assessment, team: team, student: nil, status: :submitted}) - answer = insert(:answer, submission: submission, question: question, answer: %{code: "f => f(f);"}) assert {:ok, _} = Assessments.unsubmit_submission(submission.id, avenger) end @@ -278,16 +313,31 @@ defmodule Cadet.AssessmentsTest do open_at: Timex.shift(Timex.now(), days: -5), close_at: Timex.shift(Timex.now(), hours: +5), is_published: true}) - + student = insert(:course_registration, %{course: course, role: :student}) question = insert(:question, %{assessment: assessment}) submission = insert(:submission, %{assessment: assessment, team: nil, student: student, status: :attempting}) - answer = insert(:answer, submission: submission, question: question, answer: %{code: "f => f(f);"}) + _answer = insert(:answer, submission: submission, question: question, answer: %{code: "f => f(f);"}) assert {:ok, _} = Assessments.delete_assessment(assessment.id) end - + test "get user xp for team assessment" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + team_assessment = insert(:assessment, %{config: config, course: course, max_team_size: 10, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: +5), + is_published: true}) + group = insert(:group, %{name: "group"}) + + student1 = insert(:course_registration, %{course: course, role: :student, group: group}) + student2 = insert(:course_registration, %{course: course, role: :student, group: group}) + teammember1 = insert(:team_member, %{student: student1}) + teammember2 = insert(:team_member, %{student: student2}) + _team = insert(:team, %{assessment: team_assessment, team_members: [teammember1, teammember2]}) + assert Assessments.assessments_total_xp(student1) == 0 + end end describe "contest voting" do diff --git a/test/cadet_web/controllers/teams_controller_test.exs b/test/cadet_web/controllers/teams_controller_test.exs index cbb3bf2cb..2b48c2ebd 100644 --- a/test/cadet_web/controllers/teams_controller_test.exs +++ b/test/cadet_web/controllers/teams_controller_test.exs @@ -1,10 +1,7 @@ defmodule CadetWeb.TeamsControllerTest do use CadetWeb.ConnCase - import Ecto.Query - alias Cadet.Repo - alias Cadet.Accounts.{CourseRegistration} alias Cadet.Courses.Course alias CadetWeb.TeamController setup do From d6ca36475ef6586c9b47ffbd2186fa1f2a84ba21 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Sat, 20 Jan 2024 19:15:29 +0800 Subject: [PATCH 093/128] Update Swagger Documentation --- lib/cadet/assessments/assessments.ex | 2 +- .../admin_teams_controller.ex | 125 +++++++++--------- lib/cadet_web/controllers/team_controller.ex | 31 ++--- 3 files changed, 81 insertions(+), 77 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 863b619f0..524bf6d0a 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -109,7 +109,7 @@ defmodule Cadet.Assessments do query = from(t in Team, join: tm in assoc(t, :team_members), - where: tm.student_id == ^cr_id, + where: tm.student_id == ^cr_id ) teams = Repo.all(query) diff --git a/lib/cadet_web/admin_controllers/admin_teams_controller.ex b/lib/cadet_web/admin_controllers/admin_teams_controller.ex index 4dd7243df..128e585de 100644 --- a/lib/cadet_web/admin_controllers/admin_teams_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_teams_controller.ex @@ -95,19 +95,19 @@ defmodule CadetWeb.AdminTeamsController do security([%{JWT: []}]) - response(200, "OK", Schema.array(:TeamsList)) + response(200, "OK", :Teams) response(400, "Bad Request") response(403, "Forbidden") end swagger_path :create do - post("/admin/teams") + post("/courses/{course_id}/admin/teams") summary("Creates a new team") security([%{JWT: []}]) - consumes("application/json") # Adjust the content type if applicable + consumes("application/json") parameters do team_params(:body, :AdminCreateTeamPayload, "Team parameters", required: true) @@ -115,13 +115,15 @@ defmodule CadetWeb.AdminTeamsController do response(201, "Created") response(400, "Bad Request") + response(401, "Unauthorised") response(403, "Forbidden") + response(409, "Conflict") end swagger_path :update do - post("/admin/teams/{teamId}") + post("/courses/{course_id}/admin/teams/{teamId}") - summary("Updates a team") + summary("Updates an existing team") security([%{JWT: []}]) @@ -137,13 +139,15 @@ defmodule CadetWeb.AdminTeamsController do response(200, "OK") response(400, "Bad Request") + response(401, "Unauthorised") response(403, "Forbidden") + response(409, "Conflict") end swagger_path :delete do - PhoenixSwagger.Path.delete("/admin/teams/{teamId}") + PhoenixSwagger.Path.delete("/courses/{course_id}/admin/teams/{teamId}") - summary("Deletes a team") + summary("Deletes an existing team") security([%{JWT: []}]) @@ -153,65 +157,64 @@ defmodule CadetWeb.AdminTeamsController do response(200, "OK") response(400, "Bad Request") + response(401, "Unauthorised") response(403, "Forbidden") + response(409, "Conflict") end def swagger_definitions do %{ - # Schemas for payloads to create or modify data - AdminCreateTeamPayload: %{ - "type" => "object", - "properties" => %{ - "name" => %{"type" => "string", "description" => "Team name"}, - "course" => %{"type" => "string", "description" => "Course name"}, - "other_property" => %{"type" => "string", "description" => "Other relevant property"} - }, - "required" => ["name", "course"] - }, - AdminUpdateTeamPayload: %{ - "type" => "object", - "properties" => %{ - "teamId" => %{"type" => "number", "description" => "The existing team id"}, - "assessmentId" => %{"type" => "number", "description" => "The updated assessment id"}, - "student_ids" => %{ - "type" => "array", - "items" => %{"$ref" => "#/definitions/AdminUpdateStudentId"}, - "description" => "The updated student ids" - } - }, - "required" => ["teamId", "assessmentId", "student_ids"] - }, - AdminUpdateStudentId: %{ - "type" => "object", - "properties" => %{ - "id" => %{"type" => "number", "description" => "Student ID"} - }, - "required" => ["id"] - }, - TeamList: %{ - "type" => "array", - "items" => %{"$ref" => "#/definitions/TeamItem"} - }, - TeamItem: %{ - "type" => "object", - "properties" => %{ - "teamId" => %{"type" => "integer", "description" => "Team ID"}, - "assessmentId" => %{"type" => "integer", "description" => "Assessment ID"}, - "assessmentName" => %{"type" => "string", "description" => "Assessment name"}, - "assessmentType" => %{"type" => "string", "description" => "Assessment type"}, - "studentIds" => %{ - "type" => "array", - "items" => %{"type" => "integer"}, - "description" => "Student IDs" - }, - "studentNames" => %{ - "type" => "array", - "items" => %{"type" => "string"}, - "description" => "Student names" - } - }, - "required" => ["teamId", "assessmentId", "assessmentName", "assessmentType", "studentIds", "studentNames"] - } + AdminCreateTeamPayload: + swagger_schema do + properties do + assessmentId(:integer, "Assessment ID") + studentIds(:array, "Student IDs", items: %{type: :integer}) + end + required [:assessmentId, :studentIds] + end, + AdminUpdateTeamPayload: + swagger_schema do + properties do + teamId(:integer, "Team ID") + assessmentId(:integer, "Assessment ID") + studentIds(:integer, "Student IDs", items: %{type: :integer}) + end + required [:teamId, :assessmentId, :studentIds] + end, + Teams: + swagger_schema do + type(:array) + items(Schema.ref(:Team)) + end, + Team: + swagger_schema do + properties do + id(:integer, "Team Id") + assessment( + Schema.ref(:Assessment) + ) + team_members(Schema.ref(:TeamMembers)) + end + required [:id, :assessment, :team_members] + end, + TeamMembers: + swagger_schema do + type(:array) + items(Schema.ref(:TeamMember)) + end, + TeamMember: + swagger_schema do + properties do + id(:integer, "Team Member Id") + student( + Schema.ref(:CourseRegistration) + ) + team( + Schema.ref(:Team) + ) + end + required [:id, :student, :team] + end } end end diff --git a/lib/cadet_web/controllers/team_controller.ex b/lib/cadet_web/controllers/team_controller.ex index 4c558e95d..9b3e73f6f 100644 --- a/lib/cadet_web/controllers/team_controller.ex +++ b/lib/cadet_web/controllers/team_controller.ex @@ -9,8 +9,7 @@ defmodule CadetWeb.TeamController do import Ecto.Query alias Cadet.Repo - alias Cadet.Accounts.{Teams, Team} - alias CadetWeb.Router.Helpers, as: Routes + alias Cadet.Accounts.Team def index(conn, %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do cr = conn.assigns.course_reg @@ -70,18 +69,20 @@ defmodule CadetWeb.TeamController do response(403, "Forbidden") end - @swagger_definitions %{ - TeamFormationOverview: %{ - "type" => "object", - "properties" => %{ - "teamId" => %{"type" => "number", "description" => "The ID of the team"}, - "assessmentId" => %{"type" => "number", "description" => "The ID of the assessment"}, - "assessmentName" => %{"type" => "string", "description" => "The name of the assessment"}, - "assessmentType" => %{"type" => "string", "description" => "The type of the assessment"}, - "studentIds" => %{"type" => "array", "items" => %{"type" => "number"}, "description" => "List of student IDs"}, - "studentNames" => %{"type" => "array", "items" => %{"type" => "string"}, "description" => "List of student names"} - }, - "required" => ["teamId", "assessmentId", "assessmentName", "assessmentType", "studentIds", "studentNames"] + def swagger_definitions do + %{ + TeamFormationOverview: %{ + "type" => "object", + "properties" => %{ + "teamId" => %{"type" => "number", "description" => "The ID of the team"}, + "assessmentId" => %{"type" => "number", "description" => "The ID of the assessment"}, + "assessmentName" => %{"type" => "string", "description" => "The name of the assessment"}, + "assessmentType" => %{"type" => "string", "description" => "The type of the assessment"}, + "studentIds" => %{"type" => "array", "items" => %{"type" => "number"}, "description" => "List of student IDs"}, + "studentNames" => %{"type" => "array", "items" => %{"type" => "string"}, "description" => "List of student names"} + }, + "required" => ["teamId", "assessmentId", "assessmentName", "assessmentType", "studentIds", "studentNames"] + } } - } + end end From 317c62158e2ba5d1cb283bcc75c30d8a0f8fda62 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Sat, 20 Jan 2024 19:23:15 +0800 Subject: [PATCH 094/128] Modify AddMaxTeamSizeToAssessments migration file --- .../20230704125701_add_max_team_size_to_assessments.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/priv/repo/migrations/20230704125701_add_max_team_size_to_assessments.exs b/priv/repo/migrations/20230704125701_add_max_team_size_to_assessments.exs index 6ecfa9cea..6a9532298 100644 --- a/priv/repo/migrations/20230704125701_add_max_team_size_to_assessments.exs +++ b/priv/repo/migrations/20230704125701_add_max_team_size_to_assessments.exs @@ -3,7 +3,7 @@ defmodule Cadet.Repo.Migrations.AddMaxTeamSizeToAssessments do def change do alter table(:assessments) do - add(:max_team_size, :integer) + add(:max_team_size, :integer, null: false, default: 1) end end end \ No newline at end of file From 82ff5f1393ce24b9195a9cd62efc995abe7df84a Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Sat, 20 Jan 2024 19:31:00 +0800 Subject: [PATCH 095/128] Remove unused variable in notification test --- test/cadet/accounts/notification_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/cadet/accounts/notification_test.exs b/test/cadet/accounts/notification_test.exs index f89fa6384..6c90774bf 100644 --- a/test/cadet/accounts/notification_test.exs +++ b/test/cadet/accounts/notification_test.exs @@ -14,8 +14,8 @@ defmodule Cadet.Accounts.NotificationTest do individual_submission = insert(:submission, %{student: student, assessment: assessment}) team = insert(:team) - _team_member1 = insert(:team_member, %{team: team}) - _team_member2 = insert(:team_member, %{team: team}) + insert(:team_member, %{team: team}) + insert(:team_member, %{team: team}) team_submission = insert(:submission, %{team: team, assessment: assessment, student: nil}) valid_params_for_student = %{ From 7d31d8fb4829157bb2e86d8a7911851929bdebfb Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Sat, 20 Jan 2024 23:13:42 +0800 Subject: [PATCH 096/128] Fix format --- lib/cadet/accounts/notifications.ex | 43 +- lib/cadet/accounts/team.ex | 1 - lib/cadet/accounts/team_member.ex | 1 - lib/cadet/accounts/teams.ex | 81 +- lib/cadet/assessments/assessment.ex | 1 + lib/cadet/assessments/assessments.ex | 100 ++- lib/cadet/assessments/submission.ex | 8 +- .../admin_assessments_controller.ex | 2 +- .../admin_grading_controller.ex | 1 + .../admin_teams_controller.ex | 61 +- .../admin_views/admin_grading_view.ex | 33 +- lib/cadet_web/admin_views/admin_teams_view.ex | 5 +- lib/cadet_web/admin_views/admin_user_view.ex | 2 +- .../controllers/answer_controller.ex | 8 +- lib/cadet_web/controllers/team_controller.ex | 35 +- lib/cadet_web/router.ex | 7 +- lib/cadet_web/views/answer_view.ex | 2 +- ...25701_add_max_team_size_to_assessments.exs | 2 +- ...805094759_add_last_modified_to_answers.exs | 2 +- test/cadet/accounts/notification_test.exs | 12 +- test/cadet/accounts/team_members_test.exs | 2 +- test/cadet/accounts/teams_test.exs | 830 +++++++++++------- test/cadet/assessments/assessment_test.exs | 3 +- test/cadet/assessments/assessments_test.exs | 191 +++- test/cadet/assessments/submission_test.exs | 27 +- test/cadet/auth/empty_guardian_test.exs | 2 +- .../admin_grading_controller_test.exs | 4 +- .../admin_teams_controller_test.exs | 99 ++- .../controllers/answer_controller_test.exs | 63 +- .../controllers/teams_controller_test.exs | 27 +- test/cadet_web/views/answer_view_test.exs | 2 +- test/cadet_web/views/team_view_test.exs | 2 +- test/factories/accounts/team_factory.ex | 1 - .../factories/accounts/team_member_factory.ex | 1 - test/factories/factory.ex | 8 +- 35 files changed, 1098 insertions(+), 571 deletions(-) diff --git a/lib/cadet/accounts/notifications.ex b/lib/cadet/accounts/notifications.ex index bcefc0f02..e66482df2 100644 --- a/lib/cadet/accounts/notifications.ex +++ b/lib/cadet/accounts/notifications.ex @@ -172,17 +172,22 @@ defmodule Cadet.Accounts.Notifications do case Repo.get(Submission, submission_id) do nil -> {:error, %Ecto.Changeset{}} + submission -> case submission.student_id do nil -> team = Repo.get(Team, submission.team_id) + query = from(t in Team, - join: tm in TeamMember, on: t.id == tm.team_id, - join: cr in CourseRegistration, on: tm.student_id == cr.id, + join: tm in TeamMember, + on: t.id == tm.team_id, + join: cr in CourseRegistration, + on: tm.student_id == cr.id, where: t.id == ^team.id, select: cr.id ) + team_members = Repo.all(query) Enum.each(team_members, fn tm_id -> @@ -194,6 +199,7 @@ defmodule Cadet.Accounts.Notifications do assessment_id: submission.assessment_id }) end) + student_id -> write(%{ type: type, @@ -247,21 +253,24 @@ defmodule Cadet.Accounts.Notifications do @spec write_notification_when_student_submits(Submission.t()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} def write_notification_when_student_submits(submission = %Submission{}) do - id = case submission.student_id do - nil -> - team_id = String.to_integer(to_string(submission.team_id)) - team = - from(t in Team, - where: t.id == ^team_id, - preload: [:team_members] - ) - |> Repo.one() - - s_id = team.team_members |> hd() |> Map.get(:student_id) - s_id - _ -> - submission.student_id - end + id = + case submission.student_id do + nil -> + team_id = String.to_integer(to_string(submission.team_id)) + + team = + from(t in Team, + where: t.id == ^team_id, + preload: [:team_members] + ) + |> Repo.one() + + s_id = team.team_members |> hd() |> Map.get(:student_id) + s_id + + _ -> + submission.student_id + end avenger_id = get_avenger_id_of(id) diff --git a/lib/cadet/accounts/team.ex b/lib/cadet/accounts/team.ex index b9d8e3027..53384958d 100644 --- a/lib/cadet/accounts/team.ex +++ b/lib/cadet/accounts/team.ex @@ -18,7 +18,6 @@ defmodule Cadet.Accounts.Team do - `team_members`: A list of team members associated with the team. """ schema "teams" do - belongs_to(:assessment, Assessment) has_one(:submission, Submission, on_delete: :delete_all) has_many(:team_members, TeamMember, on_delete: :delete_all) diff --git a/lib/cadet/accounts/team_member.ex b/lib/cadet/accounts/team_member.ex index 6fdb7e9fa..8903cdb80 100644 --- a/lib/cadet/accounts/team_member.ex +++ b/lib/cadet/accounts/team_member.ex @@ -19,7 +19,6 @@ defmodule Cadet.Accounts.TeamMember do - `team`: A reference to the team associated with the student. """ schema "team_members" do - belongs_to(:student, CourseRegistration) belongs_to(:team, Team) diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index d4e03dcd1..299632a9b 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -23,7 +23,7 @@ defmodule Cadet.Accounts.Teams do ## Returns Returns a tuple `{:ok, team}` on success; otherwise, an error tuple. - + """ def create_team(attrs) do assessment_id = attrs["assessment_id"] @@ -39,25 +39,30 @@ defmodule Cadet.Accounts.Teams do !all_student_enrolled_in_course?(teams, assessment.course_id) -> {:error, {:conflict, "One or more students not enrolled in this course!"}} - + student_already_assigned?(teams, assessment_id) -> {:error, {:conflict, "One or more students already in a team for this assessment!"}} - true -> + true -> Enum.reduce_while(attrs["student_ids"], {:ok, nil}, fn team_attrs, {:ok, _} -> student_ids = Enum.map(team_attrs, &Map.get(&1, "userId")) - {:ok, team} = %Team{} - |> Team.changeset(attrs) - |> Repo.insert() + {:ok, team} = + %Team{} + |> Team.changeset(attrs) + |> Repo.insert() + team_id = team.id + Enum.each(team_attrs, fn student -> student_id = Map.get(student, "userId") attributes = %{student_id: student_id, team_id: team_id} + %TeamMember{} |> cast(attributes, [:student_id, :team_id]) |> Repo.insert() end) + {:cont, {:ok, team}} end) end @@ -100,7 +105,8 @@ defmodule Cadet.Accounts.Teams do """ defp all_students_distinct?(team_attrs) do - all_ids = team_attrs + all_ids = + team_attrs |> Enum.flat_map(fn team -> Enum.map(team, fn row -> Map.get(row, "userId") end) end) @@ -124,7 +130,7 @@ defmodule Cadet.Accounts.Teams do Returns `true` if all the teams have size less or equal to the max team size; otherwise, returns `false`. """ - defp all_team_within_max_size?(teams, max_team_size) do + defp all_team_within_max_size?(teams, max_team_size) do Enum.all?(teams, fn team -> ids = Enum.map(team, &Map.get(&1, "userId")) length(ids) <= max_team_size @@ -145,14 +151,17 @@ defmodule Cadet.Accounts.Teams do """ defp all_student_enrolled_in_course?(teams, course_id) do - all_ids = teams + all_ids = + teams |> Enum.flat_map(fn team -> Enum.map(team, fn row -> Map.get(row, "userId") end) end) - query = from(cr in Cadet.Accounts.CourseRegistration, - where: cr.id in ^all_ids and cr.course_id == ^course_id, - select: count(cr.id)) + query = + from(cr in Cadet.Accounts.CourseRegistration, + where: cr.id in ^all_ids and cr.course_id == ^course_id, + select: count(cr.id) + ) count = Repo.one(query) count == length(all_ids) @@ -170,14 +179,16 @@ defmodule Cadet.Accounts.Teams do ## Returns Returns `true` if any student in the list is already a member of another team for the same assessment; otherwise, returns `false`. - + """ defp student_already_in_team?(team_id, student_ids, assessment_id) do query = - from tm in TeamMember, + from(tm in TeamMember, join: t in assoc(tm, :team), - where: tm.student_id in ^student_ids and t.assessment_id == ^assessment_id and t.id != ^team_id, + where: + tm.student_id in ^student_ids and t.assessment_id == ^assessment_id and t.id != ^team_id, select: tm.student_id + ) existing_student_ids = Repo.all(query) @@ -202,8 +213,10 @@ defmodule Cadet.Accounts.Teams do old_assessment_id = team.assessment_id team_id = team.id new_student_ids = Enum.map(hd(student_ids), fn student -> Map.get(student, "userId") end) + if student_already_in_team?(team_id, new_student_ids, new_assessment_id) do - {:error, {:conflict, "One or more students are already in another team for the same assessment!"}} + {:error, + {:conflict, "One or more students are already in another team for the same assessment!"}} else attrs = %{assessment_id: new_assessment_id} @@ -215,7 +228,6 @@ defmodule Cadet.Accounts.Teams do |> Repo.update() |> case do {:ok, updated_team} -> - update_team_members(updated_team, student_ids, team_id) {:ok, updated_team} end @@ -233,15 +245,19 @@ defmodule Cadet.Accounts.Teams do """ defp update_team_members(team, student_ids, team_id) do - current_student_ids = team.team_members |> Enum.map(&(&1.student_id)) - new_student_ids = Enum.map(hd(student_ids), fn student -> Map.get(student, "userId") end) + current_student_ids = team.team_members |> Enum.map(& &1.student_id) + new_student_ids = Enum.map(hd(student_ids), fn student -> Map.get(student, "userId") end) - student_ids_to_add = Enum.filter(new_student_ids, fn elem -> not Enum.member?(current_student_ids, elem) end) - student_ids_to_remove = Enum.filter(current_student_ids, fn elem -> not Enum.member?(new_student_ids, elem) end) + student_ids_to_add = + Enum.filter(new_student_ids, fn elem -> not Enum.member?(current_student_ids, elem) end) + + student_ids_to_remove = + Enum.filter(current_student_ids, fn elem -> not Enum.member?(new_student_ids, elem) end) Enum.each(student_ids_to_add, fn student_id -> %TeamMember{} - |> Ecto.Changeset.change(%{team_id: team_id, student_id: student_id}) # Change here + # Change here + |> Ecto.Changeset.change(%{team_id: team_id, student_id: student_id}) |> Repo.insert() end) @@ -260,13 +276,13 @@ defmodule Cadet.Accounts.Teams do """ def delete_team(%Team{} = team) do - if (has_submitted_answer?(team.id)) do + if has_submitted_answer?(team.id) do {:error, {:conflict, "This team has submitted their answers! Unable to delete the team!"}} else - - submission = Submission - |> where(team_id: ^team.id) - |> Repo.one() + submission = + Submission + |> where(team_id: ^team.id) + |> Repo.one() if submission do Submission @@ -300,11 +316,12 @@ defmodule Cadet.Accounts.Teams do Returns `true` if any one of the submission has the status of "submitted", `false` otherwise """ - defp has_submitted_answer?(team_id) do - submission = Submission - |> where([s], s.team_id == ^team_id and s.status == :submitted) - |> Repo.all() + defp has_submitted_answer?(team_id) do + submission = + Submission + |> where([s], s.team_id == ^team_id and s.status == :submitted) + |> Repo.all() length(submission) > 0 end -end \ No newline at end of file +end diff --git a/lib/cadet/assessments/assessment.ex b/lib/cadet/assessments/assessment.ex index 81f34973b..5e5c36233 100644 --- a/lib/cadet/assessments/assessment.ex +++ b/lib/cadet/assessments/assessment.ex @@ -67,6 +67,7 @@ defmodule Cadet.Assessments.Assessment do defp validate_config_course(changeset) do config_id = get_field(changeset, :config_id) course_id = get_field(changeset, :course_id) + case Repo.get(AssessmentConfig, config_id) do nil -> add_error(changeset, :config, "does not exist") diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 328dbc392..172e13c62 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -111,13 +111,14 @@ defmodule Cadet.Assessments do join: tm in assoc(t, :team_members), where: tm.student_id == ^cr_id ) + teams = Repo.all(query) submission_xp = Submission |> where( [s], - s.student_id == ^cr_id or s.team_id in ^Enum.map(teams, &(&1.id)) + s.student_id == ^cr_id or s.team_id in ^Enum.map(teams, & &1.id) ) |> join(:inner, [s], a in Answer, on: s.id == a.submission_id) |> group_by([s], s.id) @@ -271,7 +272,6 @@ defmodule Cadet.Assessments do assessment = %Assessment{id: id}, course_reg = %CourseRegistration{role: role} ) do - query = from(t in Team, where: t.assessment_id == ^assessment.id, @@ -279,7 +279,9 @@ defmodule Cadet.Assessments do where: tm.student_id == ^course_reg.id, limit: 1 ) + team = Repo.one(query) + team_id = if team do team.id @@ -330,6 +332,7 @@ defmodule Cadet.Assessments do join: tm in assoc(t, :team_members), where: tm.student_id == ^cr.id ) + teams = Repo.all(query) submission_aggregates = @@ -337,7 +340,7 @@ defmodule Cadet.Assessments do |> join(:left, [s], ans in Answer, on: ans.submission_id == s.id) |> where( [s], - s.student_id == ^cr.id or s.team_id in ^Enum.map(teams, &(&1.id)) + s.student_id == ^cr.id or s.team_id in ^Enum.map(teams, & &1.id) ) |> group_by([s], s.assessment_id) |> select([s, ans], %{ @@ -351,7 +354,7 @@ defmodule Cadet.Assessments do Submission |> where( [s], - s.student_id == ^cr.id or s.team_id in ^Enum.map(teams, &(&1.id)) + s.student_id == ^cr.id or s.team_id in ^Enum.map(teams, & &1.id) ) |> select([s], [:assessment_id, :status]) @@ -833,34 +836,39 @@ defmodule Cadet.Assessments do end defp find_team(assessment_id, cr_id) - when is_ecto_id(assessment_id) and is_ecto_id(cr_id) do - query = - from(t in Team, - where: t.assessment_id == ^assessment_id, - join: tm in assoc(t, :team_members), - where: tm.student_id == ^cr_id, - limit: 1 - ) - assessment_team_size = - Repo.one( - from(a in Assessment, where: a.id == ^assessment_id, select: %{max_team_size: a.max_team_size}) + when is_ecto_id(assessment_id) and is_ecto_id(cr_id) do + query = + from(t in Team, + where: t.assessment_id == ^assessment_id, + join: tm in assoc(t, :team_members), + where: tm.student_id == ^cr_id, + limit: 1 + ) + + assessment_team_size = + Repo.one( + from(a in Assessment, + where: a.id == ^assessment_id, + select: %{max_team_size: a.max_team_size} ) - |> Map.get(:max_team_size, 0) + ) + |> Map.get(:max_team_size, 0) - case assessment_team_size > 1 do - true -> - case Repo.one(query) do - nil -> {:error, :team_not_found} - team -> {:ok, team} - end - # team is nil for individual assessments - false -> - {:ok, nil} - end + case assessment_team_size > 1 do + true -> + case Repo.one(query) do + nil -> {:error, :team_not_found} + team -> {:ok, team} + end + + # team is nil for individual assessments + false -> + {:ok, nil} end + end def get_submission(assessment_id, %CourseRegistration{id: cr_id}) - when is_ecto_id(assessment_id) do + when is_ecto_id(assessment_id) do query = from(t in Team, where: t.assessment_id == ^assessment_id, @@ -868,7 +876,9 @@ defmodule Cadet.Assessments do where: tm.student_id == ^cr_id, limit: 1 ) + team = Repo.one(query) + case team do %Team{} -> Submission @@ -877,6 +887,7 @@ defmodule Cadet.Assessments do |> join(:inner, [s], a in assoc(s, :assessment)) |> preload([_, a], assessment: a) |> Repo.one() + _ -> Submission |> where(assessment_id: ^assessment_id) @@ -984,15 +995,20 @@ defmodule Cadet.Assessments do |> Repo.transaction() case submission.student_id do - nil -> # Team submission, handle notifications for team members + # Team submission, handle notifications for team members + nil -> team = Repo.get(Team, submission.team_id) + query = from(t in Team, - join: tm in TeamMember, on: t.id == tm.team_id, - join: cr in CourseRegistration, on: tm.student_id == cr.id, + join: tm in TeamMember, + on: t.id == tm.team_id, + join: cr in CourseRegistration, + on: tm.student_id == cr.id, where: t.id == ^team.id, select: cr.id ) + team_members = Repo.all(query) Enum.each(team_members, fn tm_id -> @@ -1353,8 +1369,14 @@ defmodule Cadet.Assessments do {:unauthorized, "Forbidden."}} """ @spec all_submissions_by_grader_for_index(CourseRegistration.t()) :: - {:ok, %{:assessments => [any()], :submissions => [any()], :users => [any()], - :teams => [any()], :team_members => [any()]}} + {:ok, + %{ + :assessments => [any()], + :submissions => [any()], + :users => [any()], + :teams => [any()], + :team_members => [any()] + }} def all_submissions_by_grader_for_index( grader = %CourseRegistration{course_id: course_id}, group_only \\ false, @@ -1492,7 +1514,8 @@ defmodule Cadet.Assessments do |> preload([_, q, ast, ac, g, gu, s, st, u, t, tm, tms, tmu], question: {q, assessment: {ast, config: ac}}, grader: {g, user: gu}, - submission: {s, student: {st, user: u}, team: {t, team_members: {tm, student: {tms, user: tmu}}}} + submission: + {s, student: {st, user: u}, team: {t, team_members: {tm, student: {tms, user: tmu}}}} ) answers = @@ -1671,6 +1694,7 @@ defmodule Cadet.Assessments do where: tm.student_id == ^cr.id, limit: 1 ) + team = Repo.one(query) submission = @@ -1795,6 +1819,7 @@ defmodule Cadet.Assessments do where: tm.student_id == ^cr.id, limit: 1 ) + team = Repo.one(query) case team do @@ -1805,6 +1830,7 @@ defmodule Cadet.Assessments do |> case do {:ok, submission} -> {:ok, submission} end + _ -> %Submission{} |> Submission.changeset(%{student: cr, assessment: assessment}) @@ -1845,7 +1871,9 @@ defmodule Cadet.Assessments do Repo.insert( answer_changeset, - on_conflict: [set: [answer: get_change(answer_changeset, :answer), last_modified_at: Timex.now()]], + on_conflict: [ + set: [answer: get_change(answer_changeset, :answer), last_modified_at: Timex.now()] + ], conflict_target: [:submission_id, :question_id] ) end @@ -1859,7 +1887,7 @@ defmodule Cadet.Assessments do ) do with {:ok, submission} <- find_or_create_submission(cr, question.assessment), {:status, true} <- {:status, force_submit or submission.status != :submitted}, - {:ok, is_modified} <- answer_last_modified?(submission, question, last_modified_at) do + {:ok, is_modified} <- answer_last_modified?(submission, question, last_modified_at) do {:ok, is_modified} else {:status, _} -> @@ -1882,9 +1910,9 @@ defmodule Cadet.Assessments do last_modified_at ) do case Repo.get_by(Answer, submission_id: submission.id, question_id: question.id) do - %Answer{last_modified_at: existing_last_modified_at} -> existing_iso8601 = DateTime.to_iso8601(existing_last_modified_at) + if existing_iso8601 == last_modified_at do {:ok, false} else diff --git a/lib/cadet/assessments/submission.ex b/lib/cadet/assessments/submission.ex index 8b9acb8e0..9fd80d97b 100644 --- a/lib/cadet/assessments/submission.ex +++ b/lib/cadet/assessments/submission.ex @@ -48,19 +48,21 @@ defmodule Cadet.Assessments.Submission do |> foreign_key_constraint(:student_id) end - defp validate_xor_relationship(changeset) do case {get_field(changeset, :student_id), get_field(changeset, :team_id)} do {nil, nil} -> - changeset + changeset |> add_error(:student_id, "either student or team_id must be present") |> add_error(:team_id, "either student_id or team must be present") + {nil, _} -> changeset + {_, nil} -> changeset + {_student, _team} -> - changeset + changeset |> add_error(:student_id, "student and team_id cannot be present at the same time") |> add_error(:team_id, "student_id and team cannot be present at the same time") end diff --git a/lib/cadet_web/admin_controllers/admin_assessments_controller.ex b/lib/cadet_web/admin_controllers/admin_assessments_controller.ex index 2db9d1667..83ca0b032 100644 --- a/lib/cadet_web/admin_controllers/admin_assessments_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_assessments_controller.ex @@ -90,7 +90,7 @@ defmodule CadetWeb.AdminAssessmentsController do else %{:is_published => is_published} end - + updated_assessment = if is_nil(max_team_size) do updated_assessment diff --git a/lib/cadet_web/admin_controllers/admin_grading_controller.ex b/lib/cadet_web/admin_controllers/admin_grading_controller.ex index dd464c1c1..1bc47664e 100644 --- a/lib/cadet_web/admin_controllers/admin_grading_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_grading_controller.ex @@ -54,6 +54,7 @@ defmodule CadetWeb.AdminGradingController do ) do {:ok, _} -> text(conn, "OK") + # :ok -> # text(conn, "OK") diff --git a/lib/cadet_web/admin_controllers/admin_teams_controller.ex b/lib/cadet_web/admin_controllers/admin_teams_controller.ex index 128e585de..94c0769b5 100644 --- a/lib/cadet_web/admin_controllers/admin_teams_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_teams_controller.ex @@ -6,11 +6,13 @@ defmodule CadetWeb.AdminTeamsController do alias Cadet.Accounts.{Teams, Team} def index(conn, _params) do - teams = Team - |> Repo.all() - |> Repo.preload([assessment: [:config], team_members: [student: [:user]]]) + teams = + Team + |> Repo.all() + |> Repo.preload(assessment: [:config], team_members: [student: [:user]]) - teamFormationOverviews = teams + teamFormationOverviews = + teams |> Enum.map(&team_to_team_formation_overview/1) conn @@ -27,8 +29,8 @@ defmodule CadetWeb.AdminTeamsController do assessmentId: assessment.id, assessmentName: assessment.title, assessmentType: assessment.config.type, - studentIds: team.team_members |> Enum.map(&(&1.student.user.id)), - studentNames: team.team_members |> Enum.map(&(&1.student.user.name)) + studentIds: team.team_members |> Enum.map(& &1.student.user.id), + studentNames: team.team_members |> Enum.map(& &1.student.user.name) } teamFormationOverview @@ -48,10 +50,15 @@ defmodule CadetWeb.AdminTeamsController do end end - def update(conn, %{"teamId" => teamId, "assessmentId" => assessmentId, "student_ids" => student_ids}) do - team = Team - |> Repo.get!(teamId) - |> Repo.preload([assessment: [:config], team_members: [student: [:user]]]) + def update(conn, %{ + "teamId" => teamId, + "assessmentId" => assessmentId, + "student_ids" => student_ids + }) do + team = + Team + |> Repo.get!(teamId) + |> Repo.preload(assessment: [:config], team_members: [student: [:user]]) case Teams.update_team(team, assessmentId, student_ids) do {:ok, _updated_team} -> @@ -68,12 +75,14 @@ defmodule CadetWeb.AdminTeamsController do def delete(conn, %{"teamId" => team_id}) do team = Repo.get(Team, team_id) + if team do case Teams.delete_team(team) do {:error, {status, error_message}} -> conn |> put_status(status) |> text(error_message) + {:ok, _} -> text(conn, "Team deleted successfully.") end @@ -132,9 +141,7 @@ defmodule CadetWeb.AdminTeamsController do parameters do teamId(:path, :integer, "Team ID", required: true) - team(:body, Schema.ref(:AdminUpdateTeamPayload), "Updated team details", - required: true - ) + team(:body, Schema.ref(:AdminUpdateTeamPayload), "Updated team details", required: true) end response(200, "OK") @@ -170,32 +177,33 @@ defmodule CadetWeb.AdminTeamsController do assessmentId(:integer, "Assessment ID") studentIds(:array, "Student IDs", items: %{type: :integer}) end - required [:assessmentId, :studentIds] + + required([:assessmentId, :studentIds]) end, - AdminUpdateTeamPayload: + AdminUpdateTeamPayload: swagger_schema do properties do teamId(:integer, "Team ID") assessmentId(:integer, "Assessment ID") studentIds(:integer, "Student IDs", items: %{type: :integer}) end - required [:teamId, :assessmentId, :studentIds] + + required([:teamId, :assessmentId, :studentIds]) end, Teams: swagger_schema do type(:array) items(Schema.ref(:Team)) end, - Team: + Team: swagger_schema do properties do id(:integer, "Team Id") - assessment( - Schema.ref(:Assessment) - ) + assessment(Schema.ref(:Assessment)) team_members(Schema.ref(:TeamMembers)) end - required [:id, :assessment, :team_members] + + required([:id, :assessment, :team_members]) end, TeamMembers: swagger_schema do @@ -206,14 +214,11 @@ defmodule CadetWeb.AdminTeamsController do swagger_schema do properties do id(:integer, "Team Member Id") - student( - Schema.ref(:CourseRegistration) - ) - team( - Schema.ref(:Team) - ) + student(Schema.ref(:CourseRegistration)) + team(Schema.ref(:Team)) end - required [:id, :student, :team] + + required([:id, :student, :team]) end } end diff --git a/lib/cadet_web/admin_views/admin_grading_view.ex b/lib/cadet_web/admin_views/admin_grading_view.ex index 51e4ae5e0..3ef0db8e0 100644 --- a/lib/cadet_web/admin_views/admin_grading_view.ex +++ b/lib/cadet_web/admin_views/admin_grading_view.ex @@ -19,9 +19,12 @@ defmodule CadetWeb.AdminGradingView do assessment = assessments |> Enum.find(&(&1.id == submission.assessment_id)) team = teams |> Enum.find(&(&1.id == submission.team_id)) team_members = team_members |> Enum.filter(&(&1.team_id == submission.team_id)) - team_member_users = team_members |> Enum.map(fn team_member -> - users |> Enum.find(&(&1.id == team_member.student_id)) - end) + + team_member_users = + team_members + |> Enum.map(fn team_member -> + users |> Enum.find(&(&1.id == team_member.student_id)) + end) render( CadetWeb.AdminGradingView, @@ -68,7 +71,11 @@ defmodule CadetWeb.AdminGradingView do assessment: render_one(a, CadetWeb.AdminGradingView, "gradingsummaryassessment.json", as: :assessment), student: render_one(user, CadetWeb.AdminGradingView, "gradingsummaryuser.json", as: :cr), - team: render_one(team, CadetWeb.AdminGradingView, "gradingsummaryteam.json", as: :team, assigns: %{team_members: team_members}), + team: + render_one(team, CadetWeb.AdminGradingView, "gradingsummaryteam.json", + as: :team, + assigns: %{team_members: team_members} + ), unsubmittedBy: case unsubmitter do nil -> nil @@ -92,7 +99,8 @@ defmodule CadetWeb.AdminGradingView do def render("gradingsummaryteam.json", %{team: team, assigns: %{team_members: team_members}}) do %{ id: team.id, - team_members: render_many(team_members, CadetWeb.AdminGradingView, "gradingsummaryuser.json", as: :cr) + team_members: + render_many(team_members, CadetWeb.AdminGradingView, "gradingsummaryuser.json", as: :cr) } end @@ -129,17 +137,28 @@ defmodule CadetWeb.AdminGradingView do end defp extract_student_data(nil), do: %{} + defp extract_student_data(student) do - transform_map_for_view(student, %{name: fn st -> st.user.name end, id: :id, username: fn st -> st.user.username end}) + transform_map_for_view(student, %{ + name: fn st -> st.user.name end, + id: :id, + username: fn st -> st.user.username end + }) end defp extract_team_member_data(team_member) do - transform_map_for_view(team_member, %{name: &(&1.student.user.name), id: :id, username: &(&1.student.user.username)}) + transform_map_for_view(team_member, %{ + name: & &1.student.user.name, + id: :id, + username: & &1.student.user.username + }) end defp extract_team_data(nil), do: %{} + defp extract_team_data(team) do members = team.team_members + case members do [] -> nil _ -> Enum.map(members, &extract_team_member_data/1) diff --git a/lib/cadet_web/admin_views/admin_teams_view.ex b/lib/cadet_web/admin_views/admin_teams_view.ex index 538c509a6..fcbb01d73 100644 --- a/lib/cadet_web/admin_views/admin_teams_view.ex +++ b/lib/cadet_web/admin_views/admin_teams_view.ex @@ -14,7 +14,9 @@ defmodule CadetWeb.AdminTeamsView do """ def render("index.json", %{teamFormationOverviews: teamFormationOverviews}) do - render_many(teamFormationOverviews, CadetWeb.AdminTeamsView, "team_formation_overview.json", as: :team_formation_overview) + render_many(teamFormationOverviews, CadetWeb.AdminTeamsView, "team_formation_overview.json", + as: :team_formation_overview + ) end @doc """ @@ -36,4 +38,3 @@ defmodule CadetWeb.AdminTeamsView do } end end - diff --git a/lib/cadet_web/admin_views/admin_user_view.ex b/lib/cadet_web/admin_views/admin_user_view.ex index 24d6e30fc..6d6fb4403 100644 --- a/lib/cadet_web/admin_views/admin_user_view.ex +++ b/lib/cadet_web/admin_views/admin_user_view.ex @@ -29,7 +29,7 @@ defmodule CadetWeb.AdminUserView do %{ userId: students.id, name: students.user.name, - username: students.user.username, + username: students.user.username } end end diff --git a/lib/cadet_web/controllers/answer_controller.ex b/lib/cadet_web/controllers/answer_controller.ex index c23341711..95aa19d53 100644 --- a/lib/cadet_web/controllers/answer_controller.ex +++ b/lib/cadet_web/controllers/answer_controller.ex @@ -47,7 +47,13 @@ defmodule CadetWeb.AnswerController do {:question, Assessments.get_question(question_id)}, {:is_open?, true} <- {:is_open?, can_bypass? or Assessments.is_open?(question.assessment)}, - {:ok, lastModified} <- Assessments.has_last_modified_answer?(question, course_reg, last_modified_at, can_bypass?) do + {:ok, lastModified} <- + Assessments.has_last_modified_answer?( + question, + course_reg, + last_modified_at, + can_bypass? + ) do conn |> put_status(:ok) |> put_resp_content_type("application/json") diff --git a/lib/cadet_web/controllers/team_controller.ex b/lib/cadet_web/controllers/team_controller.ex index 9b3e73f6f..c897241bc 100644 --- a/lib/cadet_web/controllers/team_controller.ex +++ b/lib/cadet_web/controllers/team_controller.ex @@ -13,7 +13,7 @@ defmodule CadetWeb.TeamController do def index(conn, %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do cr = conn.assigns.course_reg - + query = from(t in Team, where: t.assessment_id == ^assessment_id, @@ -21,9 +21,11 @@ defmodule CadetWeb.TeamController do where: tm.student_id == ^cr.id, limit: 1 ) - team = query + + team = + query |> Repo.one() - |> Repo.preload([assessment: [:config], team_members: [student: [:user]]]) + |> Repo.preload(assessment: [:config], team_members: [student: [:user]]) if team == nil do conn @@ -47,10 +49,10 @@ defmodule CadetWeb.TeamController do assessmentId: assessment.id, assessmentName: assessment.title, assessmentType: assessment.config.type, - studentIds: team.team_members |> Enum.map(&(&1.student.user.id)), - studentNames: team.team_members |> Enum.map(&(&1.student.user.name)) + studentIds: team.team_members |> Enum.map(& &1.student.user.id), + studentNames: team.team_members |> Enum.map(& &1.student.user.name) } - + teamFormationOverview end @@ -78,10 +80,25 @@ defmodule CadetWeb.TeamController do "assessmentId" => %{"type" => "number", "description" => "The ID of the assessment"}, "assessmentName" => %{"type" => "string", "description" => "The name of the assessment"}, "assessmentType" => %{"type" => "string", "description" => "The type of the assessment"}, - "studentIds" => %{"type" => "array", "items" => %{"type" => "number"}, "description" => "List of student IDs"}, - "studentNames" => %{"type" => "array", "items" => %{"type" => "string"}, "description" => "List of student names"} + "studentIds" => %{ + "type" => "array", + "items" => %{"type" => "number"}, + "description" => "List of student IDs" + }, + "studentNames" => %{ + "type" => "array", + "items" => %{"type" => "string"}, + "description" => "List of student names" + } }, - "required" => ["teamId", "assessmentId", "assessmentName", "assessmentType", "studentIds", "studentNames"] + "required" => [ + "teamId", + "assessmentId", + "assessmentName", + "assessmentType", + "studentIds", + "studentNames" + ] } } end diff --git a/lib/cadet_web/router.ex b/lib/cadet_web/router.ex index b91b52e64..d0a29aad1 100644 --- a/lib/cadet_web/router.ex +++ b/lib/cadet_web/router.ex @@ -77,7 +77,12 @@ defmodule CadetWeb.Router do post("/assessments/:assessmentid/unlock", AssessmentsController, :unlock) post("/assessments/:assessmentid/submit", AssessmentsController, :submit) post("/assessments/question/:questionid/answer", AnswerController, :submit) - post("/assessments/question/:questionid/answerLastModified", AnswerController, :checkLastModified) + + post( + "/assessments/question/:questionid/answerLastModified", + AnswerController, + :checkLastModified + ) get("/achievements", IncentivesController, :index_achievements) get("/self/goals", IncentivesController, :index_goals) diff --git a/lib/cadet_web/views/answer_view.ex b/lib/cadet_web/views/answer_view.ex index c1f6407f4..cb650739f 100644 --- a/lib/cadet_web/views/answer_view.ex +++ b/lib/cadet_web/views/answer_view.ex @@ -6,4 +6,4 @@ defmodule CadetWeb.AnswerView do lastModified: lastModified } end -end \ No newline at end of file +end diff --git a/priv/repo/migrations/20230704125701_add_max_team_size_to_assessments.exs b/priv/repo/migrations/20230704125701_add_max_team_size_to_assessments.exs index 6a9532298..d18892d50 100644 --- a/priv/repo/migrations/20230704125701_add_max_team_size_to_assessments.exs +++ b/priv/repo/migrations/20230704125701_add_max_team_size_to_assessments.exs @@ -6,4 +6,4 @@ defmodule Cadet.Repo.Migrations.AddMaxTeamSizeToAssessments do add(:max_team_size, :integer, null: false, default: 1) end end -end \ No newline at end of file +end diff --git a/priv/repo/migrations/20230805094759_add_last_modified_to_answers.exs b/priv/repo/migrations/20230805094759_add_last_modified_to_answers.exs index 083772c42..ef89b3129 100644 --- a/priv/repo/migrations/20230805094759_add_last_modified_to_answers.exs +++ b/priv/repo/migrations/20230805094759_add_last_modified_to_answers.exs @@ -3,7 +3,7 @@ defmodule Cadet.Repo.Migrations.AddLastModifiedToAnswers do def change do alter table(:answers) do - add :last_modified_at, :utc_datetime, default: fragment("CURRENT_TIMESTAMP") + add(:last_modified_at, :utc_datetime, default: fragment("CURRENT_TIMESTAMP")) end end end diff --git a/test/cadet/accounts/notification_test.exs b/test/cadet/accounts/notification_test.exs index 6c90774bf..7d0ece257 100644 --- a/test/cadet/accounts/notification_test.exs +++ b/test/cadet/accounts/notification_test.exs @@ -298,7 +298,9 @@ defmodule Cadet.Accounts.NotificationTest do Notifications.write_notification_when_student_submits(team_submission) - team_members = Repo.all(from tm in TeamMember, where: tm.team_id == ^team.id, preload: :student) + team_members = + Repo.all(from(tm in TeamMember, where: tm.team_id == ^team.id, preload: :student)) + students = Enum.map(team_members, & &1.student) Enum.each(students, fn student -> @@ -353,7 +355,9 @@ defmodule Cadet.Accounts.NotificationTest do } do Notifications.write_notification_when_graded(team_submission.id, :autograded) - team_members = Repo.all(from tm in TeamMember, where: tm.team_id == ^team.id, preload: :student) + team_members = + Repo.all(from(tm in TeamMember, where: tm.team_id == ^team.id, preload: :student)) + students = Enum.map(team_members, & &1.student) Enum.each(students, fn student -> @@ -392,7 +396,9 @@ defmodule Cadet.Accounts.NotificationTest do } do Notifications.write_notification_when_graded(team_submission.id, :graded) - team_members = Repo.all(from tm in TeamMember, where: tm.team_id == ^team.id, preload: :student) + team_members = + Repo.all(from(tm in TeamMember, where: tm.team_id == ^team.id, preload: :student)) + students = Enum.map(team_members, & &1.student) Enum.each(students, fn student -> diff --git a/test/cadet/accounts/team_members_test.exs b/test/cadet/accounts/team_members_test.exs index 8c13b9eff..4c33a6b7b 100644 --- a/test/cadet/accounts/team_members_test.exs +++ b/test/cadet/accounts/team_members_test.exs @@ -34,4 +34,4 @@ defmodule Cadet.Accounts.TeamMemberTest do assert {:error, _changeset} = Repo.insert(changeset) end end -end \ No newline at end of file +end diff --git a/test/cadet/accounts/teams_test.exs b/test/cadet/accounts/teams_test.exs index 77aafbbe6..575479077 100644 --- a/test/cadet/accounts/teams_test.exs +++ b/test/cadet/accounts/teams_test.exs @@ -1,305 +1,539 @@ defmodule Cadet.Accounts.TeamTest do - use Cadet.DataCase - alias Cadet.Accounts.{Teams, TeamMember, CourseRegistrations} - alias Cadet.Assessments.{Submission, Answer} - alias Cadet.Repo - - setup do - user1 = insert(:user, %{name: "user 1"}) - user2 = insert(:user, %{name: "user 2"}) - user3 = insert(:user, %{name: "user 3"}) - course1 = insert(:course, %{course_short_name: "course 1"}) - course2 = insert(:course, %{course_short_name: "course 2"}) - assessment1 = insert(:assessment, %{title: "A1", max_team_size: 3, course: course1}) - assessment2 = insert(:assessment, %{title: "A2", max_team_size: 2, course: course1}) - - - {:ok, %{ - user1: user1, - user2: user2, - user3: user3, - course1: course1, - course2: course2, - assessment1: assessment1, - assessment2: assessment2 - }} - end - - test "creating a new team with valid attributes", %{user1: user1, user2: user2, assessment1: assessment1, course1: course1} do - {:ok, course_reg1} = - CourseRegistrations.insert_or_update_course_registration(%{ - user_id: user1.id, - course_id: course1.id, - role: :student - }) - {:ok, course_reg2} = - CourseRegistrations.insert_or_update_course_registration(%{ - user_id: user2.id, - course_id: course1.id, - role: :student - }) - attrs = %{ - "assessment_id" => assessment1.id, - "student_ids" => [ - [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id}], - ] - } - - assert {:ok, team} = Teams.create_team(attrs) - - team_members = TeamMember - |> where([tm], tm.team_id == ^team.id) - |> Repo.all() - - assert length(team_members) == 2 - - end - - test "creating a new team with duplicate students in the one row", %{user1: user1, assessment1: assessment1, course1: course1} do - {:ok, course_reg1} = - CourseRegistrations.insert_or_update_course_registration(%{ - user_id: user1.id, - course_id: course1.id, - role: :student - }) - - attrs = %{ - "assessment_id" => assessment1.id, - "student_ids" => [ - [%{"userId" => course_reg1.id},%{"userId" => course_reg1.id}], - ] - } - - result = Teams.create_team(attrs) - assert result == {:error, {:conflict, "One or more students appear multiple times in a team!"}} - end - - test "creating a new team with duplicate students across the teams but not in one row", %{user1: user1, user2: user2, user3: user3, assessment1: assessment1, course1: course1} do - {:ok, course_reg1} = - CourseRegistrations.insert_or_update_course_registration(%{ - user_id: user1.id, - course_id: course1.id, - role: :student - }) - {:ok, course_reg2} = - CourseRegistrations.insert_or_update_course_registration(%{ - user_id: user2.id, - course_id: course1.id, - role: :student - }) - {:ok, course_reg3} = - CourseRegistrations.insert_or_update_course_registration(%{ - user_id: user3.id, - course_id: course1.id, - role: :student - }) - attrs = %{ - "assessment_id" => assessment1.id, - "student_ids" => [ - [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id}], - [%{"userId" => course_reg2.id},%{"userId" => course_reg3.id}], - ] - } - - result = Teams.create_team(attrs) - assert result == {:error, {:conflict, "One or more students appear multiple times in a team!"}} - end - - test "creating a team with students already in another team for the same assessment", %{user1: user1, user2: user2, user3: user3, assessment1: assessment1, course1: course1} do - - {:ok, course_reg1} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user1.id, course_id: course1.id, role: :student}) - {:ok, course_reg2} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user2.id, course_id: course1.id, role: :student}) - {:ok, course_reg3} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user3.id, course_id: course1.id, role: :student}) - - attrs_valid = %{ - "assessment_id" => assessment1.id, - "student_ids" => [ - [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id}], + use Cadet.DataCase + alias Cadet.Accounts.{Teams, TeamMember, CourseRegistrations} + alias Cadet.Assessments.{Submission, Answer} + alias Cadet.Repo + + setup do + user1 = insert(:user, %{name: "user 1"}) + user2 = insert(:user, %{name: "user 2"}) + user3 = insert(:user, %{name: "user 3"}) + course1 = insert(:course, %{course_short_name: "course 1"}) + course2 = insert(:course, %{course_short_name: "course 2"}) + assessment1 = insert(:assessment, %{title: "A1", max_team_size: 3, course: course1}) + assessment2 = insert(:assessment, %{title: "A2", max_team_size: 2, course: course1}) + + {:ok, + %{ + user1: user1, + user2: user2, + user3: user3, + course1: course1, + course2: course2, + assessment1: assessment1, + assessment2: assessment2 + }} + end + + test "creating a new team with valid attributes", %{ + user1: user1, + user2: user2, + assessment1: assessment1, + course1: course1 + } do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + + attrs = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id}, %{"userId" => course_reg2.id}] + ] + } + + assert {:ok, team} = Teams.create_team(attrs) + + team_members = + TeamMember + |> where([tm], tm.team_id == ^team.id) + |> Repo.all() + + assert length(team_members) == 2 + end + + test "creating a new team with duplicate students in the one row", %{ + user1: user1, + assessment1: assessment1, + course1: course1 + } do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + attrs = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id}, %{"userId" => course_reg1.id}] + ] + } + + result = Teams.create_team(attrs) + + assert result == + {:error, {:conflict, "One or more students appear multiple times in a team!"}} + end + + test "creating a new team with duplicate students across the teams but not in one row", %{ + user1: user1, + user2: user2, + user3: user3, + assessment1: assessment1, + course1: course1 + } do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg3} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user3.id, + course_id: course1.id, + role: :student + }) + + attrs = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id}, %{"userId" => course_reg2.id}], + [%{"userId" => course_reg2.id}, %{"userId" => course_reg3.id}] + ] + } + + result = Teams.create_team(attrs) + + assert result == + {:error, {:conflict, "One or more students appear multiple times in a team!"}} + end + + test "creating a team with students already in another team for the same assessment", %{ + user1: user1, + user2: user2, + user3: user3, + assessment1: assessment1, + course1: course1 + } do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg3} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user3.id, + course_id: course1.id, + role: :student + }) + + attrs_valid = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id}, %{"userId" => course_reg2.id}] + ] + } + + assert {:ok, _team} = Teams.create_team(attrs_valid) + + attrs_invalid = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id}, %{"userId" => course_reg3.id}] + ] + } + + result = Teams.create_team(attrs_invalid) + + assert result == + {:error, {:conflict, "One or more students already in a team for this assessment!"}} + end + + test "creating a team with students exceeding the maximum team size", %{ + user1: user1, + user2: user2, + user3: user3, + assessment2: assessment2, + course1: course1 + } do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg3} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user3.id, + course_id: course1.id, + role: :student + }) + + attrs_invalid = %{ + "assessment_id" => assessment2.id, + "student_ids" => [ + [ + %{"userId" => course_reg1.id}, + %{"userId" => course_reg2.id}, + %{"userId" => course_reg3.id} ] - } - - assert {:ok, _team} = Teams.create_team(attrs_valid) + ] + } + + result = Teams.create_team(attrs_invalid) + assert result == {:error, {:conflict, "One or more teams exceed the maximum team size!"}} + end + + test "inserting a team with non-exisiting student", %{ + user1: user1, + user2: user2, + assessment1: assessment1, + course1: course1 + } do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + + attrs_invalid = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id}, %{"userId" => course_reg2.id}, %{"userId" => 99999}] + ] + } + + result = Teams.create_team(attrs_invalid) + assert result == {:error, {:conflict, "One or more students not enrolled in this course!"}} + end + + test "inserting a team with an exisiting student but not enrolled in this course", %{ + user1: user1, + user2: user2, + assessment1: assessment1, + course1: course1, + course2: course2 + } do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course2.id, + role: :student + }) + + attrs_invalid = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id}, %{"userId" => course_reg2.id}] + ] + } + + result = Teams.create_team(attrs_invalid) + assert result == {:error, {:conflict, "One or more students not enrolled in this course!"}} + end + + test "update an existing team with valid new team members", %{ + user1: user1, + user2: user2, + user3: user3, + assessment1: assessment1, + course1: course1 + } do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg3} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user3.id, + course_id: course1.id, + role: :student + }) + + attrs = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id}, %{"userId" => course_reg2.id}] + ] + } + + new_ids = [ + [ + %{"userId" => course_reg1.id}, + %{"userId" => course_reg2.id}, + %{"userId" => course_reg3.id} + ] + ] + + assert {:ok, team} = Teams.create_team(attrs) + team = Repo.preload(team, :team_members) + assert {:ok, team} = Teams.update_team(team, team.assessment_id, new_ids) + + team_members = + TeamMember + |> where([tm], tm.team_id == ^team.id) + |> Repo.all() + + assert length(team_members) == 3 + end + + test "update an existing team with new team members who are already in another team", %{ + user1: user1, + user2: user2, + user3: user3, + assessment1: assessment1, + course1: course1 + } do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg3} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user3.id, + course_id: course1.id, + role: :student + }) + + attrs1 = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id}, %{"userId" => course_reg2.id}] + ] + } + + attrs2 = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg3.id}] + ] + } + + new_ids = [ + [ + %{"userId" => course_reg1.id}, + %{"userId" => course_reg2.id}, + %{"userId" => course_reg3.id} + ] + ] + + assert {:ok, team1} = Teams.create_team(attrs1) + assert {:ok, _team2} = Teams.create_team(attrs2) + team1 = Repo.preload(team1, :team_members) + + result = Teams.update_team(team1, team1.assessment_id, new_ids) + + assert result == + {:error, + {:conflict, + "One or more students are already in another team for the same assessment!"}} + end + + test "delete an existing team", %{ + user1: user1, + user2: user2, + user3: user3, + assessment1: assessment1, + course1: course1 + } do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg3} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user3.id, + course_id: course1.id, + role: :student + }) + + attrs = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [ + %{"userId" => course_reg1.id}, + %{"userId" => course_reg2.id}, + %{"userId" => course_reg3.id} + ] + ] + } + + assert {:ok, team} = Teams.create_team(attrs) + + submission = + insert(:submission, %{ + team: team, + student: nil, + assessment: assessment1 + }) + + submission_id = submission.id + + _answer = %Answer{ + submission_id: submission_id + } + + assert {:ok, deleted_team} = Teams.delete_team(team) + assert deleted_team.id == team.id + + team_members = + TeamMember + |> where([tm], tm.team_id == ^team.id) + |> Repo.all() + + assert length(team_members) == 0 + + submissions = + Submission + |> where([s], s.team_id == ^team.id) + |> Repo.all() + + assert length(submissions) == 0 + + answers = + Answer + |> where(submission_id: ^submission_id) + |> Repo.all() + + assert length(answers) == 0 + end + + test "delete an existing team with submission", %{ + user1: user1, + user2: user2, + user3: user3, + assessment1: assessment1, + course1: course1 + } do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg3} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user3.id, + course_id: course1.id, + role: :student + }) + + attrs = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [ + %{"userId" => course_reg1.id}, + %{"userId" => course_reg2.id}, + %{"userId" => course_reg3.id} + ] + ] + } - attrs_invalid = %{ - "assessment_id" => assessment1.id, - "student_ids" => [ - [%{"userId" => course_reg1.id},%{"userId" => course_reg3.id}], - ] - } + assert {:ok, team} = Teams.create_team(attrs) - result = Teams.create_team(attrs_invalid) - assert result == {:error, {:conflict, "One or more students already in a team for this assessment!"}} + submission = %Submission{ + team_id: team.id, + assessment_id: assessment1.id, + status: :submitted + } - end + {:ok, _inserted_submission} = Repo.insert(submission) - - test "creating a team with students exceeding the maximum team size", %{user1: user1, user2: user2, user3: user3, assessment2: assessment2, course1: course1} do + result = Teams.delete_team(team) - {:ok, course_reg1} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user1.id, course_id: course1.id, role: :student}) - {:ok, course_reg2} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user2.id, course_id: course1.id, role: :student}) - {:ok, course_reg3} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user3.id, course_id: course1.id, role: :student}) - - attrs_invalid = %{ - "assessment_id" => assessment2.id, - "student_ids" => [ - [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id},%{"userId" => course_reg3.id}], - ] - } - - result = Teams.create_team(attrs_invalid) - assert result == {:error, {:conflict, "One or more teams exceed the maximum team size!"}} - - end - - test "inserting a team with non-exisiting student", %{user1: user1, user2: user2, assessment1: assessment1, course1: course1} do - - {:ok, course_reg1} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user1.id, course_id: course1.id, role: :student}) - {:ok, course_reg2} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user2.id, course_id: course1.id, role: :student}) - - attrs_invalid = %{ - "assessment_id" => assessment1.id, - "student_ids" => [ - [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id},%{"userId" => 99999}], - ] - } - - result = Teams.create_team(attrs_invalid) - assert result == {:error, {:conflict, "One or more students not enrolled in this course!"}} - end - - test "inserting a team with an exisiting student but not enrolled in this course", %{user1: user1, user2: user2, assessment1: assessment1, course1: course1, course2: course2} do - - {:ok, course_reg1} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user1.id, course_id: course1.id, role: :student}) - {:ok, course_reg2} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user2.id, course_id: course2.id, role: :student}) - - attrs_invalid = %{ - "assessment_id" => assessment1.id, - "student_ids" => [ - [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id}], - ] - } - - result = Teams.create_team(attrs_invalid) - assert result == {:error, {:conflict, "One or more students not enrolled in this course!"}} - end - - test "update an existing team with valid new team members", %{user1: user1, user2: user2, user3: user3, assessment1: assessment1, course1: course1} do - {:ok, course_reg1} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user1.id, course_id: course1.id, role: :student}) - {:ok, course_reg2} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user2.id, course_id: course1.id, role: :student}) - {:ok, course_reg3} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user3.id, course_id: course1.id, role: :student}) - - attrs = %{ - "assessment_id" => assessment1.id, - "student_ids" => [ - [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id}], - ] - } - new_ids = [[%{"userId" => course_reg1.id},%{"userId" => course_reg2.id},%{"userId" => course_reg3.id}]] - assert {:ok, team} = Teams.create_team(attrs) - team = Repo.preload(team, :team_members) - assert {:ok, team} = Teams.update_team(team, team.assessment_id, new_ids) - - team_members = TeamMember - |> where([tm], tm.team_id == ^team.id) - |> Repo.all() - - assert length(team_members) == 3 - end - - test "update an existing team with new team members who are already in another team", %{user1: user1, user2: user2, user3: user3, assessment1: assessment1, course1: course1} do - {:ok, course_reg1} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user1.id, course_id: course1.id, role: :student}) - {:ok, course_reg2} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user2.id, course_id: course1.id, role: :student}) - {:ok, course_reg3} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user3.id, course_id: course1.id, role: :student}) - - attrs1 = %{ - "assessment_id" => assessment1.id, - "student_ids" => [ - [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id}], - ] - } - - attrs2 = %{ - "assessment_id" => assessment1.id, - "student_ids" => [ - [%{"userId" => course_reg3.id}], - ] - } - new_ids = [[%{"userId" => course_reg1.id},%{"userId" => course_reg2.id},%{"userId" => course_reg3.id}]] - assert {:ok, team1} = Teams.create_team(attrs1) - assert {:ok, _team2} = Teams.create_team(attrs2) - team1 = Repo.preload(team1, :team_members) - - result = Teams.update_team(team1, team1.assessment_id, new_ids) - assert result == {:error, {:conflict, "One or more students are already in another team for the same assessment!"}} - end - - test "delete an existing team", %{user1: user1, user2: user2, user3: user3, assessment1: assessment1, course1: course1} do - {:ok, course_reg1} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user1.id, course_id: course1.id, role: :student}) - {:ok, course_reg2} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user2.id, course_id: course1.id, role: :student}) - {:ok, course_reg3} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user3.id, course_id: course1.id, role: :student}) - - attrs = %{ - "assessment_id" => assessment1.id, - "student_ids" => [ - [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id},%{"userId" => course_reg3.id}], - ] - } - - assert {:ok, team} = Teams.create_team(attrs) - submission = insert(:submission, %{ - team: team, - student: nil, - assessment: assessment1 - }) - - submission_id = submission.id - - _answer = %Answer{ - submission_id: submission_id - } - - assert {:ok, deleted_team} = Teams.delete_team(team) - assert deleted_team.id == team.id - - team_members = TeamMember - |> where([tm], tm.team_id == ^team.id) - |> Repo.all() - assert length(team_members) == 0 - - submissions = Submission - |> where([s], s.team_id == ^team.id) - |> Repo.all() - assert length(submissions) == 0 - - answers = Answer - |> where(submission_id: ^submission_id) - |> Repo.all() - assert length(answers) == 0 - end - - test "delete an existing team with submission", %{user1: user1, user2: user2, user3: user3, assessment1: assessment1, course1: course1} do - {:ok, course_reg1} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user1.id, course_id: course1.id, role: :student}) - {:ok, course_reg2} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user2.id, course_id: course1.id, role: :student}) - {:ok, course_reg3} = CourseRegistrations.insert_or_update_course_registration(%{user_id: user3.id, course_id: course1.id, role: :student}) - - attrs = %{ - "assessment_id" => assessment1.id, - "student_ids" => [ - [%{"userId" => course_reg1.id},%{"userId" => course_reg2.id},%{"userId" => course_reg3.id}], - ] - } - - assert {:ok, team} = Teams.create_team(attrs) - submission = %Submission{ - team_id: team.id, - assessment_id: assessment1.id, - status: :submitted - } - - {:ok, _inserted_submission} = Repo.insert(submission) - - result = Teams.delete_team(team) - assert result == {:error, {:conflict, "This team has submitted their answers! Unable to delete the team!"}} - - end -end \ No newline at end of file + assert result == + {:error, + {:conflict, "This team has submitted their answers! Unable to delete the team!"}} + end +end diff --git a/test/cadet/assessments/assessment_test.exs b/test/cadet/assessments/assessment_test.exs index e7416e995..ad1f0e4bb 100644 --- a/test/cadet/assessments/assessment_test.exs +++ b/test/cadet/assessments/assessment_test.exs @@ -129,7 +129,7 @@ defmodule Cadet.Assessments.AssessmentTest do test "invalid changeset with invalid team size", %{ course1: course1, - config1: config1, + config1: config1 } do assert_changeset( %{ @@ -143,7 +143,6 @@ defmodule Cadet.Assessments.AssessmentTest do }, :valid ) - end end end diff --git a/test/cadet/assessments/assessments_test.exs b/test/cadet/assessments/assessments_test.exs index e4cdaec63..93589666a 100644 --- a/test/cadet/assessments/assessments_test.exs +++ b/test/cadet/assessments/assessments_test.exs @@ -142,28 +142,52 @@ defmodule Cadet.AssessmentsTest do student = insert(:course_registration, %{course: course, role: :student}) question = insert(:question, %{assessment: assessment}) - submission = insert(:submission, %{assessment: assessment, team: nil, student: student, status: :attempting}) - assert {:error, {:unauthorized, "User is not permitted to grade."}} = Assessments.update_grading_info(%{submission: submission, question: question}, %{}, student) + submission = + insert(:submission, %{ + assessment: assessment, + team: nil, + student: student, + status: :attempting + }) + + assert {:error, {:unauthorized, "User is not permitted to grade."}} = + Assessments.update_grading_info( + %{submission: submission, question: question}, + %{}, + student + ) end test "force update assessment with invalid params" do course = insert(:course) config = insert(:assessment_config, %{course: course}) - assessment = insert(:assessment, %{config: config, course: course, + + assessment = + insert(:assessment, %{ + config: config, + course: course, open_at: Timex.shift(Timex.now(), days: -5), close_at: Timex.shift(Timex.now(), hours: +5), - is_published: true}) + is_published: true + }) + assessment_params = %{ number: assessment.number, course_id: course.id } + question_params = %{ assessment: assessment, type: :programming } - assert {:error, "Question count is different"} = Assessments.insert_or_update_assessments_and_questions(assessment_params, question_params, true) + assert {:error, "Question count is different"} = + Assessments.insert_or_update_assessments_and_questions( + assessment_params, + question_params, + true + ) end end @@ -188,7 +212,8 @@ defmodule Cadet.AssessmentsTest do question = insert(:question, %{assessment: assessment}) student = insert(:course_registration, %{course: course, role: :student}) - assert Assessments.answer_question(question, student, "answer", false) == {:error, {:bad_request, "Your existing Team has been deleted!"}} + assert Assessments.answer_question(question, student, "answer", false) == + {:error, {:bad_request, "Your existing Team has been deleted!"}} end test "answer questions with a team" do @@ -201,8 +226,17 @@ defmodule Cadet.AssessmentsTest do teammember1 = insert(:team_member, %{student: student1}) teammember2 = insert(:team_member, %{student: student2}) team = insert(:team, %{assessment: assessment, team_members: [teammember1, teammember2]}) - submission = insert(:submission, %{assessment: assessment, team: team, student: nil, status: :attempting}) - _answer = insert(:answer, submission: submission, question: question, answer: %{code: "f => f(f);"}) + + submission = + insert(:submission, %{ + assessment: assessment, + team: team, + student: nil, + status: :attempting + }) + + _answer = + insert(:answer, submission: submission, question: question, answer: %{code: "f => f(f);"}) assert Assessments.answer_question(question, student1, "answer", false) == {:ok, nil} end @@ -219,31 +253,38 @@ defmodule Cadet.AssessmentsTest do test "overdue assessments with questions and answers" do course = insert(:course) config = insert(:assessment_config, %{course: course}) - assessment = insert(:assessment, %{ - config: config, - course: course, - max_team_size: 10, - open_at: Timex.shift(Timex.now(), days: -15), - close_at: Timex.shift(Timex.now(), days: -5), - is_published: true, - password: "123" - }) + + assessment = + insert(:assessment, %{ + config: config, + course: course, + max_team_size: 10, + open_at: Timex.shift(Timex.now(), days: -15), + close_at: Timex.shift(Timex.now(), days: -5), + is_published: true, + password: "123" + }) + student = insert(:course_registration, %{course: course, role: :student}) - assert {:ok, _} = Assessments.assessment_with_questions_and_answers(assessment, student, "123") + assert {:ok, _} = + Assessments.assessment_with_questions_and_answers(assessment, student, "123") end test "team assessments with questions and answers" do course = insert(:course) config = insert(:assessment_config, %{course: course}) - assessment = insert(:assessment, %{ - config: config, - course: course, - max_team_size: 10, - open_at: Timex.shift(Timex.now(), days: -15), - close_at: Timex.shift(Timex.now(), days: +5), - is_published: true - }) + + assessment = + insert(:assessment, %{ + config: config, + course: course, + max_team_size: 10, + open_at: Timex.shift(Timex.now(), days: -15), + close_at: Timex.shift(Timex.now(), days: +5), + is_published: true + }) + group = insert(:group, %{name: "group"}) student1 = insert(:course_registration, %{course: course, role: :student, group: group}) student2 = insert(:course_registration, %{course: course, role: :student, group: group}) @@ -251,47 +292,70 @@ defmodule Cadet.AssessmentsTest do teammember1 = insert(:team_member, %{student: student1}) teammember2 = insert(:team_member, %{student: student2}) team = insert(:team, %{assessment: assessment, team_members: [teammember1, teammember2]}) - submission = insert(:submission, %{assessment: assessment, team: team, student: nil, status: :submitted}) - + + submission = + insert(:submission, %{ + assessment: assessment, + team: team, + student: nil, + status: :submitted + }) + assert {:ok, _} = Assessments.assessment_with_questions_and_answers(assessment, student1) assert submission.id == Assessments.get_submission(assessment.id, student1).id end - test "create empty submission for team assessment" do course = insert(:course) config = insert(:assessment_config, %{course: course}) - team_assessment = insert(:assessment, %{config: config, course: course, max_team_size: 10, + + team_assessment = + insert(:assessment, %{ + config: config, + course: course, + max_team_size: 10, open_at: Timex.shift(Timex.now(), days: -5), close_at: Timex.shift(Timex.now(), hours: +5), - is_published: true}) + is_published: true + }) + group = insert(:group, %{name: "group"}) student1 = insert(:course_registration, %{course: course, role: :student, group: group}) student2 = insert(:course_registration, %{course: course, role: :student, group: group}) teammember1 = insert(:team_member, %{student: student1}) teammember2 = insert(:team_member, %{student: student2}) - team = insert(:team, %{assessment: team_assessment, team_members: [teammember1, teammember2]}) + + team = + insert(:team, %{assessment: team_assessment, team_members: [teammember1, teammember2]}) + question = insert(:question, %{assessment: team_assessment, type: :programming}) assert {:ok, _} = Assessments.answer_question(question, student1, "answer", false) - submission = Submission + submission = + Submission |> where([s], s.team_id == ^team.id) |> Repo.all() assert length(submission) == 1 end - @tag authenticate: :staff test "unsubmit team assessment" do course = insert(:course) config = insert(:assessment_config, %{course: course}) - team_assessment = insert(:assessment, %{config: config, course: course, max_team_size: 10, + + team_assessment = + insert(:assessment, %{ + config: config, + course: course, + max_team_size: 10, open_at: Timex.shift(Timex.now(), days: -5), close_at: Timex.shift(Timex.now(), hours: +5), - is_published: true}) + is_published: true + }) + group = insert(:group, %{name: "group"}) avenger = insert(:course_registration, %{course: course, role: :staff, group: group}) @@ -299,8 +363,17 @@ defmodule Cadet.AssessmentsTest do student2 = insert(:course_registration, %{course: course, role: :student, group: group}) teammember1 = insert(:team_member, %{student: student1}) teammember2 = insert(:team_member, %{student: student2}) - team = insert(:team, %{assessment: team_assessment, team_members: [teammember1, teammember2]}) - submission = insert(:submission, %{assessment: team_assessment, team: team, student: nil, status: :submitted}) + + team = + insert(:team, %{assessment: team_assessment, team_members: [teammember1, teammember2]}) + + submission = + insert(:submission, %{ + assessment: team_assessment, + team: team, + student: nil, + status: :submitted + }) assert {:ok, _} = Assessments.unsubmit_submission(submission.id, avenger) end @@ -309,15 +382,29 @@ defmodule Cadet.AssessmentsTest do test "delete team assessment with associating submission" do course = insert(:course) config = insert(:assessment_config, %{course: course}) - assessment = insert(:assessment, %{config: config, course: course, + + assessment = + insert(:assessment, %{ + config: config, + course: course, open_at: Timex.shift(Timex.now(), days: -5), close_at: Timex.shift(Timex.now(), hours: +5), - is_published: true}) - + is_published: true + }) + student = insert(:course_registration, %{course: course, role: :student}) question = insert(:question, %{assessment: assessment}) - submission = insert(:submission, %{assessment: assessment, team: nil, student: student, status: :attempting}) - _answer = insert(:answer, submission: submission, question: question, answer: %{code: "f => f(f);"}) + + submission = + insert(:submission, %{ + assessment: assessment, + team: nil, + student: student, + status: :attempting + }) + + _answer = + insert(:answer, submission: submission, question: question, answer: %{code: "f => f(f);"}) assert {:ok, _} = Assessments.delete_assessment(assessment.id) end @@ -325,17 +412,27 @@ defmodule Cadet.AssessmentsTest do test "get user xp for team assessment" do course = insert(:course) config = insert(:assessment_config, %{course: course}) - team_assessment = insert(:assessment, %{config: config, course: course, max_team_size: 10, + + team_assessment = + insert(:assessment, %{ + config: config, + course: course, + max_team_size: 10, open_at: Timex.shift(Timex.now(), days: -5), close_at: Timex.shift(Timex.now(), hours: +5), - is_published: true}) + is_published: true + }) + group = insert(:group, %{name: "group"}) - + student1 = insert(:course_registration, %{course: course, role: :student, group: group}) student2 = insert(:course_registration, %{course: course, role: :student, group: group}) teammember1 = insert(:team_member, %{student: student1}) teammember2 = insert(:team_member, %{student: student2}) - _team = insert(:team, %{assessment: team_assessment, team_members: [teammember1, teammember2]}) + + _team = + insert(:team, %{assessment: team_assessment, team_members: [teammember1, teammember2]}) + assert Assessments.assessments_total_xp(student1) == 0 end end diff --git a/test/cadet/assessments/submission_test.exs b/test/cadet/assessments/submission_test.exs index 40afaff17..4979a7543 100644 --- a/test/cadet/assessments/submission_test.exs +++ b/test/cadet/assessments/submission_test.exs @@ -13,7 +13,7 @@ defmodule Cadet.Assessments.SubmissionTest do student = insert(:course_registration, %{course: course, role: :student}) student1 = insert(:course_registration, %{course: course, role: :student}) student2 = insert(:course_registration, %{course: course, role: :student}) - + teammember1 = insert(:team_member, %{student: student1}) teammember2 = insert(:team_member, %{student: student2}) team = insert(:team, %{assessment: team_assessment, team_members: [teammember1, teammember2]}) @@ -21,18 +21,28 @@ defmodule Cadet.Assessments.SubmissionTest do valid_params = %{student_id: student.id, assessment_id: assessment.id} valid_params_with_team = %{student_id: nil, team_id: team.id, assessment_id: assessment.id} invalid_params_without_both = %{student_id: nil, team_id: nil, assessment_id: assessment.id} - invalid_params_with_both = %{student_id: student1.id, team_id: team.id, assessment_id: assessment.id} - {:ok, %{assessment: assessment, student: student, team: team, - valid_params: valid_params, - valid_params_with_team: valid_params_with_team, - invalid_params_without_both: invalid_params_without_both, - invalid_params_with_both: invalid_params_with_both}} + invalid_params_with_both = %{ + student_id: student1.id, + team_id: team.id, + assessment_id: assessment.id + } + + {:ok, + %{ + assessment: assessment, + student: student, + team: team, + valid_params: valid_params, + valid_params_with_team: valid_params_with_team, + invalid_params_without_both: invalid_params_without_both, + invalid_params_with_both: invalid_params_with_both + }} end describe "Changesets" do test "valid params", %{valid_params: params} do - params + params |> assert_changeset_db(:valid) end @@ -71,7 +81,6 @@ defmodule Cadet.Assessments.SubmissionTest do assert_changeset_db(params, :valid) end - test "invalid changeset without team and student", %{ invalid_params_without_both: params } do diff --git a/test/cadet/auth/empty_guardian_test.exs b/test/cadet/auth/empty_guardian_test.exs index b76c2af98..54a3258b7 100644 --- a/test/cadet/auth/empty_guardian_test.exs +++ b/test/cadet/auth/empty_guardian_test.exs @@ -21,4 +21,4 @@ defmodule Cadet.Auth.EmptyGuardianTest do assert EmptyGuardian.config(:other_key, :default) == :default end end -end \ No newline at end of file +end diff --git a/test/cadet_web/admin_controllers/admin_grading_controller_test.exs b/test/cadet_web/admin_controllers/admin_grading_controller_test.exs index a4cb37f45..e60c9f55e 100644 --- a/test/cadet_web/admin_controllers/admin_grading_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_grading_controller_test.exs @@ -1285,7 +1285,9 @@ defmodule CadetWeb.AdminGradingControllerTest do is_published: true, max_team_size: 1 }) -S + + S + questions = for index <- 0..2 do # insert with display order in reverse diff --git a/test/cadet_web/admin_controllers/admin_teams_controller_test.exs b/test/cadet_web/admin_controllers/admin_teams_controller_test.exs index 5707222b2..f2ba20117 100644 --- a/test/cadet_web/admin_controllers/admin_teams_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_teams_controller_test.exs @@ -63,14 +63,16 @@ defmodule CadetWeb.AdminTeamsControllerTest do assessment = insert(:assessment, %{course: course, max_team_size: 2}) student1 = insert(:course_registration, %{course: course}) student2 = insert(:course_registration, %{course: course}) - team_params = %{"team" => - %{ - "assessment_id" => assessment.id, + + team_params = %{ + "team" => %{ + "assessment_id" => assessment.id, # student_ids is a list of lists of maps where each map is a student with attributes # userId where this userId is the CourseRegistration.id "student_ids" => [[%{userId: student1.id}, %{userId: student2.id}]] - } } + } + conn = post(conn, build_url(course_id), team_params) assert response(conn, 201) =~ "Teams created successfully." end @@ -81,14 +83,16 @@ defmodule CadetWeb.AdminTeamsControllerTest do course = Repo.get(Course, course_id) assessment = insert(:assessment, %{course: course, max_team_size: 2}) student1 = insert(:course_registration, %{course: course}) - team_params = %{"team" => - %{ - "assessment_id" => assessment.id, + + team_params = %{ + "team" => %{ + "assessment_id" => assessment.id, # student_ids is a list of lists of maps where each map is a student with attributes # userId where this userId is the CourseRegistration.id "student_ids" => [[%{userId: student1.id}, %{userId: student1.id}]] - } } + } + conn = post(conn, build_url(course_id), team_params) assert response(conn, 409) =~ "One or more students appear multiple times in a team!" end @@ -101,14 +105,18 @@ defmodule CadetWeb.AdminTeamsControllerTest do student1 = insert(:course_registration, %{course: course}) student2 = insert(:course_registration, %{course: course}) student3 = insert(:course_registration, %{course: course}) - team_params = %{"team" => - %{ - "assessment_id" => assessment.id, + + team_params = %{ + "team" => %{ + "assessment_id" => assessment.id, # student_ids is a list of lists of maps where each map is a student with attributes # userId where this userId is the CourseRegistration.id - "student_ids" => [[%{userId: student1.id}, %{userId: student2.id}, %{userId: student3.id}]] - } + "student_ids" => [ + [%{userId: student1.id}, %{userId: student2.id}, %{userId: student3.id}] + ] } + } + conn = post(conn, build_url(course_id), team_params) assert response(conn, 409) =~ "One or more teams exceed the maximum team size!" end @@ -120,20 +128,24 @@ defmodule CadetWeb.AdminTeamsControllerTest do assessment = insert(:assessment, %{course: course, max_team_size: 2}) student1 = insert(:course_registration, %{course: course}) student2 = insert(:course_registration) - team_params = %{"team" => - %{ - "assessment_id" => assessment.id, + + team_params = %{ + "team" => %{ + "assessment_id" => assessment.id, # student_ids is a list of lists of maps where each map is a student with attributes # userId where this userId is the CourseRegistration.id "student_ids" => [[%{userId: student1.id}, %{userId: student2.id}]] - } } + } + conn = post(conn, build_url(course_id), team_params) assert response(conn, 409) =~ "One or more students not enrolled in this course!" end @tag authenticate: :staff - test "creates an invalid team where student already has a team for this assessment", %{conn: conn} do + test "creates an invalid team where student already has a team for this assessment", %{ + conn: conn + } do course_id = conn.assigns.course_id course = Repo.get(Course, course_id) assessment = insert(:assessment, %{course: course, max_team_size: 2}) @@ -143,14 +155,16 @@ defmodule CadetWeb.AdminTeamsControllerTest do team = insert(:team, %{assessment: assessment}) _team_member1 = insert(:team_member, %{team: team, student: student1}) _team_member2 = insert(:team_member, %{team: team, student: student2}) - team_params = %{"team" => - %{ - "assessment_id" => assessment.id, + + team_params = %{ + "team" => %{ + "assessment_id" => assessment.id, # student_ids is a list of lists of maps where each map is a student with attributes # userId where this userId is the CourseRegistration.id "student_ids" => [[%{userId: student1.id}, %{userId: student3.id}]] - } } + } + conn = post(conn, build_url(course_id), team_params) assert response(conn, 409) =~ "One or more students already in a team for this assessment!" end @@ -182,12 +196,14 @@ defmodule CadetWeb.AdminTeamsControllerTest do _team_member1 = insert(:team_member, %{team: team, student: student1}) _team_member2 = insert(:team_member, %{team: team, student: student2}) _team_member3 = insert(:team_member, %{team: team, student: student3}) + updated_team_params = %{ "course_id" => course.id, "teamId" => team.id, - "assessmentId" => assessment.id, + "assessmentId" => assessment.id, "student_ids" => [[%{userId: student1.id}, %{userId: student3.id}]] - } + } + conn = put(conn, build_url(course_id, team.id), updated_team_params) assert response(conn, 200) =~ "Teams updated successfully." end @@ -204,14 +220,18 @@ defmodule CadetWeb.AdminTeamsControllerTest do _team_member1 = insert(:team_member, %{team: team1, student: student1}) _team_member2 = insert(:team_member, %{team: team1, student: student2}) team2 = insert(:team, %{assessment: assessment}) + updated_team_params = %{ "course_id" => course.id, "teamId" => team2.id, - "assessmentId" => assessment.id, + "assessmentId" => assessment.id, "student_ids" => [[%{userId: student1.id}, %{userId: student3.id}]] - } + } + conn = put(conn, build_url(course_id, team2.id), updated_team_params) - assert response(conn, 409) =~ "One or more students are already in another team for the same assessment!" + + assert response(conn, 409) =~ + "One or more students are already in another team for the same assessment!" end end @@ -251,19 +271,38 @@ defmodule CadetWeb.AdminTeamsControllerTest do course_id = conn.assigns.course_id course = Repo.get(Course, course_id) config = insert(:assessment_config, %{course: course}) - assessment = insert(:assessment, %{is_published: true, course: course, config: config, max_team_size: 2}) + + assessment = + insert(:assessment, %{ + is_published: true, + course: course, + config: config, + max_team_size: 2 + }) + student1 = insert(:course_registration, %{course: course}) student2 = insert(:course_registration, %{course: course}) team = insert(:team, %{assessment: assessment}) _team_member1 = insert(:team_member, %{team: team, student: student1}) _team_member2 = insert(:team_member, %{team: team, student: student2}) - _submission = insert(:submission, %{assessment: assessment, team: team, student: nil, status: :submitted}) + + _submission = + insert(:submission, %{ + assessment: assessment, + team: team, + student: nil, + status: :submitted + }) + conn = delete(conn, build_url(course_id, team.id)) - assert response(conn, 409) =~ "This team has submitted their answers! Unable to delete the team!" + + assert response(conn, 409) =~ + "This team has submitted their answers! Unable to delete the team!" end end defp build_url(course_id), do: "/v2/courses/#{course_id}/admin/teams/" + defp build_url(course_id, team_id), do: "#{build_url(course_id)}#{team_id}" end diff --git a/test/cadet_web/controllers/answer_controller_test.exs b/test/cadet_web/controllers/answer_controller_test.exs index 6a5e1fe09..ff6051c62 100644 --- a/test/cadet_web/controllers/answer_controller_test.exs +++ b/test/cadet_web/controllers/answer_controller_test.exs @@ -335,27 +335,43 @@ defmodule CadetWeb.AnswerControllerTest do last_modified_at = DateTime.to_iso8601(DateTime.utc_now()) check_last_modified_conn = - post(conn, "/v2/courses/#{course_id}/assessments/question/#{question_id}/answerLastModified/", %{ - lastModifiedAt: last_modified_at - }) + post( + conn, + "/v2/courses/#{course_id}/assessments/question/#{question_id}/answerLastModified/", + %{ + lastModifiedAt: last_modified_at + } + ) assert response(check_last_modified_conn, 200) =~ "{\"lastModified\":false}" end @tag authenticate: :student - test "check last modified true", %{conn: conn, assessment: assessment, programming_question: programming_question} do + test "check last modified true", %{ + conn: conn, + assessment: assessment, + programming_question: programming_question + } do course_id = conn.assigns.course_id last_modified_at = DateTime.to_iso8601(DateTime.utc_now()) question_id = programming_question.id submission = insert(:submission, %{assessment: assessment, student: conn.assigns.test_cr}) - _answer = insert(:answer, %{question: programming_question, last_modified_at: last_modified_at, submission: submission}) - + _answer = + insert(:answer, %{ + question: programming_question, + last_modified_at: last_modified_at, + submission: submission + }) check_last_modified_conn = - post(conn, "/v2/courses/#{course_id}/assessments/question/#{question_id}/answerLastModified/", %{ - lastModifiedAt: last_modified_at - }) + post( + conn, + "/v2/courses/#{course_id}/assessments/question/#{question_id}/answerLastModified/", + %{ + lastModifiedAt: last_modified_at + } + ) assert response(check_last_modified_conn, 200) =~ "{\"lastModified\":true}" end @@ -383,9 +399,13 @@ defmodule CadetWeb.AnswerControllerTest do last_modified_at = DateTime.to_iso8601(DateTime.utc_now()) check_last_modified_conn = - post(conn, "/v2/courses/#{course_id}/assessments/question/#{question_id}/answerLastModified", %{ - lastModifiedAt: last_modified_at - }) + post( + conn, + "/v2/courses/#{course_id}/assessments/question/#{question_id}/answerLastModified", + %{ + lastModifiedAt: last_modified_at + } + ) assert response(check_last_modified_conn, 404) == "Question not found" end @@ -401,16 +421,21 @@ defmodule CadetWeb.AnswerControllerTest do close_at: Timex.shift(Timex.now(), days: 10) }) - before_open_at_question = insert(:programming_question, %{assessment: before_open_at_assessment}) + before_open_at_question = + insert(:programming_question, %{assessment: before_open_at_assessment}) + + _unpublished_conn = post(conn, build_url(course_id, before_open_at_question.id), %{answer: 5}) - _unpublished_conn = - post(conn, build_url(course_id, before_open_at_question.id), %{answer: 5}) - question_id = before_open_at_question.id + check_last_modified_conn = - post(conn, "/v2/courses/#{course_id}/assessments/question/#{question_id}/answerLastModified", %{ - lastModifiedAt: last_modified_at - }) + post( + conn, + "/v2/courses/#{course_id}/assessments/question/#{question_id}/answerLastModified", + %{ + lastModifiedAt: last_modified_at + } + ) assert response(check_last_modified_conn, 403) == "Assessment not open" end diff --git a/test/cadet_web/controllers/teams_controller_test.exs b/test/cadet_web/controllers/teams_controller_test.exs index 2b48c2ebd..33409ec02 100644 --- a/test/cadet_web/controllers/teams_controller_test.exs +++ b/test/cadet_web/controllers/teams_controller_test.exs @@ -4,14 +4,15 @@ defmodule CadetWeb.TeamsControllerTest do alias Cadet.Repo alias Cadet.Courses.Course alias CadetWeb.TeamController + setup do Cadet.Test.Seeds.assessments() end + test "swagger" do TeamController.swagger_path_index(nil) end - describe "GET /v2/admin/teams" do @tag authenticate: :student test "unauthorized with student", %{conn: conn} do @@ -43,12 +44,11 @@ defmodule CadetWeb.TeamsControllerTest do assessmentName: assessment.title, assessmentType: assessment.config.type, studentIds: [], - studentNames: [], + studentNames: [] } + assert response(conn, 200) == "[#{Jason.encode!(teamFormationOverview)}]" end - - end describe "GET /v2/courses/:course_id/team/:assessment_id" do @@ -72,7 +72,12 @@ defmodule CadetWeb.TeamsControllerTest do teammember1 = insert(:team_member, %{student: cr1}) teammember2 = insert(:team_member, %{student: cr2}) teammember3 = insert(:team_member, %{student: cr}) - team = insert(:team, %{assessment: assessment, team_members: [teammember1, teammember2, teammember3]}) + + team = + insert(:team, %{ + assessment: assessment, + team_members: [teammember1, teammember2, teammember3] + }) conn = get(conn, build_url_get_by_assessment(course.id, assessment.id)) @@ -81,12 +86,16 @@ defmodule CadetWeb.TeamsControllerTest do assessmentId: assessment.id, assessmentName: assessment.title, assessmentType: assessment.config.type, - studentIds: [cr1.user.id,cr2.user.id,cr.user.id], - studentNames: [cr1.user.name, cr2.user.name, cr.user.name], + studentIds: [cr1.user.id, cr2.user.id, cr.user.id], + studentNames: [cr1.user.name, cr2.user.name, cr.user.name] } + assert response(conn, 200) == "#{Jason.encode!(teamFormationOverview)}" end end + defp build_url_get(course_id), do: "/v2/courses/#{course_id}/admin/teams" - defp build_url_get_by_assessment(course_id, assessment_id), do: "/v2/courses/#{course_id}/team/#{assessment_id}" -end \ No newline at end of file + + defp build_url_get_by_assessment(course_id, assessment_id), + do: "/v2/courses/#{course_id}/team/#{assessment_id}" +end diff --git a/test/cadet_web/views/answer_view_test.exs b/test/cadet_web/views/answer_view_test.exs index 13488ab80..acbd949ca 100644 --- a/test/cadet_web/views/answer_view_test.exs +++ b/test/cadet_web/views/answer_view_test.exs @@ -12,4 +12,4 @@ defmodule CadetWeb.AnswerViewTest do assert json[:lastModified] == @lastModified end end -end \ No newline at end of file +end diff --git a/test/cadet_web/views/team_view_test.exs b/test/cadet_web/views/team_view_test.exs index 7bbf9c9e7..be0470735 100644 --- a/test/cadet_web/views/team_view_test.exs +++ b/test/cadet_web/views/team_view_test.exs @@ -24,4 +24,4 @@ defmodule CadetWeb.TeamViewTest do assert json[:studentNames] == @teamFormationOverview.studentNames end end -end \ No newline at end of file +end diff --git a/test/factories/accounts/team_factory.ex b/test/factories/accounts/team_factory.ex index 57140de2d..36a7adc5a 100644 --- a/test/factories/accounts/team_factory.ex +++ b/test/factories/accounts/team_factory.ex @@ -13,7 +13,6 @@ defmodule Cadet.Accounts.TeamFactory do assessment: build(:assessment) } end - end end end diff --git a/test/factories/accounts/team_member_factory.ex b/test/factories/accounts/team_member_factory.ex index 4e48c18e0..5db6c6796 100644 --- a/test/factories/accounts/team_member_factory.ex +++ b/test/factories/accounts/team_member_factory.ex @@ -14,7 +14,6 @@ defmodule Cadet.Accounts.TeamMemberFactory do team: build(:team) } end - end end end diff --git a/test/factories/factory.ex b/test/factories/factory.ex index 6daf24364..86059152b 100644 --- a/test/factories/factory.ex +++ b/test/factories/factory.ex @@ -5,10 +5,10 @@ defmodule Cadet.Factory do use ExMachina.Ecto, repo: Cadet.Repo use Cadet.Accounts.{ - NotificationFactory, - UserFactory, - CourseRegistrationFactory, - TeamFactory, + NotificationFactory, + UserFactory, + CourseRegistrationFactory, + TeamFactory, TeamMemberFactory } From f64cd769440dfa67c28fb6363da970a8fef5166d Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Sun, 21 Jan 2024 00:12:13 +0800 Subject: [PATCH 097/128] Fix credo errors * Fix casing (code quality) * Fix other miscellaneous code quality issues * Revert credo dependency version update * Fix credo configuration --- .credo.exs | 4 ++-- lib/cadet/accounts/notifications.ex | 9 +++++---- lib/cadet/accounts/team_member.ex | 3 +-- lib/cadet/accounts/teams.ex | 12 ++++++------ lib/cadet/assessments/assessments.ex | 15 +++++++++------ .../admin_controllers/admin_teams_controller.ex | 8 ++++---- lib/cadet_web/controllers/answer_controller.ex | 9 ++++++--- lib/cadet_web/controllers/team_controller.ex | 8 ++++---- lib/cadet_web/router.ex | 2 +- mix.lock | 4 ++-- test/cadet/accounts/team_members_test.exs | 2 +- test/cadet/accounts/teams_test.exs | 8 ++++---- .../controllers/answer_controller_test.exs | 10 ++++++++-- .../controllers/teams_controller_test.exs | 8 ++++---- test/cadet_web/views/answer_view_test.exs | 6 +++--- test/cadet_web/views/team_view_test.exs | 16 ++++++++-------- 16 files changed, 68 insertions(+), 56 deletions(-) diff --git a/.credo.exs b/.credo.exs index cc2e7c08b..9828a7ad2 100644 --- a/.credo.exs +++ b/.credo.exs @@ -94,13 +94,13 @@ {Credo.Check.Readability.SpaceAfterCommas}, {Credo.Check.Refactor.DoubleBooleanNegation}, {Credo.Check.Refactor.CondStatements}, - {Credo.Check.Refactor.CyclomaticComplexity}, + {Credo.Check.Refactor.CyclomaticComplexity, max_complexity: 10}, {Credo.Check.Refactor.FunctionArity}, {Credo.Check.Refactor.LongQuoteBlocks}, {Credo.Check.Refactor.MatchInCondition}, {Credo.Check.Refactor.NegatedConditionsInUnless}, {Credo.Check.Refactor.NegatedConditionsWithElse}, - {Credo.Check.Refactor.Nesting}, + {Credo.Check.Refactor.Nesting, max_nesting: 5}, {Credo.Check.Refactor.PipeChainStart}, {Credo.Check.Refactor.UnlessWithElse}, {Credo.Check.Warning.BoolOperationOnSameValues}, diff --git a/lib/cadet/accounts/notifications.ex b/lib/cadet/accounts/notifications.ex index e66482df2..901cf061d 100644 --- a/lib/cadet/accounts/notifications.ex +++ b/lib/cadet/accounts/notifications.ex @@ -259,11 +259,12 @@ defmodule Cadet.Accounts.Notifications do team_id = String.to_integer(to_string(submission.team_id)) team = - from(t in Team, - where: t.id == ^team_id, - preload: [:team_members] + Repo.one( + from(t in Team, + where: t.id == ^team_id, + preload: [:team_members] + ) ) - |> Repo.one() s_id = team.team_members |> hd() |> Map.get(:student_id) s_id diff --git a/lib/cadet/accounts/team_member.ex b/lib/cadet/accounts/team_member.ex index 8903cdb80..45b901a6d 100644 --- a/lib/cadet/accounts/team_member.ex +++ b/lib/cadet/accounts/team_member.ex @@ -8,8 +8,7 @@ defmodule Cadet.Accounts.TeamMember do import Ecto.Changeset - alias Cadet.Accounts.CourseRegistration - alias Cadet.Accounts.Team + alias Cadet.Accounts.{CourseRegistration, Team} @doc """ Ecto schema definition for team members. diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index 299632a9b..347a36e06 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -6,8 +6,7 @@ defmodule Cadet.Accounts.Teams do use Cadet, [:context, :display] use Ecto.Schema - import Ecto.Changeset - import Ecto.Query + import Ecto.{Changeset, Query} alias Cadet.Repo alias Cadet.Accounts.{Team, TeamMember, CourseRegistration, Notification} @@ -209,7 +208,7 @@ defmodule Cadet.Accounts.Teams do Returns a tuple `{:ok, updated_team}` on success, containing the updated team details; otherwise, an error tuple. """ - def update_team(%Team{} = team, new_assessment_id, student_ids) do + def update_team(team = %Team{}, new_assessment_id, student_ids) do old_assessment_id = team.assessment_id team_id = team.id new_student_ids = Enum.map(hd(student_ids), fn student -> Map.get(student, "userId") end) @@ -262,8 +261,9 @@ defmodule Cadet.Accounts.Teams do end) Enum.each(student_ids_to_remove, fn student_id -> - from(tm in TeamMember, where: tm.team_id == ^team_id and tm.student_id == ^student_id) - |> Repo.delete_all() + Repo.delete_all( + from(tm in TeamMember, where: tm.team_id == ^team_id and tm.student_id == ^student_id) + ) end) end @@ -275,7 +275,7 @@ defmodule Cadet.Accounts.Teams do * `team` - The team to be deleted """ - def delete_team(%Team{} = team) do + def delete_team(team = %Team{}) do if has_submitted_answer?(team.id) do {:error, {:conflict, "This team has submitted their answers! Unable to delete the team!"}} else diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 172e13c62..31c556eee 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -846,13 +846,16 @@ defmodule Cadet.Assessments do ) assessment_team_size = - Repo.one( - from(a in Assessment, - where: a.id == ^assessment_id, - select: %{max_team_size: a.max_team_size} - ) + Map.get( + Repo.one( + from(a in Assessment, + where: a.id == ^assessment_id, + select: %{max_team_size: a.max_team_size} + ) + ), + :max_team_size, + 0 ) - |> Map.get(:max_team_size, 0) case assessment_team_size > 1 do true -> diff --git a/lib/cadet_web/admin_controllers/admin_teams_controller.ex b/lib/cadet_web/admin_controllers/admin_teams_controller.ex index 94c0769b5..c91974404 100644 --- a/lib/cadet_web/admin_controllers/admin_teams_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_teams_controller.ex @@ -11,20 +11,20 @@ defmodule CadetWeb.AdminTeamsController do |> Repo.all() |> Repo.preload(assessment: [:config], team_members: [student: [:user]]) - teamFormationOverviews = + team_formation_overviews = teams |> Enum.map(&team_to_team_formation_overview/1) conn |> put_status(:ok) |> put_resp_content_type("application/json") - |> render("index.json", teamFormationOverviews: teamFormationOverviews) + |> render("index.json", team_formation_overviews: team_formation_overviews) end defp team_to_team_formation_overview(team) do assessment = team.assessment - teamFormationOverview = %{ + team_formation_overview = %{ teamId: team.id, assessmentId: assessment.id, assessmentName: assessment.title, @@ -33,7 +33,7 @@ defmodule CadetWeb.AdminTeamsController do studentNames: team.team_members |> Enum.map(& &1.student.user.name) } - teamFormationOverview + team_formation_overview end def create(conn, %{"team" => team_params}) do diff --git a/lib/cadet_web/controllers/answer_controller.ex b/lib/cadet_web/controllers/answer_controller.ex index 95aa19d53..1e2afc4da 100644 --- a/lib/cadet_web/controllers/answer_controller.ex +++ b/lib/cadet_web/controllers/answer_controller.ex @@ -38,7 +38,10 @@ defmodule CadetWeb.AnswerController do end end - def checkLastModified(conn, %{"questionid" => question_id, "lastModifiedAt" => last_modified_at}) + def check_last_modified(conn, %{ + "questionid" => question_id, + "lastModifiedAt" => last_modified_at + }) when is_ecto_id(question_id) do course_reg = conn.assigns[:course_reg] can_bypass? = course_reg.role in @bypass_closed_roles @@ -47,7 +50,7 @@ defmodule CadetWeb.AnswerController do {:question, Assessments.get_question(question_id)}, {:is_open?, true} <- {:is_open?, can_bypass? or Assessments.is_open?(question.assessment)}, - {:ok, lastModified} <- + {:ok, last_modified} <- Assessments.has_last_modified_answer?( question, course_reg, @@ -57,7 +60,7 @@ defmodule CadetWeb.AnswerController do conn |> put_status(:ok) |> put_resp_content_type("application/json") - |> render("lastModified.json", lastModified: lastModified) + |> render("lastModified.json", lastModified: last_modified) else {:question, nil} -> conn diff --git a/lib/cadet_web/controllers/team_controller.ex b/lib/cadet_web/controllers/team_controller.ex index c897241bc..3b18c477e 100644 --- a/lib/cadet_web/controllers/team_controller.ex +++ b/lib/cadet_web/controllers/team_controller.ex @@ -32,19 +32,19 @@ defmodule CadetWeb.TeamController do |> put_status(:ok) |> text("Team is not found!") else - teamFormationOverview = team_to_team_formation_overview(team) + team_formation_overview = team_to_team_formation_overview(team) conn |> put_status(:ok) |> put_resp_content_type("application/json") - |> render("index.json", teamFormationOverview: teamFormationOverview) + |> render("index.json", teamFormationOverview: team_formation_overview) end end defp team_to_team_formation_overview(team) do assessment = team.assessment - teamFormationOverview = %{ + team_formation_overview = %{ teamId: team.id, assessmentId: assessment.id, assessmentName: assessment.title, @@ -53,7 +53,7 @@ defmodule CadetWeb.TeamController do studentNames: team.team_members |> Enum.map(& &1.student.user.name) } - teamFormationOverview + team_formation_overview end swagger_path :index do diff --git a/lib/cadet_web/router.ex b/lib/cadet_web/router.ex index d0a29aad1..9c34cf126 100644 --- a/lib/cadet_web/router.ex +++ b/lib/cadet_web/router.ex @@ -81,7 +81,7 @@ defmodule CadetWeb.Router do post( "/assessments/question/:questionid/answerLastModified", AnswerController, - :checkLastModified + :check_last_modified ) get("/achievements", IncentivesController, :index_achievements) diff --git a/mix.lock b/mix.lock index 5f3385bf2..d1df6760e 100644 --- a/mix.lock +++ b/mix.lock @@ -17,7 +17,7 @@ "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, - "credo": {:hex, :credo, "1.7.2", "fdee3a7cb553d8f2e773569181f0a4a2bb7d192e27e325404cc31b354f59d68c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dd15d6fbc280f6cf9b269f41df4e4992dee6615939653b164ef951f60afcb68e"}, + "credo": {:hex, :credo, "1.7.1", "6e26bbcc9e22eefbff7e43188e69924e78818e2fe6282487d0703652bc20fd62", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e9871c6095a4c0381c89b6aa98bc6260a8ba6addccf7f6a53da8849c748a58a2"}, "crontab": {:hex, :crontab, "1.1.13", "3bad04f050b9f7f1c237809e42223999c150656a6b2afbbfef597d56df2144c5", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "d67441bec989640e3afb94e123f45a2bc42d76e02988c9613885dc3d01cf7085"}, "csv": {:hex, :csv, "3.2.1", "6d401f1ed33acb2627682a9ab6021e96d33ca6c1c6bccc243d8f7e2197d032f5", [:mix], [], "hexpm", "8f55a0524923ae49e97ff2642122a2ce7c61e159e7fe1184670b2ce847aee6c8"}, "db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"}, @@ -43,7 +43,7 @@ "expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"}, "exvcr": {:hex, :exvcr, "0.15.0", "432a4f4b94494f996c96dd2b9b9d3306b70db269ddbdeb9e324a4371f62ce32d", [:mix], [{:exactor, "~> 2.2", [hex: :exactor, repo: "hexpm", optional: false]}, {:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:finch, "~> 0.16", [hex: :finch, repo: "hexpm", optional: true]}, {:httpoison, "~> 1.0 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.1", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "8b7e451f5fd37d1dc1252d08e55291fcb80b55b00cfd84ea41bf64be23cb142c"}, "faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"}, - "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, + "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "gen_smtp": {:hex, :gen_smtp, "1.2.0", "9cfc75c72a8821588b9b9fe947ae5ab2aed95a052b81237e0928633a13276fd3", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "5ee0375680bca8f20c4d85f58c2894441443a743355430ff33a783fe03296779"}, "gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"}, "gettext": {:hex, :gettext, "0.22.2", "6bfca374de34ecc913a28ba391ca184d88d77810a3e427afa8454a71a51341ac", [:mix], [{:expo, "~> 0.4.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "8a2d389673aea82d7eae387e6a2ccc12660610080ae7beb19452cfdc1ec30f60"}, diff --git a/test/cadet/accounts/team_members_test.exs b/test/cadet/accounts/team_members_test.exs index 4c33a6b7b..42f1dca89 100644 --- a/test/cadet/accounts/team_members_test.exs +++ b/test/cadet/accounts/team_members_test.exs @@ -1,7 +1,7 @@ defmodule Cadet.Accounts.TeamMemberTest do use Cadet.DataCase, async: true - alias Cadet.Accounts.{TeamMember} + alias Cadet.Accounts.TeamMember alias Cadet.Repo @valid_attrs %{student_id: 1, team_id: 1} diff --git a/test/cadet/accounts/teams_test.exs b/test/cadet/accounts/teams_test.exs index 575479077..c7b5129c4 100644 --- a/test/cadet/accounts/teams_test.exs +++ b/test/cadet/accounts/teams_test.exs @@ -245,7 +245,7 @@ defmodule Cadet.Accounts.TeamTest do attrs_invalid = %{ "assessment_id" => assessment1.id, "student_ids" => [ - [%{"userId" => course_reg1.id}, %{"userId" => course_reg2.id}, %{"userId" => 99999}] + [%{"userId" => course_reg1.id}, %{"userId" => course_reg2.id}, %{"userId" => 99_999}] ] } @@ -464,21 +464,21 @@ defmodule Cadet.Accounts.TeamTest do |> where([tm], tm.team_id == ^team.id) |> Repo.all() - assert length(team_members) == 0 + assert Enum.empty(team_members) submissions = Submission |> where([s], s.team_id == ^team.id) |> Repo.all() - assert length(submissions) == 0 + assert Enum.empty(submissions) answers = Answer |> where(submission_id: ^submission_id) |> Repo.all() - assert length(answers) == 0 + assert Enum.empty(answers) end test "delete an existing team with submission", %{ diff --git a/test/cadet_web/controllers/answer_controller_test.exs b/test/cadet_web/controllers/answer_controller_test.exs index ff6051c62..3117b230e 100644 --- a/test/cadet_web/controllers/answer_controller_test.exs +++ b/test/cadet_web/controllers/answer_controller_test.exs @@ -377,7 +377,11 @@ defmodule CadetWeb.AnswerControllerTest do end # @tag authenticate: :student - # test "check last modified, invalid params", %{conn: conn, assessment: assessment, mcq_question: mcq_question} do + # test "check last modified, invalid params", %{ + # conn: conn, + # assessment: assessment, + # mcq_question: mcq_question + # } do # course_reg = conn.assigns.test_cr # course_id = conn.assigns.course_id @@ -385,7 +389,9 @@ defmodule CadetWeb.AnswerControllerTest do # invalid_last_modified_at = "invalid_timestamp" # check_last_modified_conn = - # post(conn, "/v2/courses/#{course_id}/assessments/question/#{question_id}/answerLastModified", %{ + # post( + # conn, + # "/v2/courses/#{course_id}/assessments/question/#{question_id}/answerLastModified", %{ # lastModifiedAt: invalid_last_modified_at # }) diff --git a/test/cadet_web/controllers/teams_controller_test.exs b/test/cadet_web/controllers/teams_controller_test.exs index 33409ec02..b359633fc 100644 --- a/test/cadet_web/controllers/teams_controller_test.exs +++ b/test/cadet_web/controllers/teams_controller_test.exs @@ -38,7 +38,7 @@ defmodule CadetWeb.TeamsControllerTest do team = insert(:team, %{assessment: assessment}) conn = get(conn, build_url_get(course.id)) - teamFormationOverview = %{ + team_formation_overview = %{ teamId: team.id, assessmentId: assessment.id, assessmentName: assessment.title, @@ -47,7 +47,7 @@ defmodule CadetWeb.TeamsControllerTest do studentNames: [] } - assert response(conn, 200) == "[#{Jason.encode!(teamFormationOverview)}]" + assert response(conn, 200) == "[#{Jason.encode!(team_formation_overview)}]" end end @@ -81,7 +81,7 @@ defmodule CadetWeb.TeamsControllerTest do conn = get(conn, build_url_get_by_assessment(course.id, assessment.id)) - teamFormationOverview = %{ + team_formation_overview = %{ teamId: team.id, assessmentId: assessment.id, assessmentName: assessment.title, @@ -90,7 +90,7 @@ defmodule CadetWeb.TeamsControllerTest do studentNames: [cr1.user.name, cr2.user.name, cr.user.name] } - assert response(conn, 200) == "#{Jason.encode!(teamFormationOverview)}" + assert response(conn, 200) == "#{Jason.encode!(team_formation_overview)}" end end diff --git a/test/cadet_web/views/answer_view_test.exs b/test/cadet_web/views/answer_view_test.exs index acbd949ca..591c2e360 100644 --- a/test/cadet_web/views/answer_view_test.exs +++ b/test/cadet_web/views/answer_view_test.exs @@ -3,13 +3,13 @@ defmodule CadetWeb.AnswerViewTest do alias CadetWeb.AnswerView - @lastModified ~U[2022-01-01T00:00:00Z] + @last_modified ~U[2022-01-01T00:00:00Z] describe "render/2" do test "renders last modified timestamp as JSON" do - json = AnswerView.render("lastModified.json", %{lastModified: @lastModified}) + json = AnswerView.render("lastModified.json", %{lastModified: @last_modified}) - assert json[:lastModified] == @lastModified + assert json[:lastModified] == @last_modified end end end diff --git a/test/cadet_web/views/team_view_test.exs b/test/cadet_web/views/team_view_test.exs index be0470735..bc5d8a9ee 100644 --- a/test/cadet_web/views/team_view_test.exs +++ b/test/cadet_web/views/team_view_test.exs @@ -3,7 +3,7 @@ defmodule CadetWeb.TeamViewTest do alias CadetWeb.TeamView - @teamFormationOverview %{ + @team_formation_overview %{ teamId: 1, assessmentId: 2, assessmentName: "Test Assessment", @@ -14,14 +14,14 @@ defmodule CadetWeb.TeamViewTest do describe "render/2" do test "renders team formation overview as JSON" do - json = TeamView.render("index.json", %{teamFormationOverview: @teamFormationOverview}) + json = TeamView.render("index.json", %{teamFormationOverview: @team_formation_overview}) - assert json[:teamId] == @teamFormationOverview.teamId - assert json[:assessmentId] == @teamFormationOverview.assessmentId - assert json[:assessmentName] == @teamFormationOverview.assessmentName - assert json[:assessmentType] == @teamFormationOverview.assessmentType - assert json[:studentIds] == @teamFormationOverview.studentIds - assert json[:studentNames] == @teamFormationOverview.studentNames + assert json[:teamId] == @team_formation_overview.teamId + assert json[:assessmentId] == @team_formation_overview.assessmentId + assert json[:assessmentName] == @team_formation_overview.assessmentName + assert json[:assessmentType] == @team_formation_overview.assessmentType + assert json[:studentIds] == @team_formation_overview.studentIds + assert json[:studentNames] == @team_formation_overview.studentNames end end end From ef46487abf55922f96c63fcd6aeec3f9746eed21 Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Sun, 21 Jan 2024 00:17:05 +0800 Subject: [PATCH 098/128] Fix failing tests --- test/cadet/accounts/teams_test.exs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/cadet/accounts/teams_test.exs b/test/cadet/accounts/teams_test.exs index c7b5129c4..6b7c521f2 100644 --- a/test/cadet/accounts/teams_test.exs +++ b/test/cadet/accounts/teams_test.exs @@ -464,21 +464,21 @@ defmodule Cadet.Accounts.TeamTest do |> where([tm], tm.team_id == ^team.id) |> Repo.all() - assert Enum.empty(team_members) + assert team_members == [] submissions = Submission |> where([s], s.team_id == ^team.id) |> Repo.all() - assert Enum.empty(submissions) + assert submissions == [] answers = Answer |> where(submission_id: ^submission_id) |> Repo.all() - assert Enum.empty(answers) + assert answers == [] end test "delete an existing team with submission", %{ From 7852338b8effecb3936c51c906d3a472778ae33f Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Sun, 21 Jan 2024 00:49:22 +0800 Subject: [PATCH 099/128] Remove unused variables --- .../admin_teams_controller_test.exs | 48 ++++++++----------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/test/cadet_web/admin_controllers/admin_teams_controller_test.exs b/test/cadet_web/admin_controllers/admin_teams_controller_test.exs index f2ba20117..761a0a31d 100644 --- a/test/cadet_web/admin_controllers/admin_teams_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_teams_controller_test.exs @@ -31,9 +31,10 @@ defmodule CadetWeb.AdminTeamsControllerTest do test "returns a list of teams", %{conn: conn} do course_id = conn.assigns.course_id team = insert(:team) - _team_member1 = insert(:team_member, %{team: team}) - _team_member2 = insert(:team_member, %{team: team}) + insert(:team_member, %{team: team}) + insert(:team_member, %{team: team}) + IO.inspect(build_url(course_id)) conn = get(conn, build_url(course_id)) assert response(conn, 200) @@ -67,8 +68,6 @@ defmodule CadetWeb.AdminTeamsControllerTest do team_params = %{ "team" => %{ "assessment_id" => assessment.id, - # student_ids is a list of lists of maps where each map is a student with attributes - # userId where this userId is the CourseRegistration.id "student_ids" => [[%{userId: student1.id}, %{userId: student2.id}]] } } @@ -87,8 +86,6 @@ defmodule CadetWeb.AdminTeamsControllerTest do team_params = %{ "team" => %{ "assessment_id" => assessment.id, - # student_ids is a list of lists of maps where each map is a student with attributes - # userId where this userId is the CourseRegistration.id "student_ids" => [[%{userId: student1.id}, %{userId: student1.id}]] } } @@ -109,8 +106,6 @@ defmodule CadetWeb.AdminTeamsControllerTest do team_params = %{ "team" => %{ "assessment_id" => assessment.id, - # student_ids is a list of lists of maps where each map is a student with attributes - # userId where this userId is the CourseRegistration.id "student_ids" => [ [%{userId: student1.id}, %{userId: student2.id}, %{userId: student3.id}] ] @@ -132,8 +127,6 @@ defmodule CadetWeb.AdminTeamsControllerTest do team_params = %{ "team" => %{ "assessment_id" => assessment.id, - # student_ids is a list of lists of maps where each map is a student with attributes - # userId where this userId is the CourseRegistration.id "student_ids" => [[%{userId: student1.id}, %{userId: student2.id}]] } } @@ -153,14 +146,12 @@ defmodule CadetWeb.AdminTeamsControllerTest do student2 = insert(:course_registration, %{course: course}) student3 = insert(:course_registration, %{course: course}) team = insert(:team, %{assessment: assessment}) - _team_member1 = insert(:team_member, %{team: team, student: student1}) - _team_member2 = insert(:team_member, %{team: team, student: student2}) + insert(:team_member, %{team: team, student: student1}) + insert(:team_member, %{team: team, student: student2}) team_params = %{ "team" => %{ "assessment_id" => assessment.id, - # student_ids is a list of lists of maps where each map is a student with attributes - # userId where this userId is the CourseRegistration.id "student_ids" => [[%{userId: student1.id}, %{userId: student3.id}]] } } @@ -193,9 +184,9 @@ defmodule CadetWeb.AdminTeamsControllerTest do student2 = insert(:course_registration, %{course: course}) student3 = insert(:course_registration, %{course: course}) team = insert(:team, %{assessment: assessment}) - _team_member1 = insert(:team_member, %{team: team, student: student1}) - _team_member2 = insert(:team_member, %{team: team, student: student2}) - _team_member3 = insert(:team_member, %{team: team, student: student3}) + insert(:team_member, %{team: team, student: student1}) + insert(:team_member, %{team: team, student: student2}) + insert(:team_member, %{team: team, student: student3}) updated_team_params = %{ "course_id" => course.id, @@ -217,8 +208,8 @@ defmodule CadetWeb.AdminTeamsControllerTest do student2 = insert(:course_registration, %{course: course}) student3 = insert(:course_registration, %{course: course}) team1 = insert(:team, %{assessment: assessment}) - _team_member1 = insert(:team_member, %{team: team1, student: student1}) - _team_member2 = insert(:team_member, %{team: team1, student: student2}) + insert(:team_member, %{team: team1, student: student1}) + insert(:team_member, %{team: team1, student: student2}) team2 = insert(:team, %{assessment: assessment}) updated_team_params = %{ @@ -283,16 +274,15 @@ defmodule CadetWeb.AdminTeamsControllerTest do student1 = insert(:course_registration, %{course: course}) student2 = insert(:course_registration, %{course: course}) team = insert(:team, %{assessment: assessment}) - _team_member1 = insert(:team_member, %{team: team, student: student1}) - _team_member2 = insert(:team_member, %{team: team, student: student2}) - - _submission = - insert(:submission, %{ - assessment: assessment, - team: team, - student: nil, - status: :submitted - }) + insert(:team_member, %{team: team, student: student1}) + insert(:team_member, %{team: team, student: student2}) + + insert(:submission, %{ + assessment: assessment, + team: team, + student: nil, + status: :submitted + }) conn = delete(conn, build_url(course_id, team.id)) From a33b1eb5b22759c32a3a0e82eb2714e51f5709a9 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Sun, 21 Jan 2024 00:49:36 +0800 Subject: [PATCH 100/128] Fix failing tests --- lib/cadet_web/admin_views/admin_teams_view.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/cadet_web/admin_views/admin_teams_view.ex b/lib/cadet_web/admin_views/admin_teams_view.ex index fcbb01d73..1f6891093 100644 --- a/lib/cadet_web/admin_views/admin_teams_view.ex +++ b/lib/cadet_web/admin_views/admin_teams_view.ex @@ -13,8 +13,8 @@ defmodule CadetWeb.AdminTeamsView do * `teamFormationOverviews` - A list of team formation overviews to be rendered. """ - def render("index.json", %{teamFormationOverviews: teamFormationOverviews}) do - render_many(teamFormationOverviews, CadetWeb.AdminTeamsView, "team_formation_overview.json", + def render("index.json", %{team_formation_overviews: team_formation_overviews}) do + render_many(team_formation_overviews, CadetWeb.AdminTeamsView, "team_formation_overview.json", as: :team_formation_overview ) end From af335f3d6a09591a7579d2c6a1971e9c90a8277f Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Sun, 21 Jan 2024 00:53:36 +0800 Subject: [PATCH 101/128] Remove IO.inspect --- test/cadet_web/admin_controllers/admin_teams_controller_test.exs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/cadet_web/admin_controllers/admin_teams_controller_test.exs b/test/cadet_web/admin_controllers/admin_teams_controller_test.exs index 761a0a31d..32d2a517e 100644 --- a/test/cadet_web/admin_controllers/admin_teams_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_teams_controller_test.exs @@ -34,7 +34,6 @@ defmodule CadetWeb.AdminTeamsControllerTest do insert(:team_member, %{team: team}) insert(:team_member, %{team: team}) - IO.inspect(build_url(course_id)) conn = get(conn, build_url(course_id)) assert response(conn, 200) From 42198696ffc4ef3266a4e6e69b58e8e9f41fac07 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Sun, 21 Jan 2024 01:06:34 +0800 Subject: [PATCH 102/128] Fix dialyzer CI --- lib/cadet_web/controllers/answer_controller.ex | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/cadet_web/controllers/answer_controller.ex b/lib/cadet_web/controllers/answer_controller.ex index 1e2afc4da..5713ec4f0 100644 --- a/lib/cadet_web/controllers/answer_controller.ex +++ b/lib/cadet_web/controllers/answer_controller.ex @@ -71,6 +71,11 @@ defmodule CadetWeb.AnswerController do conn |> put_status(:forbidden) |> text("Assessment not open") + + {:error, _} -> + conn + |> put_status(:forbidden) + |> text("Forbidden") end end From 4fb8b25c891bf61457c3bec9edf2ffd464a36f0a Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Sun, 21 Jan 2024 01:12:02 +0800 Subject: [PATCH 103/128] Run mix format --- lib/cadet.ex | 2 +- lib/cadet/accounts/accounts.ex | 2 +- lib/cadet/accounts/teams.ex | 92 +++++++------- lib/cadet/assessments/assessments.ex | 10 +- lib/cadet/auth/providers/config.ex | 6 +- lib/cadet/courses/courses.ex | 4 +- lib/cadet/helpers/model_helper.ex | 6 +- lib/cadet/helpers/shared_helper.ex | 8 +- lib/cadet/incentives/achievements.ex | 2 +- lib/cadet/jobs/autograder/grading_job.ex | 4 +- lib/cadet/notifications.ex | 102 ++++++++-------- lib/cadet_web.ex | 8 +- lib/cadet_web/admin_views/admin_teams_view.ex | 12 +- .../controllers/answer_controller.ex | 2 +- lib/cadet_web/controllers/auth_controller.ex | 4 +- lib/cadet_web/endpoint.ex | 2 +- lib/cadet_web/gettext.ex | 12 +- lib/cadet_web/helpers/view_helper.ex | 20 +-- lib/cadet_web/views/team_view.ex | 6 +- lib/context_manager.ex | 8 +- lib/mix/tasks/token.ex | 8 +- test/cadet/auth/providers/adfs_test.exs | 10 +- test/cadet/auth/providers/openid_test.exs | 4 +- test/cadet/program_analysis/lexer_test.exs | 114 +++++++++--------- test/support/changeset_case.ex | 2 +- test/support/conn_case.ex | 4 +- test/support/data_case.ex | 8 +- test/support/xml_generator.ex | 2 +- 28 files changed, 232 insertions(+), 232 deletions(-) diff --git a/lib/cadet.ex b/lib/cadet.ex index cc451955a..7f09b5a24 100644 --- a/lib/cadet.ex +++ b/lib/cadet.ex @@ -3,7 +3,7 @@ defmodule Cadet do @moduledoc """ Cadet keeps the contexts that define your domain and business logic. - + Contexts are also responsible for managing your data, regardless if it comes from the database, an external API or others. """ diff --git a/lib/cadet/accounts/accounts.ex b/lib/cadet/accounts/accounts.ex index 92ea72577..0bf8741c3 100644 --- a/lib/cadet/accounts/accounts.ex +++ b/lib/cadet/accounts/accounts.ex @@ -11,7 +11,7 @@ defmodule Cadet.Accounts do @doc """ Register new User entity using Cadet.Accounts.Form.Registration - + Returns {:ok, user} on success, otherwise {:error, changeset} """ def register(attrs = %{username: username, provider: _provider}) when is_binary(username) do diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index 347a36e06..6ef3f2976 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -14,15 +14,15 @@ defmodule Cadet.Accounts.Teams do @doc """ Creates a new team and assigns an assessment and team members to it. - + ## Parameters - + * `attrs` - A map containing the attributes for assessment id and creating the team and its members. - + ## Returns - + Returns a tuple `{:ok, team}` on success; otherwise, an error tuple. - + """ def create_team(attrs) do assessment_id = attrs["assessment_id"] @@ -69,16 +69,16 @@ defmodule Cadet.Accounts.Teams do @doc """ Validates whether there are student(s) who are already assigned to another group. - + ## Parameters - + * `team_attrs` - A list of all the teams and their members. * `assessment_id` - Id of the target assessment. - + ## Returns - + Returns `true` on success; otherwise, `false`. - + """ defp student_already_assigned?(team_attrs, assessment_id) do Enum.all?(team_attrs, fn team -> @@ -93,15 +93,15 @@ defmodule Cadet.Accounts.Teams do @doc """ Checks there is no duplicated student during team creation. - + ## Parameters - + * `team_attrs` - IDs of the team members being created - + ## Returns - + Returns `true` if all students in the list are distinct; otherwise, returns `false`. - + """ defp all_students_distinct?(team_attrs) do all_ids = @@ -118,16 +118,16 @@ defmodule Cadet.Accounts.Teams do @doc """ Checks if all the teams satisfy the max team size constraint. - + ## Parameters - + * `teams` - IDs of the team members being created * `max_team_size` - max team size of the team - + ## Returns - + Returns `true` if all the teams have size less or equal to the max team size; otherwise, returns `false`. - + """ defp all_team_within_max_size?(teams, max_team_size) do Enum.all?(teams, fn team -> @@ -138,16 +138,16 @@ defmodule Cadet.Accounts.Teams do @doc """ Checks if one or more students are enrolled in the course. - + ## Parameters - + * `teams` - ID of the team being created * `course_id` - ID of the course - + ## Returns - + Returns `true` if all students in the list enroll in the course; otherwise, returns `false`. - + """ defp all_student_enrolled_in_course?(teams, course_id) do all_ids = @@ -168,17 +168,17 @@ defmodule Cadet.Accounts.Teams do @doc """ Checks if one or more students are already in another team for the same assessment. - + ## Parameters - + * `team_id` - ID of the team being updated (use -1 for team creation) * `student_ids` - List of student IDs * `assessment_id` - ID of the assessment - + ## Returns - + Returns `true` if any student in the list is already a member of another team for the same assessment; otherwise, returns `false`. - + """ defp student_already_in_team?(team_id, student_ids, assessment_id) do query = @@ -196,17 +196,17 @@ defmodule Cadet.Accounts.Teams do @doc """ Updates an existing team, the corresponding assessment, and its members. - + ## Parameters - + * `team` - The existing team to be updated * `new_assessment_id` - The ID of the updated assessment * `student_ids` - List of student ids for team members - + ## Returns - + Returns a tuple `{:ok, updated_team}` on success, containing the updated team details; otherwise, an error tuple. - + """ def update_team(team = %Team{}, new_assessment_id, student_ids) do old_assessment_id = team.assessment_id @@ -235,13 +235,13 @@ defmodule Cadet.Accounts.Teams do @doc """ Updates team members based on the new list of student IDs. - + ## Parameters - + * `team` - The team being updated * `student_ids` - List of student ids for team members * `team_id` - ID of the team - + """ defp update_team_members(team, student_ids, team_id) do current_student_ids = team.team_members |> Enum.map(& &1.student_id) @@ -269,11 +269,11 @@ defmodule Cadet.Accounts.Teams do @doc """ Deletes a team along with its associated submissions and answers. - + ## Parameters - + * `team` - The team to be deleted - + """ def delete_team(team = %Team{}) do if has_submitted_answer?(team.id) do @@ -306,15 +306,15 @@ defmodule Cadet.Accounts.Teams do @doc """ Check whether a team has subnitted submissions and answers. - + ## Parameters - + * `team_id` - The team id of the team to be checked - + ## Returns - + Returns `true` if any one of the submission has the status of "submitted", `false` otherwise - + """ defp has_submitted_answer?(team_id) do submission = diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 31c556eee..c89fe5ee8 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -799,10 +799,10 @@ defmodule Cadet.Assessments do Public internal api to submit new answers for a question. Possible return values are: `{:ok, nil}` -> success `{:error, error}` -> failed. `error` is in the format of `{http_response_code, error message}` - + Note: In the event of `find_or_create_submission` failing due to a race condition, error will be: `{:bad_request, "Missing or invalid parameter(s)"}` - + """ def answer_question( question = %Question{}, @@ -1198,7 +1198,7 @@ defmodule Cadet.Assessments do @doc """ Fetches top answers for the given question, based on the contest relative_score - + Used for contest leaderboard fetching """ def fetch_top_relative_score_answers(question_id, number_of_answers) do @@ -1363,11 +1363,11 @@ defmodule Cadet.Assessments do fields that are exposed in the /grading endpoint. The reason we select only those fields is to reduce the memory usage especially when the number of submissions is large i.e. > 25000 submissions. - + The input parameters are the user and group_only. group_only is used to check whether only the groups under the grader should be returned. The parameter is a boolean which is false by default. - + The return value is {:ok, submissions} if no errors, else it is {:error, {:unauthorized, "Forbidden."}} """ diff --git a/lib/cadet/auth/providers/config.ex b/lib/cadet/auth/providers/config.ex index 726a71658..eed25adbe 100644 --- a/lib/cadet/auth/providers/config.ex +++ b/lib/cadet/auth/providers/config.ex @@ -1,13 +1,13 @@ defmodule Cadet.Auth.Providers.Config do @moduledoc """ Provides identity using configuration. - + The configuration should be a list of users in the following format: - + ``` [%{code: "code1", token: "token1", username: "Username", name: "Name", role: :student}] ``` - + This is mainly meant for test and development use. """ diff --git a/lib/cadet/courses/courses.ex b/lib/cadet/courses/courses.ex index 5c0464fae..3b583db5a 100644 --- a/lib/cadet/courses/courses.ex +++ b/lib/cadet/courses/courses.ex @@ -332,7 +332,7 @@ defmodule Cadet.Courses do @doc """ Upload a sourcecast file. - + Note that there are no checks for whether the user belongs to the course, as this has been checked inside a plug in the router. """ @@ -384,7 +384,7 @@ defmodule Cadet.Courses do @doc """ Delete a sourcecast file - + Note that there are no checks for whether the user belongs to the course, as this has been checked inside a plug in the router. """ diff --git a/lib/cadet/helpers/model_helper.ex b/lib/cadet/helpers/model_helper.ex index 5dbc72e23..db6dd8a07 100644 --- a/lib/cadet/helpers/model_helper.ex +++ b/lib/cadet/helpers/model_helper.ex @@ -29,7 +29,7 @@ defmodule Cadet.ModelHelper do @doc """ Given a changeset for a model that has some `belongs_to` associations, this function will attach multiple ids to the changeset if the models are provided in the parameters. - + example: ``` defmodule MyTest do @@ -37,10 +37,10 @@ defmodule Cadet.ModelHelper do belongs_to(:bossman, User) belongs_to(:item, Box) end - + def changeset(my_test, params) do # params = %{bossman: %User{}, item: %Box{}} - + my_test |> cast(params, []) |> add_belongs_to_id_from_model([:bossman, :item], params) diff --git a/lib/cadet/helpers/shared_helper.ex b/lib/cadet/helpers/shared_helper.ex index 0c20d7fcf..e234863c4 100644 --- a/lib/cadet/helpers/shared_helper.ex +++ b/lib/cadet/helpers/shared_helper.ex @@ -22,7 +22,7 @@ defmodule Cadet.SharedHelper do @doc """ Snake-casifies string keys. - + Meant for use when accepting a JSON map from the frontend, where keys are usually camel-case. """ @@ -40,7 +40,7 @@ defmodule Cadet.SharedHelper do @doc """ Snake-casifies string keys, recursively. - + Meant for use when accepting a JSON map from the frontend, where keys are usually camel-case. """ @@ -60,7 +60,7 @@ defmodule Cadet.SharedHelper do @doc """ Camel-casifies atom keys and converts them to strings. - + Meant for use when sending an Elixir map, which usually has snake-case keys, to the frontend. """ @@ -73,7 +73,7 @@ defmodule Cadet.SharedHelper do @doc """ Converts a map like `%{"a" => 123}` into a keyword list like [a: 123]. Returns nil if any keys are not existing atoms. - + Meant for use for GET endpoints that filter based on the query string. """ def try_keywordise_string_keys(map) do diff --git a/lib/cadet/incentives/achievements.ex b/lib/cadet/incentives/achievements.ex index cf7b4ae25..4a3d3abcb 100644 --- a/lib/cadet/incentives/achievements.ex +++ b/lib/cadet/incentives/achievements.ex @@ -12,7 +12,7 @@ defmodule Cadet.Incentives.Achievements do @doc """ Returns all achievements. - + This returns Achievement structs with prerequisites and goal association maps pre-loaded. """ @spec get(integer()) :: [Achievement.t()] diff --git a/lib/cadet/jobs/autograder/grading_job.ex b/lib/cadet/jobs/autograder/grading_job.ex index 89afb69ba..0380ca107 100644 --- a/lib/cadet/jobs/autograder/grading_job.ex +++ b/lib/cadet/jobs/autograder/grading_job.ex @@ -44,10 +44,10 @@ defmodule Cadet.Autograder.GradingJob do Exposed as public function in case future mix tasks are needed to regrade certain submissions. Manual grading can also be triggered from iex with this function. - + Takes in submission to be graded. Submission will be graded regardless of its assessment's close_by date or submission status. - + Every answer will be regraded regardless of its current autograding status. """ def force_grade_individual_submission(submission = %Submission{}, overwrite \\ false) do diff --git a/lib/cadet/notifications.ex b/lib/cadet/notifications.ex index cc65d529a..8a6d8d175 100644 --- a/lib/cadet/notifications.ex +++ b/lib/cadet/notifications.ex @@ -16,30 +16,30 @@ defmodule Cadet.Notifications do @doc """ Gets a single notification_type. - + Raises `Ecto.NoResultsError` if the Notification type does not exist. - + ## Examples - + iex> get_notification_type!(123) %NotificationType{} - + iex> get_notification_type!(456) ** (Ecto.NoResultsError) - + """ def get_notification_type!(id), do: Repo.get!(NotificationType, id) @doc """ Gets a single notification_type by name.any() - + Raises `Ecto.NoResultsError` if the Notification type does not exist. - + ## Examples - + iex> get_notification_type_by_name!("AVENGER BACKLOG") %NotificationType{} - + iex> get_notification_type_by_name!("AVENGER BACKLOG") ** (Ecto.NoResultsError) """ @@ -67,15 +67,15 @@ defmodule Cadet.Notifications do @doc """ Updates a notification_config. - + ## Examples - + iex> update_notification_config(notification_config, %{field: new_value}) {:ok, %NotificationConfig{}} - + iex> update_notification_config(notification_config, %{field: bad_value}) {:error, %Ecto.Changeset{}} - + """ def update_notification_config(notification_config = %NotificationConfig{}, attrs) do notification_config @@ -85,12 +85,12 @@ defmodule Cadet.Notifications do @doc """ Returns an `%Ecto.Changeset{}` for tracking notification_config changes. - + ## Examples - + iex> change_notification_config(notification_config) %Ecto.Changeset{data: %NotificationConfig{}} - + """ def change_notification_config(notification_config = %NotificationConfig{}, attrs \\ %{}) do NotificationConfig.changeset(notification_config, attrs) @@ -98,17 +98,17 @@ defmodule Cadet.Notifications do @doc """ Gets a single time_option. - + Raises `Ecto.NoResultsError` if the Time option does not exist. - + ## Examples - + iex> get_time_option!(123) %TimeOption{} - + iex> get_time_option!(456) ** (Ecto.NoResultsError) - + """ def get_time_option!(id), do: Repo.get!(TimeOption, id) @@ -144,15 +144,15 @@ defmodule Cadet.Notifications do @doc """ Creates a time_option. - + ## Examples - + iex> create_time_option(%{field: value}) {:ok, %TimeOption{}} - + iex> create_time_option(%{field: bad_value}) {:error, %Ecto.Changeset{}} - + """ def create_time_option(attrs \\ %{}) do %TimeOption{} @@ -162,15 +162,15 @@ defmodule Cadet.Notifications do @doc """ Deletes a time_option. - + ## Examples - + iex> delete_time_option(time_option) {:ok, %TimeOption{}} - + iex> delete_time_option(time_option) {:error, %Ecto.Changeset{}} - + """ def delete_time_option(time_option = %TimeOption{}) do Repo.delete(time_option) @@ -192,15 +192,15 @@ defmodule Cadet.Notifications do @doc """ Creates a notification_preference. - + ## Examples - + iex> create_notification_preference(%{field: value}) {:ok, %NotificationPreference{}} - + iex> create_notification_preference(%{field: bad_value}) {:error, %Ecto.Changeset{}} - + """ def create_notification_preference(attrs \\ %{}) do %NotificationPreference{} @@ -210,15 +210,15 @@ defmodule Cadet.Notifications do @doc """ Updates a notification_preference. - + ## Examples - + iex> update_notification_preference(notification_preference, %{field: new_value}) {:ok, %NotificationPreference{}} - + iex> update_notification_preference(notification_preference, %{field: bad_value}) {:error, %Ecto.Changeset{}} - + """ def update_notification_preference(notification_preference = %NotificationPreference{}, attrs) do notification_preference @@ -228,15 +228,15 @@ defmodule Cadet.Notifications do @doc """ Deletes a notification_preference. - + ## Examples - + iex> delete_notification_preference(notification_preference) {:ok, %NotificationPreference{}} - + iex> delete_notification_preference(notification_preference) {:error, %Ecto.Changeset{}} - + """ def delete_notification_preference(notification_preference = %NotificationPreference{}) do Repo.delete(notification_preference) @@ -244,12 +244,12 @@ defmodule Cadet.Notifications do @doc """ Returns an `%Ecto.Changeset{}` for tracking notification_preference changes. - + ## Examples - + iex> change_notification_preference(notification_preference) %Ecto.Changeset{data: %NotificationPreference{}} - + """ def change_notification_preference( notification_preference = %NotificationPreference{}, @@ -260,15 +260,15 @@ defmodule Cadet.Notifications do @doc """ Creates a sent_notification. - + ## Examples - + iex> create_sent_notification(%{field: value}) {:ok, %SentNotification{}} - + iex> create_sent_notification(%{field: bad_value}) {:error, %Ecto.Changeset{}} - + """ def create_sent_notification(course_reg_id, content) do %SentNotification{} @@ -278,12 +278,12 @@ defmodule Cadet.Notifications do @doc """ Returns the list of sent_notifications. - + ## Examples - + iex> list_sent_notifications() [%SentNotification{}, ...] - + """ # def list_sent_notifications do diff --git a/lib/cadet_web.ex b/lib/cadet_web.ex index 332fe138e..f4d5ccb53 100644 --- a/lib/cadet_web.ex +++ b/lib/cadet_web.ex @@ -3,16 +3,16 @@ defmodule CadetWeb do @moduledoc """ The entrypoint for defining your web interface, such as controllers, views, channels and so on. - + This can be used in your application as: - + use CadetWeb, :controller use CadetWeb, :view - + The definitions below will be executed for every view, controller, etc, so keep them short and clean, focused on imports, uses and aliases. - + Do NOT define functions inside the quoted expressions below. Instead, define any helper function in modules and import those modules here. diff --git a/lib/cadet_web/admin_views/admin_teams_view.ex b/lib/cadet_web/admin_views/admin_teams_view.ex index 1f6891093..87b82d67c 100644 --- a/lib/cadet_web/admin_views/admin_teams_view.ex +++ b/lib/cadet_web/admin_views/admin_teams_view.ex @@ -7,11 +7,11 @@ defmodule CadetWeb.AdminTeamsView do @doc """ Renders a list of team formation overviews in JSON format. - + ## Parameters - + * `teamFormationOverviews` - A list of team formation overviews to be rendered. - + """ def render("index.json", %{team_formation_overviews: team_formation_overviews}) do render_many(team_formation_overviews, CadetWeb.AdminTeamsView, "team_formation_overview.json", @@ -21,11 +21,11 @@ defmodule CadetWeb.AdminTeamsView do @doc """ Renders a single team formation overview in JSON format. - + ## Parameters - + * `team_formation_overview` - The team formation overview to be rendered. - + """ def render("team_formation_overview.json", %{team_formation_overview: team_formation_overview}) do %{ diff --git a/lib/cadet_web/controllers/answer_controller.ex b/lib/cadet_web/controllers/answer_controller.ex index 5713ec4f0..7e87ab22e 100644 --- a/lib/cadet_web/controllers/answer_controller.ex +++ b/lib/cadet_web/controllers/answer_controller.ex @@ -71,7 +71,7 @@ defmodule CadetWeb.AnswerController do conn |> put_status(:forbidden) |> text("Assessment not open") - + {:error, _} -> conn |> put_status(:forbidden) diff --git a/lib/cadet_web/controllers/auth_controller.ex b/lib/cadet_web/controllers/auth_controller.ex index 99db02c7d..f921cc1fd 100644 --- a/lib/cadet_web/controllers/auth_controller.ex +++ b/lib/cadet_web/controllers/auth_controller.ex @@ -11,7 +11,7 @@ defmodule CadetWeb.AuthController do @doc """ Receives a /login request with valid attributes. - + If the user is already registered in our database, simply return `Tokens`. If the user has not been registered before, register the user, then return the `Tokens`. @@ -60,7 +60,7 @@ defmodule CadetWeb.AuthController do @doc """ Receives a /refresh request with valid attribute. - + Exchanges the refresh_token with a new access_token. """ def refresh(conn, %{"refresh_token" => refresh_token}) do diff --git a/lib/cadet_web/endpoint.ex b/lib/cadet_web/endpoint.ex index 70e62d0f8..1c50f3f34 100644 --- a/lib/cadet_web/endpoint.ex +++ b/lib/cadet_web/endpoint.ex @@ -59,7 +59,7 @@ defmodule CadetWeb.Endpoint do @doc """ Callback invoked for dynamically configuring the endpoint. - + It receives the endpoint configuration and checks if configuration should be loaded from the system environment. """ diff --git a/lib/cadet_web/gettext.ex b/lib/cadet_web/gettext.ex index 797c9d132..a7bfcf59e 100644 --- a/lib/cadet_web/gettext.ex +++ b/lib/cadet_web/gettext.ex @@ -1,23 +1,23 @@ defmodule CadetWeb.Gettext do @moduledoc """ A module providing Internationalization with a gettext-based API. - + By using [Gettext](https://hexdocs.pm/gettext), your module gains a set of macros for translations, for example: - + import CadetWeb.Gettext - + # Simple translation gettext "Here is the string to translate" - + # Plural translation ngettext "Here is the string to translate", "Here are the strings to translate", 3 - + # Domain-based translation dgettext "errors", "Here is the error message to translate" - + See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. """ use Gettext, otp_app: :cadet diff --git a/lib/cadet_web/helpers/view_helper.ex b/lib/cadet_web/helpers/view_helper.ex index 39310ea73..03faf4018 100644 --- a/lib/cadet_web/helpers/view_helper.ex +++ b/lib/cadet_web/helpers/view_helper.ex @@ -41,13 +41,13 @@ defmodule CadetWeb.ViewHelper do @doc """ This function allows you to build a map for a view from a map of transformations or a list of fields. - + Given a `key_list`, it is the equivalent of `Map.take(source, key_list)`. - + Given a map of `%{view_field: source_field, ...}`, it is the equivalent of `%{view_field: Map.get(source, source_field), ...}` - + Given a map of `%{view_field: source_function, ...}`, it is the equivalent of `%{view_field: apply(source_function, source)}` - + Examples: ``` source = %{ @@ -55,31 +55,31 @@ defmodule CadetWeb.ViewHelper do barbar: "ha", foobar: "hoha" } - + field_list = [:foofoo, :barbar] - + transform_map_for_view(source, field_list) > %{ foofoo: "ho", barbar: "ha" } - + key_transformations = %{ foo: :foofoo, bar: :barbar } - + transform_map_for_view(source, key_transformations) > %{ foo: Map.get(source, :foofoo), bar: Map.get(source, :barbar) } - + function_transformations = %{ foo: fn source -> source.foofoo <> "hoho", bar: fn source -> source.barbar <> "barbar" } - + transform_map_for_view(source, function_transformations) > %{ foo: source.foofoo <> "hoho", diff --git a/lib/cadet_web/views/team_view.ex b/lib/cadet_web/views/team_view.ex index c93f684c1..f3d1f3285 100644 --- a/lib/cadet_web/views/team_view.ex +++ b/lib/cadet_web/views/team_view.ex @@ -7,11 +7,11 @@ defmodule CadetWeb.TeamView do @doc """ Renders the JSON representation of team formation overview. - + ## Parameters - + * `teamFormationOverview` - A map containing team formation overview data. - + """ def render("index.json", %{teamFormationOverview: teamFormationOverview}) do %{ diff --git a/lib/context_manager.ex b/lib/context_manager.ex index 6de0abd50..78d751f1e 100644 --- a/lib/context_manager.ex +++ b/lib/context_manager.ex @@ -2,22 +2,22 @@ defmodule ContextManager do @moduledoc """ This module helps you to define context macros by providing boiletplate code. Usage example: - + ``` defmodule MyModule do use ContextManager - + def my_context do import my_import_1 import my_import_2 end - + def my_context_2 do import my_import_3 import my_import_4 end end - + def MyOtherModule do use MyModule, :my_context # or diff --git a/lib/mix/tasks/token.ex b/lib/mix/tasks/token.ex index 808ded384..579f7dfc0 100644 --- a/lib/mix/tasks/token.ex +++ b/lib/mix/tasks/token.ex @@ -1,13 +1,13 @@ defmodule Mix.Tasks.Cadet.Token do @moduledoc """ Helper to generate access_token to ease development. - + Usage: `mix cadet.token ` - + where in #{inspect(Enum.filter(Cadet.Accounts.Role.__valid_values__(), &is_binary/1))} - + For example: `mix cadet.token student` - + Caveat emptor!!! The list of roles here is generated at compile-time. To get the most up-to-date list, please recompile by running `mix` """ diff --git a/test/cadet/auth/providers/adfs_test.exs b/test/cadet/auth/providers/adfs_test.exs index 8511c05b4..309617194 100644 --- a/test/cadet/auth/providers/adfs_test.exs +++ b/test/cadet/auth/providers/adfs_test.exs @@ -2,21 +2,21 @@ defmodule Cadet.Auth.Providers.ADFSTest do @moduledoc """ This test module uses pre-recorded HTTP responses saved by ExVCR. This allows testing without actual external ADFS API calls. - + If you need to re-record these responses, set the ADFS API key in config/test.exs, retrieve a ADFS authorisation token, delete the pre-recorded responses, and then run - + TOKEN=auth_code_goes_here mix test - + You can retrieve the authorisation token by manually hitting the ADFS endpoints, or just by logging in to ADFS in your browser and extracting the token from the Authorization header in API requests. - + If you need to re-record the authorise responses, you will have to hit ADFS manually to get an authorisation code, and set the appropriate environment variables (see the module attributes defined below). - + Note that all the cassettes are marked as custom as they have been manually edited to suit the particular test case. """ diff --git a/test/cadet/auth/providers/openid_test.exs b/test/cadet/auth/providers/openid_test.exs index fef336ddd..f674a3966 100644 --- a/test/cadet/auth/providers/openid_test.exs +++ b/test/cadet/auth/providers/openid_test.exs @@ -2,9 +2,9 @@ defmodule Cadet.Auth.Providers.OpenIDTest do @moduledoc """ Tests the OpenID authentication provider by simulating an OpenID authentication server. - + The RSA keypair used to generate the token below is as follows (as a JWK): - + ``` { "p": "3jfRwYW0kdmSyxjalJY03koNmaoeTqDE1_UQoT3T-BvzipuZoTns44WfTZGvKpsRH8GjTxgiP4JDDl27JYfGvrFz9e-HTmJJfalycraYddmRYCRJbwfyLHj5agul0wktIpG3C20VTGo3oXWvCpo2EaCfK-8neYsm_VLyH9Am4aE", diff --git a/test/cadet/program_analysis/lexer_test.exs b/test/cadet/program_analysis/lexer_test.exs index 545c3ad3f..dc5ac6e65 100644 --- a/test/cadet/program_analysis/lexer_test.exs +++ b/test/cadet/program_analysis/lexer_test.exs @@ -36,20 +36,20 @@ defmodule Cadet.ProgramAnalysis.LexerTest do /* Virtual machine implementation of language Source §0 following the virtual machine of Lecture Week 2 of CS4215 - + Instructions: Copy this file into the Source Academy frontend: https://source-academy.github.io/playground You can use the google drive feature to save your work. When done, copy the file back to the repository and push your changes. - + To run your program, press "Run" and observe the result on the right. - + The language Source §0 is defined as follows: - + prgm ::= expr ; - + expr ::= number | true | false | expr binop expr @@ -58,140 +58,140 @@ defmodule Cadet.ProgramAnalysis.LexerTest do | === | && | || unop ::= ! */ - + // Functions from SICP JS Section 4.1.2 // with slight modifications - - + + function is_tagged_list(expr, the_tag) { return is_pair(expr) && head(expr) === the_tag; } - + function make_literal(value) { return list("literal", value); } - + function is_literal(expr) { return is_tagged_list(expr, "literal"); } - + function literal_value(expr) { return head(tail(expr)); } - + function is_operator_combination(expr) { return is_unary_operator_combination(expr) || is_binary_operator_combination(expr); } - + function is_unary_operator_combination(expr) { return is_tagged_list(expr, "unary_operator_combination"); } - + // logical composition (&&, ||) is treated as binary operator combination function is_binary_operator_combination(expr) { return is_tagged_list(expr, "binary_operator_combination") || is_tagged_list(expr, "logical_composition"); } - + function operator(expr) { return head(tail(expr)); } - + function first_operand(expr) { return head(tail(tail(expr))); } - + function second_operand(expr) { return head(tail(tail(tail(expr)))); } - + // two new functions, not in 4.1.2 - + function is_boolean_literal(expr) { return is_tagged_list(expr, "literal") && is_boolean(literal_value(expr)); } - + function is_number_literal(expr) { return is_tagged_list(expr, "literal") && is_number(literal_value(expr)); } - + // functions to represent virtual machine code - + function op_code(instr) { return head(instr); } - + function arg(instr) { return head(tail(instr)); } - + function make_simple_instruction(op_code) { return list(op_code); } - + function DONE() { return list("DONE"); } - + function LDCI(i) { return list("LDCI", i); } - + function LDCB(b) { return list("LDCB", b); } - + function PLUS() { return list("PLUS"); } - + function MINUS() { return list("MINUS"); } - + function TIMES() { return list("TIMES"); } - + function DIV() { return list("DIV"); } - + function AND() { return list("AND"); } - + function OR() { return list("OR"); } - + function NOT() { return list("NOT"); } - + function LT() { return list("LT"); } - + function GT() { return list("GT"); } - + function EQ() { return list("EQ"); } - + // compile_program: see relation ->> in Section 3.5.2 - + function compile_program(program) { return append(compile_expression(program), list(DONE())); } - + // compile_expression: see relation hookarrow in 3.5.2 - + function compile_expression(expr) { if (is_number_literal(expr)) { return list(LDCI(literal_value(expr))); @@ -220,56 +220,56 @@ defmodule Cadet.ProgramAnalysis.LexerTest do } } } - + function parse_and_compile(string) { return compile_program(parse(string)); } - + // parse_and_compile("! (1 === 1 && 2 > 3);"); // parse_and_compile("1 + 2 / 0;"); // parse_and_compile("1 + 2 / 1;"); // parse_and_compile("3 / 4;"); - + // machine state: a pair consisting // of an operand stack and a program counter, // following 3.5.3 - + function make_state(stack, pc) { return pair(stack, pc); } - + function get_stack(state) { return head(state); } - + function get_pc(state) { return tail(state); } - + // operations on the operand stack - + function empty_stack() { return null; } function push(stack, value) { return pair(value, stack); } - + function pop(stack) { return tail(stack); } - + function top(stack) { return head(stack); } - + // run the machine according to 3.5.3 - + function run(code) { const initial_state = make_state(empty_stack(), 0); return transition(code, initial_state); } - + function transition(code, state) { const pc = get_pc(state); const stack = get_stack(state); @@ -281,7 +281,7 @@ defmodule Cadet.ProgramAnalysis.LexerTest do pc + 1)); } } - + function next_stack(stack, instr) { const op = op_code(instr); return op === "LDCI" ? push(stack, arg(instr)) @@ -298,12 +298,12 @@ defmodule Cadet.ProgramAnalysis.LexerTest do : op === "AND" ? push(pop(pop(stack)), top(pop(stack)) && top(stack)) : /*op === "OR" ?*/ push(pop(pop(stack)), top(pop(stack)) || top(stack)); } - + function parse_compile_and_run(string) { const code = compile_program(parse(string)); return run(code); } - + // parse_compile_and_run("! (1 === 1 && 2 > 3);"); // parse_compile_and_run("1 + 2 / 0;"); // parse_compile_and_run("1 + 2 / 1;"); diff --git a/test/support/changeset_case.ex b/test/support/changeset_case.ex index bcffcbf17..d20a4de89 100644 --- a/test/support/changeset_case.ex +++ b/test/support/changeset_case.ex @@ -2,7 +2,7 @@ defmodule Cadet.ChangesetCase do @moduledoc """ This module defines helper method(s) that is useful to test changeset/2 of an Ecto schema. - + This module provides `assert_changeset`, `assert_changeset_db`, `generate_changeset` ``` """ diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 5d734fc58..425e62cb3 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -2,11 +2,11 @@ defmodule CadetWeb.ConnCase do @moduledoc """ This module defines the test case to be used by tests that require setting up a connection. - + Such tests rely on `Phoenix.ConnTest` and also import other functionality to make it easier to build common datastructures and query the data layer. - + Finally, if the test case interacts with the database, it cannot be async. For this reason, every test runs inside a transaction which is reset at the beginning diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 5b0a5c76f..d1d9380a5 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -2,10 +2,10 @@ defmodule Cadet.DataCase do @moduledoc """ This module defines the setup for tests requiring access to the application's data layer. - + You may define functions here to be used as helpers in your tests. - + Finally, if the test case interacts with the database, it cannot be async. For this reason, every test runs inside a transaction which is reset at the beginning @@ -36,11 +36,11 @@ defmodule Cadet.DataCase do @doc """ A helper that transform changeset errors to a map of messages. - + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) assert "password is too short" in errors_on(changeset).password assert %{password: ["password is too short"]} = errors_on(changeset) - + """ def errors_on(changeset) do Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> diff --git a/test/support/xml_generator.ex b/test/support/xml_generator.ex index 8782d32a4..7062824fb 100644 --- a/test/support/xml_generator.ex +++ b/test/support/xml_generator.ex @@ -2,7 +2,7 @@ defmodule Cadet.Test.XMLGenerator do @moduledoc """ This module contains functions to produce sample XML codes in accordance to the specification (xml_api.rst). - + # TODO: Refactor using macros """ From c6312c444ec0065c62524834509eedd938458276 Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Sun, 21 Jan 2024 01:16:17 +0800 Subject: [PATCH 104/128] Fix format --- lib/cadet.ex | 2 +- lib/cadet/accounts/accounts.ex | 2 +- lib/cadet/accounts/teams.ex | 92 +++++++------- lib/cadet/assessments/assessments.ex | 10 +- lib/cadet/auth/providers/config.ex | 6 +- lib/cadet/courses/courses.ex | 4 +- lib/cadet/helpers/model_helper.ex | 6 +- lib/cadet/helpers/shared_helper.ex | 8 +- lib/cadet/incentives/achievements.ex | 2 +- lib/cadet/jobs/autograder/grading_job.ex | 4 +- lib/cadet/notifications.ex | 102 ++++++++-------- lib/cadet_web.ex | 8 +- lib/cadet_web/admin_views/admin_teams_view.ex | 12 +- lib/cadet_web/controllers/auth_controller.ex | 4 +- lib/cadet_web/endpoint.ex | 2 +- lib/cadet_web/gettext.ex | 12 +- lib/cadet_web/helpers/view_helper.ex | 20 +-- lib/cadet_web/views/team_view.ex | 6 +- lib/context_manager.ex | 8 +- lib/mix/tasks/token.ex | 8 +- test/cadet/auth/providers/adfs_test.exs | 10 +- test/cadet/auth/providers/openid_test.exs | 4 +- test/cadet/program_analysis/lexer_test.exs | 114 +++++++++--------- test/support/changeset_case.ex | 2 +- test/support/conn_case.ex | 4 +- test/support/data_case.ex | 8 +- test/support/xml_generator.ex | 2 +- 27 files changed, 231 insertions(+), 231 deletions(-) diff --git a/lib/cadet.ex b/lib/cadet.ex index 7f09b5a24..cc451955a 100644 --- a/lib/cadet.ex +++ b/lib/cadet.ex @@ -3,7 +3,7 @@ defmodule Cadet do @moduledoc """ Cadet keeps the contexts that define your domain and business logic. - + Contexts are also responsible for managing your data, regardless if it comes from the database, an external API or others. """ diff --git a/lib/cadet/accounts/accounts.ex b/lib/cadet/accounts/accounts.ex index 0bf8741c3..92ea72577 100644 --- a/lib/cadet/accounts/accounts.ex +++ b/lib/cadet/accounts/accounts.ex @@ -11,7 +11,7 @@ defmodule Cadet.Accounts do @doc """ Register new User entity using Cadet.Accounts.Form.Registration - + Returns {:ok, user} on success, otherwise {:error, changeset} """ def register(attrs = %{username: username, provider: _provider}) when is_binary(username) do diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index 6ef3f2976..347a36e06 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -14,15 +14,15 @@ defmodule Cadet.Accounts.Teams do @doc """ Creates a new team and assigns an assessment and team members to it. - + ## Parameters - + * `attrs` - A map containing the attributes for assessment id and creating the team and its members. - + ## Returns - + Returns a tuple `{:ok, team}` on success; otherwise, an error tuple. - + """ def create_team(attrs) do assessment_id = attrs["assessment_id"] @@ -69,16 +69,16 @@ defmodule Cadet.Accounts.Teams do @doc """ Validates whether there are student(s) who are already assigned to another group. - + ## Parameters - + * `team_attrs` - A list of all the teams and their members. * `assessment_id` - Id of the target assessment. - + ## Returns - + Returns `true` on success; otherwise, `false`. - + """ defp student_already_assigned?(team_attrs, assessment_id) do Enum.all?(team_attrs, fn team -> @@ -93,15 +93,15 @@ defmodule Cadet.Accounts.Teams do @doc """ Checks there is no duplicated student during team creation. - + ## Parameters - + * `team_attrs` - IDs of the team members being created - + ## Returns - + Returns `true` if all students in the list are distinct; otherwise, returns `false`. - + """ defp all_students_distinct?(team_attrs) do all_ids = @@ -118,16 +118,16 @@ defmodule Cadet.Accounts.Teams do @doc """ Checks if all the teams satisfy the max team size constraint. - + ## Parameters - + * `teams` - IDs of the team members being created * `max_team_size` - max team size of the team - + ## Returns - + Returns `true` if all the teams have size less or equal to the max team size; otherwise, returns `false`. - + """ defp all_team_within_max_size?(teams, max_team_size) do Enum.all?(teams, fn team -> @@ -138,16 +138,16 @@ defmodule Cadet.Accounts.Teams do @doc """ Checks if one or more students are enrolled in the course. - + ## Parameters - + * `teams` - ID of the team being created * `course_id` - ID of the course - + ## Returns - + Returns `true` if all students in the list enroll in the course; otherwise, returns `false`. - + """ defp all_student_enrolled_in_course?(teams, course_id) do all_ids = @@ -168,17 +168,17 @@ defmodule Cadet.Accounts.Teams do @doc """ Checks if one or more students are already in another team for the same assessment. - + ## Parameters - + * `team_id` - ID of the team being updated (use -1 for team creation) * `student_ids` - List of student IDs * `assessment_id` - ID of the assessment - + ## Returns - + Returns `true` if any student in the list is already a member of another team for the same assessment; otherwise, returns `false`. - + """ defp student_already_in_team?(team_id, student_ids, assessment_id) do query = @@ -196,17 +196,17 @@ defmodule Cadet.Accounts.Teams do @doc """ Updates an existing team, the corresponding assessment, and its members. - + ## Parameters - + * `team` - The existing team to be updated * `new_assessment_id` - The ID of the updated assessment * `student_ids` - List of student ids for team members - + ## Returns - + Returns a tuple `{:ok, updated_team}` on success, containing the updated team details; otherwise, an error tuple. - + """ def update_team(team = %Team{}, new_assessment_id, student_ids) do old_assessment_id = team.assessment_id @@ -235,13 +235,13 @@ defmodule Cadet.Accounts.Teams do @doc """ Updates team members based on the new list of student IDs. - + ## Parameters - + * `team` - The team being updated * `student_ids` - List of student ids for team members * `team_id` - ID of the team - + """ defp update_team_members(team, student_ids, team_id) do current_student_ids = team.team_members |> Enum.map(& &1.student_id) @@ -269,11 +269,11 @@ defmodule Cadet.Accounts.Teams do @doc """ Deletes a team along with its associated submissions and answers. - + ## Parameters - + * `team` - The team to be deleted - + """ def delete_team(team = %Team{}) do if has_submitted_answer?(team.id) do @@ -306,15 +306,15 @@ defmodule Cadet.Accounts.Teams do @doc """ Check whether a team has subnitted submissions and answers. - + ## Parameters - + * `team_id` - The team id of the team to be checked - + ## Returns - + Returns `true` if any one of the submission has the status of "submitted", `false` otherwise - + """ defp has_submitted_answer?(team_id) do submission = diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index c89fe5ee8..31c556eee 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -799,10 +799,10 @@ defmodule Cadet.Assessments do Public internal api to submit new answers for a question. Possible return values are: `{:ok, nil}` -> success `{:error, error}` -> failed. `error` is in the format of `{http_response_code, error message}` - + Note: In the event of `find_or_create_submission` failing due to a race condition, error will be: `{:bad_request, "Missing or invalid parameter(s)"}` - + """ def answer_question( question = %Question{}, @@ -1198,7 +1198,7 @@ defmodule Cadet.Assessments do @doc """ Fetches top answers for the given question, based on the contest relative_score - + Used for contest leaderboard fetching """ def fetch_top_relative_score_answers(question_id, number_of_answers) do @@ -1363,11 +1363,11 @@ defmodule Cadet.Assessments do fields that are exposed in the /grading endpoint. The reason we select only those fields is to reduce the memory usage especially when the number of submissions is large i.e. > 25000 submissions. - + The input parameters are the user and group_only. group_only is used to check whether only the groups under the grader should be returned. The parameter is a boolean which is false by default. - + The return value is {:ok, submissions} if no errors, else it is {:error, {:unauthorized, "Forbidden."}} """ diff --git a/lib/cadet/auth/providers/config.ex b/lib/cadet/auth/providers/config.ex index eed25adbe..726a71658 100644 --- a/lib/cadet/auth/providers/config.ex +++ b/lib/cadet/auth/providers/config.ex @@ -1,13 +1,13 @@ defmodule Cadet.Auth.Providers.Config do @moduledoc """ Provides identity using configuration. - + The configuration should be a list of users in the following format: - + ``` [%{code: "code1", token: "token1", username: "Username", name: "Name", role: :student}] ``` - + This is mainly meant for test and development use. """ diff --git a/lib/cadet/courses/courses.ex b/lib/cadet/courses/courses.ex index 3b583db5a..5c0464fae 100644 --- a/lib/cadet/courses/courses.ex +++ b/lib/cadet/courses/courses.ex @@ -332,7 +332,7 @@ defmodule Cadet.Courses do @doc """ Upload a sourcecast file. - + Note that there are no checks for whether the user belongs to the course, as this has been checked inside a plug in the router. """ @@ -384,7 +384,7 @@ defmodule Cadet.Courses do @doc """ Delete a sourcecast file - + Note that there are no checks for whether the user belongs to the course, as this has been checked inside a plug in the router. """ diff --git a/lib/cadet/helpers/model_helper.ex b/lib/cadet/helpers/model_helper.ex index db6dd8a07..5dbc72e23 100644 --- a/lib/cadet/helpers/model_helper.ex +++ b/lib/cadet/helpers/model_helper.ex @@ -29,7 +29,7 @@ defmodule Cadet.ModelHelper do @doc """ Given a changeset for a model that has some `belongs_to` associations, this function will attach multiple ids to the changeset if the models are provided in the parameters. - + example: ``` defmodule MyTest do @@ -37,10 +37,10 @@ defmodule Cadet.ModelHelper do belongs_to(:bossman, User) belongs_to(:item, Box) end - + def changeset(my_test, params) do # params = %{bossman: %User{}, item: %Box{}} - + my_test |> cast(params, []) |> add_belongs_to_id_from_model([:bossman, :item], params) diff --git a/lib/cadet/helpers/shared_helper.ex b/lib/cadet/helpers/shared_helper.ex index e234863c4..0c20d7fcf 100644 --- a/lib/cadet/helpers/shared_helper.ex +++ b/lib/cadet/helpers/shared_helper.ex @@ -22,7 +22,7 @@ defmodule Cadet.SharedHelper do @doc """ Snake-casifies string keys. - + Meant for use when accepting a JSON map from the frontend, where keys are usually camel-case. """ @@ -40,7 +40,7 @@ defmodule Cadet.SharedHelper do @doc """ Snake-casifies string keys, recursively. - + Meant for use when accepting a JSON map from the frontend, where keys are usually camel-case. """ @@ -60,7 +60,7 @@ defmodule Cadet.SharedHelper do @doc """ Camel-casifies atom keys and converts them to strings. - + Meant for use when sending an Elixir map, which usually has snake-case keys, to the frontend. """ @@ -73,7 +73,7 @@ defmodule Cadet.SharedHelper do @doc """ Converts a map like `%{"a" => 123}` into a keyword list like [a: 123]. Returns nil if any keys are not existing atoms. - + Meant for use for GET endpoints that filter based on the query string. """ def try_keywordise_string_keys(map) do diff --git a/lib/cadet/incentives/achievements.ex b/lib/cadet/incentives/achievements.ex index 4a3d3abcb..cf7b4ae25 100644 --- a/lib/cadet/incentives/achievements.ex +++ b/lib/cadet/incentives/achievements.ex @@ -12,7 +12,7 @@ defmodule Cadet.Incentives.Achievements do @doc """ Returns all achievements. - + This returns Achievement structs with prerequisites and goal association maps pre-loaded. """ @spec get(integer()) :: [Achievement.t()] diff --git a/lib/cadet/jobs/autograder/grading_job.ex b/lib/cadet/jobs/autograder/grading_job.ex index 0380ca107..89afb69ba 100644 --- a/lib/cadet/jobs/autograder/grading_job.ex +++ b/lib/cadet/jobs/autograder/grading_job.ex @@ -44,10 +44,10 @@ defmodule Cadet.Autograder.GradingJob do Exposed as public function in case future mix tasks are needed to regrade certain submissions. Manual grading can also be triggered from iex with this function. - + Takes in submission to be graded. Submission will be graded regardless of its assessment's close_by date or submission status. - + Every answer will be regraded regardless of its current autograding status. """ def force_grade_individual_submission(submission = %Submission{}, overwrite \\ false) do diff --git a/lib/cadet/notifications.ex b/lib/cadet/notifications.ex index 8a6d8d175..cc65d529a 100644 --- a/lib/cadet/notifications.ex +++ b/lib/cadet/notifications.ex @@ -16,30 +16,30 @@ defmodule Cadet.Notifications do @doc """ Gets a single notification_type. - + Raises `Ecto.NoResultsError` if the Notification type does not exist. - + ## Examples - + iex> get_notification_type!(123) %NotificationType{} - + iex> get_notification_type!(456) ** (Ecto.NoResultsError) - + """ def get_notification_type!(id), do: Repo.get!(NotificationType, id) @doc """ Gets a single notification_type by name.any() - + Raises `Ecto.NoResultsError` if the Notification type does not exist. - + ## Examples - + iex> get_notification_type_by_name!("AVENGER BACKLOG") %NotificationType{} - + iex> get_notification_type_by_name!("AVENGER BACKLOG") ** (Ecto.NoResultsError) """ @@ -67,15 +67,15 @@ defmodule Cadet.Notifications do @doc """ Updates a notification_config. - + ## Examples - + iex> update_notification_config(notification_config, %{field: new_value}) {:ok, %NotificationConfig{}} - + iex> update_notification_config(notification_config, %{field: bad_value}) {:error, %Ecto.Changeset{}} - + """ def update_notification_config(notification_config = %NotificationConfig{}, attrs) do notification_config @@ -85,12 +85,12 @@ defmodule Cadet.Notifications do @doc """ Returns an `%Ecto.Changeset{}` for tracking notification_config changes. - + ## Examples - + iex> change_notification_config(notification_config) %Ecto.Changeset{data: %NotificationConfig{}} - + """ def change_notification_config(notification_config = %NotificationConfig{}, attrs \\ %{}) do NotificationConfig.changeset(notification_config, attrs) @@ -98,17 +98,17 @@ defmodule Cadet.Notifications do @doc """ Gets a single time_option. - + Raises `Ecto.NoResultsError` if the Time option does not exist. - + ## Examples - + iex> get_time_option!(123) %TimeOption{} - + iex> get_time_option!(456) ** (Ecto.NoResultsError) - + """ def get_time_option!(id), do: Repo.get!(TimeOption, id) @@ -144,15 +144,15 @@ defmodule Cadet.Notifications do @doc """ Creates a time_option. - + ## Examples - + iex> create_time_option(%{field: value}) {:ok, %TimeOption{}} - + iex> create_time_option(%{field: bad_value}) {:error, %Ecto.Changeset{}} - + """ def create_time_option(attrs \\ %{}) do %TimeOption{} @@ -162,15 +162,15 @@ defmodule Cadet.Notifications do @doc """ Deletes a time_option. - + ## Examples - + iex> delete_time_option(time_option) {:ok, %TimeOption{}} - + iex> delete_time_option(time_option) {:error, %Ecto.Changeset{}} - + """ def delete_time_option(time_option = %TimeOption{}) do Repo.delete(time_option) @@ -192,15 +192,15 @@ defmodule Cadet.Notifications do @doc """ Creates a notification_preference. - + ## Examples - + iex> create_notification_preference(%{field: value}) {:ok, %NotificationPreference{}} - + iex> create_notification_preference(%{field: bad_value}) {:error, %Ecto.Changeset{}} - + """ def create_notification_preference(attrs \\ %{}) do %NotificationPreference{} @@ -210,15 +210,15 @@ defmodule Cadet.Notifications do @doc """ Updates a notification_preference. - + ## Examples - + iex> update_notification_preference(notification_preference, %{field: new_value}) {:ok, %NotificationPreference{}} - + iex> update_notification_preference(notification_preference, %{field: bad_value}) {:error, %Ecto.Changeset{}} - + """ def update_notification_preference(notification_preference = %NotificationPreference{}, attrs) do notification_preference @@ -228,15 +228,15 @@ defmodule Cadet.Notifications do @doc """ Deletes a notification_preference. - + ## Examples - + iex> delete_notification_preference(notification_preference) {:ok, %NotificationPreference{}} - + iex> delete_notification_preference(notification_preference) {:error, %Ecto.Changeset{}} - + """ def delete_notification_preference(notification_preference = %NotificationPreference{}) do Repo.delete(notification_preference) @@ -244,12 +244,12 @@ defmodule Cadet.Notifications do @doc """ Returns an `%Ecto.Changeset{}` for tracking notification_preference changes. - + ## Examples - + iex> change_notification_preference(notification_preference) %Ecto.Changeset{data: %NotificationPreference{}} - + """ def change_notification_preference( notification_preference = %NotificationPreference{}, @@ -260,15 +260,15 @@ defmodule Cadet.Notifications do @doc """ Creates a sent_notification. - + ## Examples - + iex> create_sent_notification(%{field: value}) {:ok, %SentNotification{}} - + iex> create_sent_notification(%{field: bad_value}) {:error, %Ecto.Changeset{}} - + """ def create_sent_notification(course_reg_id, content) do %SentNotification{} @@ -278,12 +278,12 @@ defmodule Cadet.Notifications do @doc """ Returns the list of sent_notifications. - + ## Examples - + iex> list_sent_notifications() [%SentNotification{}, ...] - + """ # def list_sent_notifications do diff --git a/lib/cadet_web.ex b/lib/cadet_web.ex index f4d5ccb53..332fe138e 100644 --- a/lib/cadet_web.ex +++ b/lib/cadet_web.ex @@ -3,16 +3,16 @@ defmodule CadetWeb do @moduledoc """ The entrypoint for defining your web interface, such as controllers, views, channels and so on. - + This can be used in your application as: - + use CadetWeb, :controller use CadetWeb, :view - + The definitions below will be executed for every view, controller, etc, so keep them short and clean, focused on imports, uses and aliases. - + Do NOT define functions inside the quoted expressions below. Instead, define any helper function in modules and import those modules here. diff --git a/lib/cadet_web/admin_views/admin_teams_view.ex b/lib/cadet_web/admin_views/admin_teams_view.ex index 87b82d67c..1f6891093 100644 --- a/lib/cadet_web/admin_views/admin_teams_view.ex +++ b/lib/cadet_web/admin_views/admin_teams_view.ex @@ -7,11 +7,11 @@ defmodule CadetWeb.AdminTeamsView do @doc """ Renders a list of team formation overviews in JSON format. - + ## Parameters - + * `teamFormationOverviews` - A list of team formation overviews to be rendered. - + """ def render("index.json", %{team_formation_overviews: team_formation_overviews}) do render_many(team_formation_overviews, CadetWeb.AdminTeamsView, "team_formation_overview.json", @@ -21,11 +21,11 @@ defmodule CadetWeb.AdminTeamsView do @doc """ Renders a single team formation overview in JSON format. - + ## Parameters - + * `team_formation_overview` - The team formation overview to be rendered. - + """ def render("team_formation_overview.json", %{team_formation_overview: team_formation_overview}) do %{ diff --git a/lib/cadet_web/controllers/auth_controller.ex b/lib/cadet_web/controllers/auth_controller.ex index f921cc1fd..99db02c7d 100644 --- a/lib/cadet_web/controllers/auth_controller.ex +++ b/lib/cadet_web/controllers/auth_controller.ex @@ -11,7 +11,7 @@ defmodule CadetWeb.AuthController do @doc """ Receives a /login request with valid attributes. - + If the user is already registered in our database, simply return `Tokens`. If the user has not been registered before, register the user, then return the `Tokens`. @@ -60,7 +60,7 @@ defmodule CadetWeb.AuthController do @doc """ Receives a /refresh request with valid attribute. - + Exchanges the refresh_token with a new access_token. """ def refresh(conn, %{"refresh_token" => refresh_token}) do diff --git a/lib/cadet_web/endpoint.ex b/lib/cadet_web/endpoint.ex index 1c50f3f34..70e62d0f8 100644 --- a/lib/cadet_web/endpoint.ex +++ b/lib/cadet_web/endpoint.ex @@ -59,7 +59,7 @@ defmodule CadetWeb.Endpoint do @doc """ Callback invoked for dynamically configuring the endpoint. - + It receives the endpoint configuration and checks if configuration should be loaded from the system environment. """ diff --git a/lib/cadet_web/gettext.ex b/lib/cadet_web/gettext.ex index a7bfcf59e..797c9d132 100644 --- a/lib/cadet_web/gettext.ex +++ b/lib/cadet_web/gettext.ex @@ -1,23 +1,23 @@ defmodule CadetWeb.Gettext do @moduledoc """ A module providing Internationalization with a gettext-based API. - + By using [Gettext](https://hexdocs.pm/gettext), your module gains a set of macros for translations, for example: - + import CadetWeb.Gettext - + # Simple translation gettext "Here is the string to translate" - + # Plural translation ngettext "Here is the string to translate", "Here are the strings to translate", 3 - + # Domain-based translation dgettext "errors", "Here is the error message to translate" - + See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. """ use Gettext, otp_app: :cadet diff --git a/lib/cadet_web/helpers/view_helper.ex b/lib/cadet_web/helpers/view_helper.ex index 03faf4018..39310ea73 100644 --- a/lib/cadet_web/helpers/view_helper.ex +++ b/lib/cadet_web/helpers/view_helper.ex @@ -41,13 +41,13 @@ defmodule CadetWeb.ViewHelper do @doc """ This function allows you to build a map for a view from a map of transformations or a list of fields. - + Given a `key_list`, it is the equivalent of `Map.take(source, key_list)`. - + Given a map of `%{view_field: source_field, ...}`, it is the equivalent of `%{view_field: Map.get(source, source_field), ...}` - + Given a map of `%{view_field: source_function, ...}`, it is the equivalent of `%{view_field: apply(source_function, source)}` - + Examples: ``` source = %{ @@ -55,31 +55,31 @@ defmodule CadetWeb.ViewHelper do barbar: "ha", foobar: "hoha" } - + field_list = [:foofoo, :barbar] - + transform_map_for_view(source, field_list) > %{ foofoo: "ho", barbar: "ha" } - + key_transformations = %{ foo: :foofoo, bar: :barbar } - + transform_map_for_view(source, key_transformations) > %{ foo: Map.get(source, :foofoo), bar: Map.get(source, :barbar) } - + function_transformations = %{ foo: fn source -> source.foofoo <> "hoho", bar: fn source -> source.barbar <> "barbar" } - + transform_map_for_view(source, function_transformations) > %{ foo: source.foofoo <> "hoho", diff --git a/lib/cadet_web/views/team_view.ex b/lib/cadet_web/views/team_view.ex index f3d1f3285..c93f684c1 100644 --- a/lib/cadet_web/views/team_view.ex +++ b/lib/cadet_web/views/team_view.ex @@ -7,11 +7,11 @@ defmodule CadetWeb.TeamView do @doc """ Renders the JSON representation of team formation overview. - + ## Parameters - + * `teamFormationOverview` - A map containing team formation overview data. - + """ def render("index.json", %{teamFormationOverview: teamFormationOverview}) do %{ diff --git a/lib/context_manager.ex b/lib/context_manager.ex index 78d751f1e..6de0abd50 100644 --- a/lib/context_manager.ex +++ b/lib/context_manager.ex @@ -2,22 +2,22 @@ defmodule ContextManager do @moduledoc """ This module helps you to define context macros by providing boiletplate code. Usage example: - + ``` defmodule MyModule do use ContextManager - + def my_context do import my_import_1 import my_import_2 end - + def my_context_2 do import my_import_3 import my_import_4 end end - + def MyOtherModule do use MyModule, :my_context # or diff --git a/lib/mix/tasks/token.ex b/lib/mix/tasks/token.ex index 579f7dfc0..808ded384 100644 --- a/lib/mix/tasks/token.ex +++ b/lib/mix/tasks/token.ex @@ -1,13 +1,13 @@ defmodule Mix.Tasks.Cadet.Token do @moduledoc """ Helper to generate access_token to ease development. - + Usage: `mix cadet.token ` - + where in #{inspect(Enum.filter(Cadet.Accounts.Role.__valid_values__(), &is_binary/1))} - + For example: `mix cadet.token student` - + Caveat emptor!!! The list of roles here is generated at compile-time. To get the most up-to-date list, please recompile by running `mix` """ diff --git a/test/cadet/auth/providers/adfs_test.exs b/test/cadet/auth/providers/adfs_test.exs index 309617194..8511c05b4 100644 --- a/test/cadet/auth/providers/adfs_test.exs +++ b/test/cadet/auth/providers/adfs_test.exs @@ -2,21 +2,21 @@ defmodule Cadet.Auth.Providers.ADFSTest do @moduledoc """ This test module uses pre-recorded HTTP responses saved by ExVCR. This allows testing without actual external ADFS API calls. - + If you need to re-record these responses, set the ADFS API key in config/test.exs, retrieve a ADFS authorisation token, delete the pre-recorded responses, and then run - + TOKEN=auth_code_goes_here mix test - + You can retrieve the authorisation token by manually hitting the ADFS endpoints, or just by logging in to ADFS in your browser and extracting the token from the Authorization header in API requests. - + If you need to re-record the authorise responses, you will have to hit ADFS manually to get an authorisation code, and set the appropriate environment variables (see the module attributes defined below). - + Note that all the cassettes are marked as custom as they have been manually edited to suit the particular test case. """ diff --git a/test/cadet/auth/providers/openid_test.exs b/test/cadet/auth/providers/openid_test.exs index f674a3966..fef336ddd 100644 --- a/test/cadet/auth/providers/openid_test.exs +++ b/test/cadet/auth/providers/openid_test.exs @@ -2,9 +2,9 @@ defmodule Cadet.Auth.Providers.OpenIDTest do @moduledoc """ Tests the OpenID authentication provider by simulating an OpenID authentication server. - + The RSA keypair used to generate the token below is as follows (as a JWK): - + ``` { "p": "3jfRwYW0kdmSyxjalJY03koNmaoeTqDE1_UQoT3T-BvzipuZoTns44WfTZGvKpsRH8GjTxgiP4JDDl27JYfGvrFz9e-HTmJJfalycraYddmRYCRJbwfyLHj5agul0wktIpG3C20VTGo3oXWvCpo2EaCfK-8neYsm_VLyH9Am4aE", diff --git a/test/cadet/program_analysis/lexer_test.exs b/test/cadet/program_analysis/lexer_test.exs index dc5ac6e65..545c3ad3f 100644 --- a/test/cadet/program_analysis/lexer_test.exs +++ b/test/cadet/program_analysis/lexer_test.exs @@ -36,20 +36,20 @@ defmodule Cadet.ProgramAnalysis.LexerTest do /* Virtual machine implementation of language Source §0 following the virtual machine of Lecture Week 2 of CS4215 - + Instructions: Copy this file into the Source Academy frontend: https://source-academy.github.io/playground You can use the google drive feature to save your work. When done, copy the file back to the repository and push your changes. - + To run your program, press "Run" and observe the result on the right. - + The language Source §0 is defined as follows: - + prgm ::= expr ; - + expr ::= number | true | false | expr binop expr @@ -58,140 +58,140 @@ defmodule Cadet.ProgramAnalysis.LexerTest do | === | && | || unop ::= ! */ - + // Functions from SICP JS Section 4.1.2 // with slight modifications - - + + function is_tagged_list(expr, the_tag) { return is_pair(expr) && head(expr) === the_tag; } - + function make_literal(value) { return list("literal", value); } - + function is_literal(expr) { return is_tagged_list(expr, "literal"); } - + function literal_value(expr) { return head(tail(expr)); } - + function is_operator_combination(expr) { return is_unary_operator_combination(expr) || is_binary_operator_combination(expr); } - + function is_unary_operator_combination(expr) { return is_tagged_list(expr, "unary_operator_combination"); } - + // logical composition (&&, ||) is treated as binary operator combination function is_binary_operator_combination(expr) { return is_tagged_list(expr, "binary_operator_combination") || is_tagged_list(expr, "logical_composition"); } - + function operator(expr) { return head(tail(expr)); } - + function first_operand(expr) { return head(tail(tail(expr))); } - + function second_operand(expr) { return head(tail(tail(tail(expr)))); } - + // two new functions, not in 4.1.2 - + function is_boolean_literal(expr) { return is_tagged_list(expr, "literal") && is_boolean(literal_value(expr)); } - + function is_number_literal(expr) { return is_tagged_list(expr, "literal") && is_number(literal_value(expr)); } - + // functions to represent virtual machine code - + function op_code(instr) { return head(instr); } - + function arg(instr) { return head(tail(instr)); } - + function make_simple_instruction(op_code) { return list(op_code); } - + function DONE() { return list("DONE"); } - + function LDCI(i) { return list("LDCI", i); } - + function LDCB(b) { return list("LDCB", b); } - + function PLUS() { return list("PLUS"); } - + function MINUS() { return list("MINUS"); } - + function TIMES() { return list("TIMES"); } - + function DIV() { return list("DIV"); } - + function AND() { return list("AND"); } - + function OR() { return list("OR"); } - + function NOT() { return list("NOT"); } - + function LT() { return list("LT"); } - + function GT() { return list("GT"); } - + function EQ() { return list("EQ"); } - + // compile_program: see relation ->> in Section 3.5.2 - + function compile_program(program) { return append(compile_expression(program), list(DONE())); } - + // compile_expression: see relation hookarrow in 3.5.2 - + function compile_expression(expr) { if (is_number_literal(expr)) { return list(LDCI(literal_value(expr))); @@ -220,56 +220,56 @@ defmodule Cadet.ProgramAnalysis.LexerTest do } } } - + function parse_and_compile(string) { return compile_program(parse(string)); } - + // parse_and_compile("! (1 === 1 && 2 > 3);"); // parse_and_compile("1 + 2 / 0;"); // parse_and_compile("1 + 2 / 1;"); // parse_and_compile("3 / 4;"); - + // machine state: a pair consisting // of an operand stack and a program counter, // following 3.5.3 - + function make_state(stack, pc) { return pair(stack, pc); } - + function get_stack(state) { return head(state); } - + function get_pc(state) { return tail(state); } - + // operations on the operand stack - + function empty_stack() { return null; } function push(stack, value) { return pair(value, stack); } - + function pop(stack) { return tail(stack); } - + function top(stack) { return head(stack); } - + // run the machine according to 3.5.3 - + function run(code) { const initial_state = make_state(empty_stack(), 0); return transition(code, initial_state); } - + function transition(code, state) { const pc = get_pc(state); const stack = get_stack(state); @@ -281,7 +281,7 @@ defmodule Cadet.ProgramAnalysis.LexerTest do pc + 1)); } } - + function next_stack(stack, instr) { const op = op_code(instr); return op === "LDCI" ? push(stack, arg(instr)) @@ -298,12 +298,12 @@ defmodule Cadet.ProgramAnalysis.LexerTest do : op === "AND" ? push(pop(pop(stack)), top(pop(stack)) && top(stack)) : /*op === "OR" ?*/ push(pop(pop(stack)), top(pop(stack)) || top(stack)); } - + function parse_compile_and_run(string) { const code = compile_program(parse(string)); return run(code); } - + // parse_compile_and_run("! (1 === 1 && 2 > 3);"); // parse_compile_and_run("1 + 2 / 0;"); // parse_compile_and_run("1 + 2 / 1;"); diff --git a/test/support/changeset_case.ex b/test/support/changeset_case.ex index d20a4de89..bcffcbf17 100644 --- a/test/support/changeset_case.ex +++ b/test/support/changeset_case.ex @@ -2,7 +2,7 @@ defmodule Cadet.ChangesetCase do @moduledoc """ This module defines helper method(s) that is useful to test changeset/2 of an Ecto schema. - + This module provides `assert_changeset`, `assert_changeset_db`, `generate_changeset` ``` """ diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 425e62cb3..5d734fc58 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -2,11 +2,11 @@ defmodule CadetWeb.ConnCase do @moduledoc """ This module defines the test case to be used by tests that require setting up a connection. - + Such tests rely on `Phoenix.ConnTest` and also import other functionality to make it easier to build common datastructures and query the data layer. - + Finally, if the test case interacts with the database, it cannot be async. For this reason, every test runs inside a transaction which is reset at the beginning diff --git a/test/support/data_case.ex b/test/support/data_case.ex index d1d9380a5..5b0a5c76f 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -2,10 +2,10 @@ defmodule Cadet.DataCase do @moduledoc """ This module defines the setup for tests requiring access to the application's data layer. - + You may define functions here to be used as helpers in your tests. - + Finally, if the test case interacts with the database, it cannot be async. For this reason, every test runs inside a transaction which is reset at the beginning @@ -36,11 +36,11 @@ defmodule Cadet.DataCase do @doc """ A helper that transform changeset errors to a map of messages. - + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) assert "password is too short" in errors_on(changeset).password assert %{password: ["password is too short"]} = errors_on(changeset) - + """ def errors_on(changeset) do Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> diff --git a/test/support/xml_generator.ex b/test/support/xml_generator.ex index 7062824fb..8782d32a4 100644 --- a/test/support/xml_generator.ex +++ b/test/support/xml_generator.ex @@ -2,7 +2,7 @@ defmodule Cadet.Test.XMLGenerator do @moduledoc """ This module contains functions to produce sample XML codes in accordance to the specification (xml_api.rst). - + # TODO: Refactor using macros """ From 2f2ebd2396a91c8cf84f1c8503e614060126a74c Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Mon, 22 Jan 2024 14:13:54 +0800 Subject: [PATCH 105/128] Simplify code * Group multiple aliases together * Remove unnecesary newlines * Reorder/revert/reformat unnecessary changes to simplify diff --- .iex.exs | 6 +----- lib/cadet/assessments/submission.ex | 15 ++++++--------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/.iex.exs b/.iex.exs index 2ef5de047..6628fe0fe 100644 --- a/.iex.exs +++ b/.iex.exs @@ -1,9 +1,5 @@ import Ecto.Query alias Cadet.Repo -alias Cadet.Accounts.User +alias Cadet.Accounts.{User, Team, TeamMember} alias Cadet.Assessments.{Answer, Assessment, Question, Submission} alias Cadet.Courses.Group -alias Cadet.Accounts.Team -alias Cadet.Accounts.TeamMember - - diff --git a/lib/cadet/assessments/submission.ex b/lib/cadet/assessments/submission.ex index 9fd80d97b..db3a2b296 100644 --- a/lib/cadet/assessments/submission.ex +++ b/lib/cadet/assessments/submission.ex @@ -22,30 +22,27 @@ defmodule Cadet.Assessments.Submission do belongs_to(:student, CourseRegistration) belongs_to(:team, Team) belongs_to(:unsubmitted_by, CourseRegistration) - has_many(:answers, Answer, on_delete: :delete_all) - # has_one(:notification, Notification, on_delete: :delete_all) timestamps() end - @required_fields [ - :assessment_id, - :status - ] - + @required_fields ~w(assessment_id status)a @optional_fields ~w(xp_bonus unsubmitted_by_id unsubmitted_at student_id team_id)a def changeset(submission, params) do submission |> cast(params, @required_fields ++ @optional_fields) - |> validate_number(:xp_bonus, greater_than_or_equal_to: 0) + |> validate_number( + :xp_bonus, + greater_than_or_equal_to: 0 + ) |> add_belongs_to_id_from_model([:team, :student, :assessment, :unsubmitted_by], params) |> validate_xor_relationship |> validate_required(@required_fields) + |> foreign_key_constraint(:student_id) |> foreign_key_constraint(:assessment_id) |> foreign_key_constraint(:unsubmitted_by_id) - |> foreign_key_constraint(:student_id) end defp validate_xor_relationship(changeset) do From 059cf74ee0fdf17e6c3b8d8c46d7900c244f9ab4 Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Mon, 22 Jan 2024 14:21:27 +0800 Subject: [PATCH 106/128] Revert file permission changes --- lib/cadet/accounts/notification.ex | 0 priv/repo/migrations/20190510152804_drop_announcements_table.exs | 0 2 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 lib/cadet/accounts/notification.ex mode change 100644 => 100755 priv/repo/migrations/20190510152804_drop_announcements_table.exs diff --git a/lib/cadet/accounts/notification.ex b/lib/cadet/accounts/notification.ex old mode 100644 new mode 100755 diff --git a/priv/repo/migrations/20190510152804_drop_announcements_table.exs b/priv/repo/migrations/20190510152804_drop_announcements_table.exs old mode 100644 new mode 100755 From 168a75cf19cb1d184285675a8af6667ed44a9c4c Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Mon, 22 Jan 2024 14:21:56 +0800 Subject: [PATCH 107/128] Remove commented code --- lib/cadet_web/admin_controllers/admin_grading_controller.ex | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/cadet_web/admin_controllers/admin_grading_controller.ex b/lib/cadet_web/admin_controllers/admin_grading_controller.ex index 1bc47664e..caa9771a1 100644 --- a/lib/cadet_web/admin_controllers/admin_grading_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_grading_controller.ex @@ -55,9 +55,6 @@ defmodule CadetWeb.AdminGradingController do {:ok, _} -> text(conn, "OK") - # :ok -> - # text(conn, "OK") - {:error, {status, message}} -> conn |> put_status(status) From b61e9cfa5f74b65dd67bd70e7aa069063818625e Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Mon, 22 Jan 2024 15:04:01 +0800 Subject: [PATCH 108/128] Revert status code changes --- lib/cadet/assessments/assessments.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 31c556eee..51e50df13 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -314,7 +314,7 @@ defmodule Cadet.Assessments do assessment = assessment |> Map.put(:questions, questions) {:ok, assessment} else - {:error, {:unauthorized, "Assessment not open"}} + {:error, {:forbidden, "Assessment not open"}} end end @@ -1369,7 +1369,7 @@ defmodule Cadet.Assessments do a boolean which is false by default. The return value is {:ok, submissions} if no errors, else it is {:error, - {:unauthorized, "Forbidden."}} + {:forbidden, "Forbidden."}} """ @spec all_submissions_by_grader_for_index(CourseRegistration.t()) :: {:ok, From 37944c788569dc5319d3723eae3f6fd20f5d3f17 Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Mon, 22 Jan 2024 15:10:38 +0800 Subject: [PATCH 109/128] Fix tests following status code change revert --- test/cadet_web/controllers/assessments_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/cadet_web/controllers/assessments_controller_test.exs b/test/cadet_web/controllers/assessments_controller_test.exs index 3c3a8ba54..dfef71e17 100644 --- a/test/cadet_web/controllers/assessments_controller_test.exs +++ b/test/cadet_web/controllers/assessments_controller_test.exs @@ -899,7 +899,7 @@ defmodule CadetWeb.AssessmentsControllerTest do |> sign_in(student.user) |> get(build_url(course1.id, mission.assessment.id)) - assert response(conn, 401) == "Assessment not open" + assert response(conn, 403) == "Assessment not open" end test "it does not permit access to unpublished assessments", %{ From 44a020d2f4188ffd2cbf03c6bf05344001dd8222 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Tue, 23 Jan 2024 01:58:54 +0800 Subject: [PATCH 110/128] Remove redundant conversion --- lib/cadet/accounts/notifications.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/cadet/accounts/notifications.ex b/lib/cadet/accounts/notifications.ex index 901cf061d..f7f8a8fbe 100644 --- a/lib/cadet/accounts/notifications.ex +++ b/lib/cadet/accounts/notifications.ex @@ -256,7 +256,7 @@ defmodule Cadet.Accounts.Notifications do id = case submission.student_id do nil -> - team_id = String.to_integer(to_string(submission.team_id)) + team_id = submission.team_id team = Repo.one( From 151a367986abfc68ad66fe3e05a94dd848a03bdc Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Tue, 23 Jan 2024 01:59:22 +0800 Subject: [PATCH 111/128] Add comment to notifications --- lib/cadet/accounts/notifications.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/cadet/accounts/notifications.ex b/lib/cadet/accounts/notifications.ex index f7f8a8fbe..b6bd14d64 100644 --- a/lib/cadet/accounts/notifications.ex +++ b/lib/cadet/accounts/notifications.ex @@ -266,6 +266,8 @@ defmodule Cadet.Accounts.Notifications do ) ) + # Does not matter if team members have different Avengers + # Just require one of them to be notified of the submission s_id = team.team_members |> hd() |> Map.get(:student_id) s_id From 65d836780ed9ae696b48ca6ccd4f81fb810411aa Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Tue, 23 Jan 2024 02:03:41 +0800 Subject: [PATCH 112/128] Add validation for max team size --- lib/cadet/assessments/assessment.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/cadet/assessments/assessment.ex b/lib/cadet/assessments/assessment.ex index 5e5c36233..dc0ecd6b7 100644 --- a/lib/cadet/assessments/assessment.ex +++ b/lib/cadet/assessments/assessment.ex @@ -62,6 +62,7 @@ defmodule Cadet.Assessments.Assessment do |> unique_constraint([:number, :course_id]) |> validate_config_course |> validate_open_close_date + |> validate_number(:max_team_size, greater_than_or_equal_to: 1) end defp validate_config_course(changeset) do From fbf3b06efe312c635f64b5798d520c5dbe5b1c3d Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Tue, 23 Jan 2024 02:05:28 +0800 Subject: [PATCH 113/128] Update assessment changeset testcase --- test/cadet/assessments/assessment_test.exs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test/cadet/assessments/assessment_test.exs b/test/cadet/assessments/assessment_test.exs index ad1f0e4bb..5c90813ba 100644 --- a/test/cadet/assessments/assessment_test.exs +++ b/test/cadet/assessments/assessment_test.exs @@ -131,8 +131,8 @@ defmodule Cadet.Assessments.AssessmentTest do course1: course1, config1: config1 } do - assert_changeset( - %{ + changeset = + Assessment.changeset(%Assessment{}, %{ config_id: config1.id, course_id: course1.id, title: "mission", @@ -140,9 +140,10 @@ defmodule Cadet.Assessments.AssessmentTest do max_team_size: -1, open_at: Timex.now() |> Timex.to_unix() |> Integer.to_string(), close_at: Timex.now() |> Timex.shift(days: 7) |> Timex.to_unix() |> Integer.to_string() - }, - :valid - ) + }) + + assert changeset.valid? == false + assert changeset.errors[:max_team_size] == {"must be greater than or equal to %{number}", [validation: :number, kind: :greater_than_or_equal_to, number: 1]} end end end From d9e03723be562d6c996f21ae4a1c3da5a2456451 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Tue, 23 Jan 2024 02:07:04 +0800 Subject: [PATCH 114/128] Abstract out find teams --- lib/cadet/assessments/assessments.ex | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 51e50df13..5bd8dea2d 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -106,13 +106,7 @@ defmodule Cadet.Assessments do end def assessments_total_xp(%CourseRegistration{id: cr_id}) do - query = - from(t in Team, - join: tm in assoc(t, :team_members), - where: tm.student_id == ^cr_id - ) - - teams = Repo.all(query) + teams = find_teams(cr_id) submission_xp = Submission @@ -327,13 +321,7 @@ defmodule Cadet.Assessments do by the supplied user """ def all_assessments(cr = %CourseRegistration{}) do - query = - from(t in Team, - join: tm in assoc(t, :team_members), - where: tm.student_id == ^cr.id - ) - - teams = Repo.all(query) + teams = find_teams(cr.id) submission_aggregates = Submission @@ -835,6 +823,17 @@ defmodule Cadet.Assessments do end end + defp find_teams(cr_id) do + query = + from(t in Team, + join: tm in assoc(t, :team_members), + where: tm.student_id == ^cr_id + ) + + Repo.all(query) + end + + defp find_team(assessment_id, cr_id) when is_ecto_id(assessment_id) and is_ecto_id(cr_id) do query = From a775413140bda3a7f1b91661d99277d4601e8c89 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Tue, 23 Jan 2024 02:07:21 +0800 Subject: [PATCH 115/128] Remove repeated code --- lib/cadet/assessments/assessments.ex | 59 ++++++---------------------- 1 file changed, 13 insertions(+), 46 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 5bd8dea2d..bf9d78545 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -266,22 +266,13 @@ defmodule Cadet.Assessments do assessment = %Assessment{id: id}, course_reg = %CourseRegistration{role: role} ) do - query = - from(t in Team, - where: t.assessment_id == ^assessment.id, - join: tm in assoc(t, :team_members), - where: tm.student_id == ^course_reg.id, - limit: 1 - ) - - team = Repo.one(query) - - team_id = - if team do - team.id - else - -1 - end + team_id = -1 + case find_team(id, course_reg.id) do + {:ok, team} -> + team_id = team.id + {:error, :team_not_found} -> + {:error, :team_not_found} + end if Timex.compare(Timex.now(), assessment.open_at) >= 0 or role in @open_all_assessment_roles do answer_query = @@ -871,15 +862,7 @@ defmodule Cadet.Assessments do def get_submission(assessment_id, %CourseRegistration{id: cr_id}) when is_ecto_id(assessment_id) do - query = - from(t in Team, - where: t.assessment_id == ^assessment_id, - join: tm in assoc(t, :team_members), - where: tm.student_id == ^cr_id, - limit: 1 - ) - - team = Repo.one(query) + {:ok, team} = find_team(assessment_id, cr_id) case team do %Team{} -> @@ -890,7 +873,7 @@ defmodule Cadet.Assessments do |> preload([_, a], assessment: a) |> Repo.one() - _ -> + nil -> Submission |> where(assessment_id: ^assessment_id) |> where(student_id: ^cr_id) @@ -1689,15 +1672,7 @@ defmodule Cadet.Assessments do end defp find_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do - query = - from(t in Team, - where: t.assessment_id == ^assessment.id, - join: tm in assoc(t, :team_members), - where: tm.student_id == ^cr.id, - limit: 1 - ) - - team = Repo.one(query) + {:ok, team} = find_team(assessment.id, cr.id) submission = case team do @@ -1707,7 +1682,7 @@ defmodule Cadet.Assessments do |> where(assessment_id: ^assessment.id) |> Repo.one() - _ -> + nil -> Submission |> where(student_id: ^cr.id) |> where(assessment_id: ^assessment.id) @@ -1814,15 +1789,7 @@ defmodule Cadet.Assessments do end defp create_empty_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do - query = - from(t in Team, - where: t.assessment_id == ^assessment.id, - join: tm in assoc(t, :team_members), - where: tm.student_id == ^cr.id, - limit: 1 - ) - - team = Repo.one(query) + {:ok, team} = find_team(assessment.id, cr.id) case team do %Team{} -> @@ -1833,7 +1800,7 @@ defmodule Cadet.Assessments do {:ok, submission} -> {:ok, submission} end - _ -> + nil -> %Submission{} |> Submission.changeset(%{student: cr, assessment: assessment}) |> Repo.insert() From 300dcd2e8525f6d25280d0ab3373355309a72fc9 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Tue, 23 Jan 2024 02:55:51 +0800 Subject: [PATCH 116/128] Abstraction of XOR logic --- lib/cadet/assessments/assessments.ex | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index bf9d78545..5b02c8315 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -107,12 +107,13 @@ defmodule Cadet.Assessments do def assessments_total_xp(%CourseRegistration{id: cr_id}) do teams = find_teams(cr_id) + submission_ids = get_submission_ids(cr_id, teams) submission_xp = Submission |> where( [s], - s.student_id == ^cr_id or s.team_id in ^Enum.map(teams, & &1.id) + s.id in ^submission_ids ) |> join(:inner, [s], a in Answer, on: s.id == a.submission_id) |> group_by([s], s.id) @@ -313,13 +314,14 @@ defmodule Cadet.Assessments do """ def all_assessments(cr = %CourseRegistration{}) do teams = find_teams(cr.id) + submission_ids = get_submission_ids(cr.id, teams) submission_aggregates = Submission |> join(:left, [s], ans in Answer, on: ans.submission_id == s.id) |> where( [s], - s.student_id == ^cr.id or s.team_id in ^Enum.map(teams, & &1.id) + s.id in ^submission_ids ) |> group_by([s], s.assessment_id) |> select([s, ans], %{ @@ -333,7 +335,7 @@ defmodule Cadet.Assessments do Submission |> where( [s], - s.student_id == ^cr.id or s.team_id in ^Enum.map(teams, & &1.id) + s.id in ^submission_ids ) |> select([s], [:assessment_id, :status]) @@ -362,6 +364,16 @@ defmodule Cadet.Assessments do {:ok, assessments} end + defp get_submission_ids(cr_id, teams) do + query = + from(s in Submission, + where: s.student_id == ^cr_id or s.team_id in ^Enum.map(teams, & &1.id), + select: s.id + ) + + Repo.all(query) + end + def filter_published_assessments(assessments, cr) do role = cr.role From e5bb030debc84406fa36ab347e29ce1ca9840fec Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Tue, 23 Jan 2024 20:09:16 +0800 Subject: [PATCH 117/128] Fix format --- lib/cadet/assessments/assessments.ex | 3 ++- test/cadet/assessments/assessment_test.exs | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 5b02c8315..60e668228 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -268,9 +268,11 @@ defmodule Cadet.Assessments do course_reg = %CourseRegistration{role: role} ) do team_id = -1 + case find_team(id, course_reg.id) do {:ok, team} -> team_id = team.id + {:error, :team_not_found} -> {:error, :team_not_found} end @@ -836,7 +838,6 @@ defmodule Cadet.Assessments do Repo.all(query) end - defp find_team(assessment_id, cr_id) when is_ecto_id(assessment_id) and is_ecto_id(cr_id) do query = diff --git a/test/cadet/assessments/assessment_test.exs b/test/cadet/assessments/assessment_test.exs index 5c90813ba..8b7d3763b 100644 --- a/test/cadet/assessments/assessment_test.exs +++ b/test/cadet/assessments/assessment_test.exs @@ -131,7 +131,7 @@ defmodule Cadet.Assessments.AssessmentTest do course1: course1, config1: config1 } do - changeset = + changeset = Assessment.changeset(%Assessment{}, %{ config_id: config1.id, course_id: course1.id, @@ -143,7 +143,10 @@ defmodule Cadet.Assessments.AssessmentTest do }) assert changeset.valid? == false - assert changeset.errors[:max_team_size] == {"must be greater than or equal to %{number}", [validation: :number, kind: :greater_than_or_equal_to, number: 1]} + + assert changeset.errors[:max_team_size] == + {"must be greater than or equal to %{number}", + [validation: :number, kind: :greater_than_or_equal_to, number: 1]} end end end From ce1c14f791c606f3a8e68a7df7b0634a420dcd13 Mon Sep 17 00:00:00 2001 From: CheongYeeMing Date: Tue, 23 Jan 2024 22:35:02 +0800 Subject: [PATCH 118/128] Fix failing tests --- lib/cadet/assessments/assessments.ex | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 60e668228..b51e833b7 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -267,15 +267,15 @@ defmodule Cadet.Assessments do assessment = %Assessment{id: id}, course_reg = %CourseRegistration{role: role} ) do - team_id = -1 - - case find_team(id, course_reg.id) do - {:ok, team} -> - team_id = team.id - - {:error, :team_not_found} -> - {:error, :team_not_found} - end + team_id = + case find_team(id, course_reg.id) do + {:ok, nil} -> + -1 + {:ok, team} -> + team.id + {:error, :team_not_found} -> + -1 + end if Timex.compare(Timex.now(), assessment.open_at) >= 0 or role in @open_all_assessment_roles do answer_query = From 4ee4e16b3c6f28715f3ce9528070c8667970d11e Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Wed, 24 Jan 2024 15:15:50 +0800 Subject: [PATCH 119/128] Run format --- lib/cadet/assessments/assessments.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index b51e833b7..f8bd06f69 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -271,8 +271,10 @@ defmodule Cadet.Assessments do case find_team(id, course_reg.id) do {:ok, nil} -> -1 + {:ok, team} -> team.id + {:error, :team_not_found} -> -1 end From a992f0ae9bf702a1b20d5a8a5e7d237ea07211ad Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Thu, 22 Feb 2024 10:59:01 +0800 Subject: [PATCH 120/128] Fix format --- .../admin_controllers/admin_grading_controller_test.exs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/cadet_web/admin_controllers/admin_grading_controller_test.exs b/test/cadet_web/admin_controllers/admin_grading_controller_test.exs index 34431b52f..68a948d2c 100644 --- a/test/cadet_web/admin_controllers/admin_grading_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_grading_controller_test.exs @@ -313,7 +313,7 @@ defmodule CadetWeb.AdminGradingControllerTest do "id" => &1.submission.student.id }, "team" => %{} - } + } :mcq -> %{ @@ -362,7 +362,7 @@ defmodule CadetWeb.AdminGradingControllerTest do "id" => &1.submission.student.id }, "team" => %{} - } + } :voting -> %{ @@ -977,7 +977,7 @@ defmodule CadetWeb.AdminGradingControllerTest do "id" => &1.submission.student.id }, "team" => %{} - } + } :mcq -> %{ @@ -1026,7 +1026,7 @@ defmodule CadetWeb.AdminGradingControllerTest do "id" => &1.submission.student.id }, "team" => %{} - } + } :voting -> %{ From b308228e0293d85b27539e1ab014c79ff330dada Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Thu, 22 Feb 2024 11:07:36 +0800 Subject: [PATCH 121/128] Redate migrations To ensure proper ordering --- ...ts.exs => 20240214125701_add_max_team_size_to_assessments.exs} | 0 ...eate_teams_table.exs => 20240221032615_create_teams_table.exs} | 0 ...ers_table.exs => 20240221033554_create_team_members_table.exs} | 0 ...sions_table.exs => 20240221033707_alter_submissions_table.exs} | 0 ...nswers.exs => 20240222094759_add_last_modified_to_answers.exs} | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename priv/repo/migrations/{20230704125701_add_max_team_size_to_assessments.exs => 20240214125701_add_max_team_size_to_assessments.exs} (100%) rename priv/repo/migrations/{20230711032615_create_teams_table.exs => 20240221032615_create_teams_table.exs} (100%) rename priv/repo/migrations/{20230711033554_create_team_members_table.exs => 20240221033554_create_team_members_table.exs} (100%) rename priv/repo/migrations/{20230711033707_alter_submissions_table.exs => 20240221033707_alter_submissions_table.exs} (100%) rename priv/repo/migrations/{20230805094759_add_last_modified_to_answers.exs => 20240222094759_add_last_modified_to_answers.exs} (100%) diff --git a/priv/repo/migrations/20230704125701_add_max_team_size_to_assessments.exs b/priv/repo/migrations/20240214125701_add_max_team_size_to_assessments.exs similarity index 100% rename from priv/repo/migrations/20230704125701_add_max_team_size_to_assessments.exs rename to priv/repo/migrations/20240214125701_add_max_team_size_to_assessments.exs diff --git a/priv/repo/migrations/20230711032615_create_teams_table.exs b/priv/repo/migrations/20240221032615_create_teams_table.exs similarity index 100% rename from priv/repo/migrations/20230711032615_create_teams_table.exs rename to priv/repo/migrations/20240221032615_create_teams_table.exs diff --git a/priv/repo/migrations/20230711033554_create_team_members_table.exs b/priv/repo/migrations/20240221033554_create_team_members_table.exs similarity index 100% rename from priv/repo/migrations/20230711033554_create_team_members_table.exs rename to priv/repo/migrations/20240221033554_create_team_members_table.exs diff --git a/priv/repo/migrations/20230711033707_alter_submissions_table.exs b/priv/repo/migrations/20240221033707_alter_submissions_table.exs similarity index 100% rename from priv/repo/migrations/20230711033707_alter_submissions_table.exs rename to priv/repo/migrations/20240221033707_alter_submissions_table.exs diff --git a/priv/repo/migrations/20230805094759_add_last_modified_to_answers.exs b/priv/repo/migrations/20240222094759_add_last_modified_to_answers.exs similarity index 100% rename from priv/repo/migrations/20230805094759_add_last_modified_to_answers.exs rename to priv/repo/migrations/20240222094759_add_last_modified_to_answers.exs From a70477c413143387f7d672bf5ed9fa607f9321f1 Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Sat, 24 Feb 2024 14:26:28 +0800 Subject: [PATCH 122/128] Fix format post-merge --- lib/cadet/assessments/assessments.ex | 10 ++++-- .../admin_views/admin_grading_view.ex | 33 ++++++++++--------- .../admin_grading_controller_test.exs | 28 +++++++++------- 3 files changed, 40 insertions(+), 31 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 4e0d863ed..5788d9968 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -1471,9 +1471,13 @@ defmodule Cadet.Assessments do {:ok, %{ :count => integer, - :data => %{:assessments => [any()], :submissions => [any()], :users => [any()], + :data => %{ + :assessments => [any()], + :submissions => [any()], + :users => [any()], :teams => [any()], - :team_members => [any()]} + :team_members => [any()] + } }} def submissions_by_grader_for_index( grader = %CourseRegistration{course_id: course_id}, @@ -1529,7 +1533,7 @@ defmodule Cadet.Assessments do unsubmitted_at: s.unsubmitted_at, unsubmitted_by_id: s.unsubmitted_by_id, student_id: s.student_id, - team_id: s.team_id, + team_id: s.team_id, assessment_id: s.assessment_id, xp: ans.xp, xp_adjustment: ans.xp_adjustment, diff --git a/lib/cadet_web/admin_views/admin_grading_view.ex b/lib/cadet_web/admin_views/admin_grading_view.ex index 1e1fd0a17..3f26e41d1 100644 --- a/lib/cadet_web/admin_views/admin_grading_view.ex +++ b/lib/cadet_web/admin_views/admin_grading_view.ex @@ -40,14 +40,14 @@ defmodule CadetWeb.AdminGradingView do for submission <- submissions do user = users |> Enum.find(&(&1.id == submission.student_id)) assessment = assessments |> Enum.find(&(&1.id == submission.assessment_id)) - team = teams |> Enum.find(&(&1.id == submission.team_id)) - team_members = team_members |> Enum.filter(&(&1.team_id == submission.team_id)) + team = teams |> Enum.find(&(&1.id == submission.team_id)) + team_members = team_members |> Enum.filter(&(&1.team_id == submission.team_id)) - team_member_users = - team_members - |> Enum.map(fn team_member -> - users |> Enum.find(&(&1.id == team_member.student_id)) - end) + team_member_users = + team_members + |> Enum.map(fn team_member -> + users |> Enum.find(&(&1.id == team_member.student_id)) + end) render( CadetWeb.AdminGradingView, @@ -57,15 +57,16 @@ defmodule CadetWeb.AdminGradingView do assessment: assessment, submission: submission, team: team, - team_members: team_member_users, - unsubmitter: - case submission.unsubmitted_by_id do - nil -> nil - _ -> users |> Enum.find(&(&1.id == submission.unsubmitted_by_id)) - end - } - ) - end} + team_members: team_member_users, + unsubmitter: + case submission.unsubmitted_by_id do + nil -> nil + _ -> users |> Enum.find(&(&1.id == submission.unsubmitted_by_id)) + end + } + ) + end + } end def render("gradingsummary.json", %{ diff --git a/test/cadet_web/admin_controllers/admin_grading_controller_test.exs b/test/cadet_web/admin_controllers/admin_grading_controller_test.exs index d1aa9e1a4..e89c1ebde 100644 --- a/test/cadet_web/admin_controllers/admin_grading_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_grading_controller_test.exs @@ -142,9 +142,10 @@ defmodule CadetWeb.AdminGradingControllerTest do "gradedCount" => 5, "unsubmittedBy" => nil, "unsubmittedAt" => nil, - "team" => nil - } - end)} + "team" => nil + } + end) + } res = json_response(conn, 200) @@ -215,9 +216,10 @@ defmodule CadetWeb.AdminGradingControllerTest do "gradedCount" => 5, "unsubmittedBy" => nil, "unsubmittedAt" => nil, - "team" => nil - } - end)} + "team" => nil + } + end) + } res = json_response(conn, 200) @@ -842,9 +844,10 @@ defmodule CadetWeb.AdminGradingControllerTest do "gradedCount" => 5, "unsubmittedBy" => nil, "unsubmittedAt" => nil, - "team" => nil - } - end)} + "team" => nil + } + end) + } res = json_response(conn, 200) @@ -895,9 +898,10 @@ defmodule CadetWeb.AdminGradingControllerTest do "gradedCount" => 5, "unsubmittedBy" => nil, "unsubmittedAt" => nil, - "team" => nil - } - end)} + "team" => nil + } + end) + } res = json_response(conn, 200) From 14d39dabda9af10c5b84d5ebb6d4736eca9cffca Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Sat, 16 Mar 2024 19:31:05 +0800 Subject: [PATCH 123/128] Remove typo --- .../admin_controllers/admin_grading_controller_test.exs | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/cadet_web/admin_controllers/admin_grading_controller_test.exs b/test/cadet_web/admin_controllers/admin_grading_controller_test.exs index e89c1ebde..c427a6478 100644 --- a/test/cadet_web/admin_controllers/admin_grading_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_grading_controller_test.exs @@ -1345,8 +1345,6 @@ defmodule CadetWeb.AdminGradingControllerTest do max_team_size: 1 }) - S - questions = for index <- 0..2 do # insert with display order in reverse From 5c630de35b3c196c795f7412f2fdda612dfcbc2b Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Thu, 21 Mar 2024 20:39:47 +0800 Subject: [PATCH 124/128] Revert dependency downgrades --- mix.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mix.lock b/mix.lock index bdd1fc447..5548e0e94 100644 --- a/mix.lock +++ b/mix.lock @@ -31,7 +31,7 @@ "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "ex_aws": {:hex, :ex_aws, "2.5.1", "7418917974ea42e9e84b25e88b9f3d21a861d5f953ad453e212f48e593d8d39f", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1b95431f70c446fa1871f0eb9b183043c5a625f75f9948a42d25f43ae2eff12b"}, "ex_aws_lambda": {:hex, :ex_aws_lambda, "2.1.0", "f28bffae6dde34ba17ef815ef50a82a1e7f3af26d36fe31dbda3a7657e0de449", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "25608630b8b45fe22b0237696662f88be724bc89f77ee42708a5871511f531a0"}, - "ex_aws_s3": {:hex, :ex_aws_s3, "2.3.3", "61412e524616ea31d3f31675d8bc4c73f277e367dee0ae8245610446f9b778aa", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "0044f0b6f9ce925666021eafd630de64c2b3404d79c85245cc7c8a9a32d7f104"}, + "ex_aws_s3": {:hex, :ex_aws_s3, "2.4.0", "ce8decb6b523381812798396bc0e3aaa62282e1b40520125d1f4eff4abdff0f4", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "85dda6e27754d94582869d39cba3241d9ea60b6aa4167f9c88e309dc687e56bb"}, "ex_aws_secretsmanager": {:hex, :ex_aws_secretsmanager, "2.0.0", "deff8c12335f0160882afeb9687e55a97fddcd7d9a82fc3a6fbb270797374773", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "8b2838af536c32263ff797012b29e87bad73ef34f43cfa60ebca8e84576f6d45"}, "ex_aws_sts": {:hex, :ex_aws_sts, "2.3.0", "ce48c4cba7f1595a7d544458d0202ca313124026dba7b1a0021bbb1baa3d66d0", [:mix], [{:ex_aws, "~> 2.2", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "f14e4c7da3454514bf253b331e9422d25825485c211896ab3b81d2a4bdbf62f5"}, "ex_json_schema": {:hex, :ex_json_schema, "0.7.4", "09eb5b0c8184e5702bc89625a9d0c05c7a0a845d382e9f6f406a0fc1c9a8cc3f", [:mix], [], "hexpm", "45c67fa840f0d719a2b5578126dc29bcdc1f92499c0f61bcb8a3bcb5935f9684"}, @@ -52,12 +52,12 @@ "guardian_db": {:hex, :guardian_db, "2.1.0", "ec95a9d99cdd1e550555d09a7bb4a340d8887aad0697f594590c2fd74be02426", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:guardian, "~> 1.0 or ~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "f8e7d543ac92c395f3a7fd5acbe6829faeade57d688f7562e2f0fca8f94a0d70"}, "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~>2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "inch_ex": {:hex, :inch_ex, "2.1.0-rc.1", "7642a8902c0d2ed5d9b5754b2fc88fedf630500d630fc03db7caca2e92dedb36", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4ceee988760f9382d1c1d0b93ea5875727f6071693e89a0a3c49c456ef1be75d"}, "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, "jose": {:hex, :jose, "1.11.6", "613fda82552128aa6fb804682e3a616f4bc15565a048dabd05b1ebd5827ed965", [:mix, :rebar3], [], "hexpm", "6275cb75504f9c1e60eeacb771adfeee4905a9e182103aa59b53fed651ff9738"}, "jsx": {:hex, :jsx, "3.1.0", "d12516baa0bb23a59bb35dccaf02a1bd08243fcbb9efe24f2d9d056ccff71268", [:rebar3], [], "hexpm", "0c5cc8fdc11b53cc25cf65ac6705ad39e54ecc56d1c22e4adb8f5a53fb9427f3"}, - "mail": {:hex, :mail, "0.2.2", "b1d31beaa2a7b23d7b84b2794f037ef4dfdaba9e66d877142bedbaf0625b9c16", [:mix], [], "hexpm", "1c9d31548a60c44ded1806369e07a7dd4d05737eb47fa3238bbf2436b3da8a32"}, + "mail": {:hex, :mail, "0.2.3", "2c6bb5f8a5f74845fa50ecd0fb45ea16b164026f285f45104f1c4c078cd616d4", [:mix], [], "hexpm", "932b398fa9c69fdf290d7ff63175826e0f1e24414d5b0763bb00a2acfc6c6bf5"}, "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, "memento": {:hex, :memento, "0.3.2", "38cfc8ff9bcb1adff7cbd0f3b78a762636b86dff764729d1c82d0464c539bdd0", [:mix], [], "hexpm", "25cf691a98a0cb70262f4a7543c04bab24648cb2041d937eb64154a8d6f8012b"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, @@ -89,8 +89,8 @@ "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "sweet_xml": {:hex, :sweet_xml, "0.7.4", "a8b7e1ce7ecd775c7e8a65d501bc2cd933bff3a9c41ab763f5105688ef485d08", [:mix], [], "hexpm", "e7c4b0bdbf460c928234951def54fe87edf1a170f6896675443279e2dbeba167"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, - "telemetry_registry": {:hex, :telemetry_registry, "0.3.0", "6768f151ea53fc0fbca70dbff5b20a8d663ee4e0c0b2ae589590e08658e76f1e", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "492e2adbc609f3e79ece7f29fec363a97a2c484ac78a83098535d6564781e917"}, - "timex": {:hex, :timex, "3.7.8", "0e6e8bf7c0aba95f1e13204889b2446e7a5297b1c8e408f15ab58b2c8dc85f81", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8f3b8edc5faab5205d69e5255a1d64a83b190bab7f16baa78aefcb897cf81435"}, + "telemetry_registry": {:hex, :telemetry_registry, "0.3.1", "14a3319a7d9027bdbff7ebcacf1a438f5f5c903057b93aee484cca26f05bdcba", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6d0ca77b691cf854ed074b459a93b87f4c7f5512f8f7743c635ca83da81f939e"}, + "timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"}, "tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, From 4bd6544f02f1f9247f1378edbfa58c95d7b9b263 Mon Sep 17 00:00:00 2001 From: Chen Yanyu <39845424+YaleChen299@users.noreply.github.com> Date: Fri, 30 Jun 2023 11:36:17 +0800 Subject: [PATCH 125/128] Revert incorrect merge conflict resolution --- lib/cadet/assessments/assessments.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index f43b782ab..ca5595e51 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -1727,7 +1727,7 @@ defmodule Cadet.Assessments do @spec get_answers_in_submission(integer() | String.t()) :: {:ok, {[Answer.t()], Assessment.t()}} - | {:error, {:bad_request | :unauthorized, String.t()}} + | {:error, {:bad_request, String.t()}} def get_answers_in_submission(id) when is_ecto_id(id) do answer_query = Answer @@ -1803,7 +1803,7 @@ defmodule Cadet.Assessments do CourseRegistration.t() ) :: {:ok, nil} - | {:error, {:unauthorized | :bad_request | :internal_server_error, String.t()}} + | {:error, {:forbidden | :bad_request | :internal_server_error, String.t()}} def update_grading_info( %{submission_id: submission_id, question_id: question_id}, attrs, @@ -1858,7 +1858,7 @@ defmodule Cadet.Assessments do _, _ ) do - {:error, {:unauthorized, "User is not permitted to grade."}} + {:error, {:forbidden, "User is not permitted to grade."}} end @spec force_regrade_submission(integer() | String.t(), CourseRegistration.t()) :: From 37ab19288820bef69b3cc1760acc2c90da391727 Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Thu, 21 Mar 2024 21:42:49 +0800 Subject: [PATCH 126/128] Fix tests --- test/cadet/assessments/assessments_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/cadet/assessments/assessments_test.exs b/test/cadet/assessments/assessments_test.exs index fd671db8a..6b1793d0b 100644 --- a/test/cadet/assessments/assessments_test.exs +++ b/test/cadet/assessments/assessments_test.exs @@ -151,7 +151,7 @@ defmodule Cadet.AssessmentsTest do status: :attempting }) - assert {:error, {:unauthorized, "User is not permitted to grade."}} = + assert {:error, {:forbidden, "User is not permitted to grade."}} = Assessments.update_grading_info( %{submission: submission, question: question}, %{}, From 434d6d63fcf7c538b32f2279a02e441bf0c28dbb Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Thu, 21 Mar 2024 21:44:30 +0800 Subject: [PATCH 127/128] Update status code for not found --- lib/cadet_web/controllers/team_controller.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/cadet_web/controllers/team_controller.ex b/lib/cadet_web/controllers/team_controller.ex index 3b18c477e..476790055 100644 --- a/lib/cadet_web/controllers/team_controller.ex +++ b/lib/cadet_web/controllers/team_controller.ex @@ -29,7 +29,7 @@ defmodule CadetWeb.TeamController do if team == nil do conn - |> put_status(:ok) + |> put_status(:not_found) |> text("Team is not found!") else team_formation_overview = team_to_team_formation_overview(team) @@ -68,6 +68,7 @@ defmodule CadetWeb.TeamController do end response(200, "OK", Schema.ref(:TeamFormationOverview)) + response(404, "Not Found") response(403, "Forbidden") end From 64f5cbcc8c0a5e3b0a58833afbe6036bb23b8d9d Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Thu, 21 Mar 2024 22:18:44 +0800 Subject: [PATCH 128/128] Fix test for not found status code --- test/cadet_web/controllers/teams_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/cadet_web/controllers/teams_controller_test.exs b/test/cadet_web/controllers/teams_controller_test.exs index b359633fc..e67324adb 100644 --- a/test/cadet_web/controllers/teams_controller_test.exs +++ b/test/cadet_web/controllers/teams_controller_test.exs @@ -58,7 +58,7 @@ defmodule CadetWeb.TeamsControllerTest do course = Repo.get(Course, course_id) assessment = insert(:assessment, %{course: course}) conn = get(conn, build_url_get_by_assessment(course.id, assessment.id)) - assert response(conn, 200) == "Team is not found!" + assert response(conn, 404) == "Team is not found!" end @tag authenticate: :admin