diff --git a/.gitignore b/.gitignore index e64ee19a8..2cc1a63c1 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ erl_crash.dump .terraform *.tfstate *.backup + diff --git a/config/config.exs b/config/config.exs index ec0ca2146..a403c1e77 100644 --- a/config/config.exs +++ b/config/config.exs @@ -44,5 +44,3 @@ config :guardian, Guardian.DB, token_types: ["access"], # default: 60 minute sweep_interval: 60 - -config :pre_commit, commands: ["format --check-formatted", "test", "credo"] diff --git a/lib/cadet.ex b/lib/cadet.ex index d1ad75c39..5b4575714 100644 --- a/lib/cadet.ex +++ b/lib/cadet.ex @@ -11,6 +11,7 @@ defmodule Cadet do use Ecto.Schema import Ecto.Changeset + import Cadet.ModelHelper end end @@ -19,10 +20,66 @@ defmodule Cadet do alias Cadet.Repo import Ecto.Changeset + import Cadet.ContextHelper end end - defmacro __using__(which) when is_atom(which) do - apply(__MODULE__, which, []) + def display do + quote do + import Cadet.DisplayHelper + end + end + + def remote_assets do + quote do + use Arc.Definition + use Arc.Ecto.Definition + end + end + + defp apply_single(context) do + apply(__MODULE__, context, []) + end + + defp apply_multiple(contexts) do + contexts + |> Enum.filter(&is_atom/1) + |> Enum.map(&apply_single/1) + |> join_context_quotes + end + + defp join_context_quotes(context_quotes) do + Enum.reduce( + context_quotes, + quote do + end, + fn context, acc -> + quote do + unquote(acc) + unquote(context) + end + end + ) + end + + @doc """ + The `use Cadet` macro supports both single and multiple arguments i.e.: + ``` + use Cadet, :context + + use Cadet, [:context, :model, :etc] + ``` + """ + defmacro __using__(opt) do + cond do + is_atom(opt) -> + apply_single(opt) + + is_list(opt) -> + apply_multiple(opt) + + true -> + raise "invalid arguments when using Cadet contexts" + end end end diff --git a/lib/cadet/assessments/answer.ex b/lib/cadet/assessments/answer.ex new file mode 100644 index 000000000..33eaa6165 --- /dev/null +++ b/lib/cadet/assessments/answer.ex @@ -0,0 +1,32 @@ +defmodule Cadet.Assessments.Answer do + @moduledoc """ + Answers model contains domain logic for answers management for + programming and multiple choice questions. + """ + use Cadet, :model + + alias Cadet.Assessments.ProblemType + alias Cadet.Assessments.Submission + alias Cadet.Assessments.Question + + schema "answers" do + field(:marks, :float, default: 0.0) + field(:answer, :map) + field(:type, ProblemType) + field(:raw_answer, :string, virtual: true) + belongs_to(:submission, Submission) + belongs_to(:question, Question) + timestamps() + end + + @required_fields ~w(answer type)a + @optional_fields ~w(marks raw_answer)a + + def changeset(answer, params) do + answer + |> cast(params, @required_fields ++ @optional_fields) + |> validate_required(@required_fields) + |> validate_number(:marks, greater_than_or_equal_to: 0.0) + |> put_json(:answer, :raw_answer) + end +end diff --git a/lib/cadet/assessments/answer_types/mcq_answer.ex b/lib/cadet/assessments/answer_types/mcq_answer.ex index 9e3f8359e..9fcff8d1f 100644 --- a/lib/cadet/assessments/answer_types/mcq_answer.ex +++ b/lib/cadet/assessments/answer_types/mcq_answer.ex @@ -3,10 +3,7 @@ defmodule Cadet.Assessments.AnswerTypes.MCQAnswer do The Assessments.QuestionTypes.MCQQuestion entity represents an MCQ Answer. It comprises of one of the MCQ choices. """ - use Ecto.Schema - - import Ecto.Changeset - # TODO: use Cadet context after !34 is merged + use Cadet, :model embedded_schema do field(:choice_id, :integer) diff --git a/lib/cadet/assessments/answer_types/programming_answer.ex b/lib/cadet/assessments/answer_types/programming_answer.ex index 6218456ec..96949c8c2 100644 --- a/lib/cadet/assessments/answer_types/programming_answer.ex +++ b/lib/cadet/assessments/answer_types/programming_answer.ex @@ -2,9 +2,7 @@ defmodule Cadet.Assessments.AnswerTypes.ProgrammingAnswer do @moduledoc """ The ProgrammingQuestion entity represents a Programming question. """ - use Ecto.Schema - - import Ecto.Changeset + use Cadet, :model embedded_schema do field(:code, :string) diff --git a/lib/cadet/assessments/assessment.ex b/lib/cadet/assessments/assessment.ex new file mode 100644 index 000000000..267171652 --- /dev/null +++ b/lib/cadet/assessments/assessment.ex @@ -0,0 +1,57 @@ +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.Assessments.Category + alias Cadet.Assessments.Image + alias Cadet.Assessments.Question + alias Cadet.Assessments.Upload + + schema "assessments" do + field(:title, :string) + field(:is_published, :boolean, default: false) + field(:category, Category) + field(:summary_short, :string) + field(:summary_long, :string) + field(:open_at, Timex.Ecto.DateTime) + field(:close_at, Timex.Ecto.DateTime) + field(:max_xp, :integer, default: 0) + field(:cover_picture, Image.Type) + field(:mission_pdf, Upload.Type) + field(:order, :string, default: "") + has_many(:questions, Question, on_delete: :delete_all) + timestamps() + end + + @required_fields ~w(category title open_at close_at max_xp)a + @optional_fields ~w(summary_short summary_long is_published max_xp)a + @optional_file_fields ~w(cover_picture mission_pdf)a + + def changeset(mission, params) do + params = + params + |> convert_date(:open_at) + |> convert_date(:close_at) + + mission + |> cast(params, @required_fields ++ @optional_fields) + |> validate_required(@required_fields) + |> validate_number(:max_xp, greater_than_or_equal_to: 0) + |> cast_attachments(params, @optional_file_fields) + |> validate_open_close_date + 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 c94d62374..4a9b15715 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -3,29 +3,136 @@ defmodule Cadet.Assessments do Assessments context contains domain logic for assessments management such as missions, sidequests, paths, etc. """ - use Cadet, :context + use Cadet, [:context, :display] - alias Cadet.Assessments.QuestionTypes.MCQQuestion - alias Cadet.Assessments.QuestionTypes.ProgrammingQuestion + import Ecto.Query - # To be uncommented when assessments context is merged + alias Timex.Duration + + alias Cadet.Assessments.Assessment + alias Cadet.Assessments.Question + + def all_assessments() do + Repo.all(Assessment) + end + + def all_assessments(category) do + Repo.all(from(a in Assessment, where: a.category == ^category)) + end + + def all_open_assessments(category) do + now = Timex.now() + + assessment_with_category = Repo.all(from(a in Assessment, where: a.category == ^category)) + # TODO: Refactor to be done on SQL instead of in-memory + Enum.filter(assessment_with_category, &(&1.is_published and Timex.before?(&1.open_at, now))) + end + + def assessments_due_soon() do + now = Timex.now() + week_after = Timex.add(now, Duration.from_weeks(1)) + + all_assessments() + |> Enum.filter( + &(&1.is_published and Timex.before?(&1.open_at, now) and + Timex.between?(&1.close_at, now, week_after)) + ) + end + + def build_assessment(params) do + Assessment.changeset(%Assessment{}, params) + end + + def build_question(params) do + Question.changeset(%Question{}, params) + end + + def create_assessment(params) do + params + |> build_assessment + |> Repo.insert() + end + + def update_assessment(id, params) do + simple_update( + Assessment, + id, + using: &Assessment.changeset/2, + params: params + ) + end + + def update_question(id, params) do + simple_update( + Question, + id, + using: &Question.changeset/2, + params: params + ) + end + + def publish_assessment(id) do + id + |> get_assessment() + |> change(%{is_published: true}) + |> Repo.update() + end + + def get_question(id) do + Repo.get(Question, id) + end + + def get_assessment(id) do + Repo.get(Assessment, id) + end + + def create_question_for_assessment(params, assessment_id) + when is_binary(assessment_id) or is_number(assessment_id) do + assessment = get_assessment(assessment_id) + create_question_for_assessment(params, assessment) + end + + def create_question_for_assessment(params, assessment) do + Repo.transaction(fn -> + assessment = Repo.preload(assessment, :questions) + questions = assessment.questions + + changeset = + params + |> build_question + |> put_assoc(:assessment, assessment) + |> put_display_order(questions) + + case Repo.insert(changeset) do + {:ok, question} -> question + {:error, changeset} -> Repo.rollback(changeset) + end + end) + end + + def delete_question(id) do + question = Repo.get(Question, id) + Repo.delete(question) + end + + # TODO: Decide what to do with these methods # def create_multiple_choice_question(json_attr) when is_binary(json_attr) do - # %MCQQuestion{} - # |> MCQQuestion.changeset(%{raw_mcqquestion: json_attr}) + # %MCQQuestion{} + # |> MCQQuestion.changeset(%{raw_mcqquestion: json_attr}) # end # def create_multiple_choice_question(attr = %{}) do - # %MCQQuestion{} - # |> MCQQuestion.changeset(attr) + # %MCQQuestion{} + # |> MCQQuestion.changeset(attr) # end # def create_programming_question(json_attr) when is_binary(json_attr) do - # %ProgrammingQuestion{} - # |> ProgrammingQuestion.changeset(%{raw_programmingquestion: json_attr}) + # %ProgrammingQuestion{} + # |> ProgrammingQuestion.changeset(%{raw_programmingquestion: json_attr}) # end # def create_programming_question(attr = %{}) do - # %ProgrammingQuestion{} - # |> ProgrammingQuestion.changeset(attr) + # %ProgrammingQuestion{} + # |> ProgrammingQuestion.changeset(attr) # end end diff --git a/lib/cadet/assessments/image.ex b/lib/cadet/assessments/image.ex index f05597bba..f1249029e 100644 --- a/lib/cadet/assessments/image.ex +++ b/lib/cadet/assessments/image.ex @@ -2,8 +2,7 @@ defmodule Cadet.Assessments.Image do @moduledoc """ Image assets used by the missions """ - use Arc.Definition - use Arc.Ecto.Definition + use Cadet, :remote_assets @versions [:original] diff --git a/lib/cadet/assessments/mission.ex b/lib/cadet/assessments/mission.ex deleted file mode 100644 index ddfe05d49..000000000 --- a/lib/cadet/assessments/mission.ex +++ /dev/null @@ -1,26 +0,0 @@ -defmodule Cadet.Assessments.Mission do - @moduledoc """ - The Mission entity stores metadata of a students' assessment - (mission, sidequest, path, and contest) - """ - use Cadet, :model - use Arc.Ecto.Schema - - alias Cadet.Assessments.Category - alias Cadet.Assessments.Image - - schema "missions" do - field(:order, :string) - field(:category, Category) - field(:title, :string) - field(:summary_short, :string) - field(:summary_long, :string) - field(:open_at, Timex.Ecto.DateTime) - field(:close_at, Timex.Ecto.DateTime) - field(:cover_picture, Image.Type) - end - - @required_fields ~w(order category title open_at close_at)a - @optional_fields ~w(summary_short summary_long) - @optional_file_fields ~w(cover_url) -end diff --git a/lib/cadet/assessments/problem_type.ex b/lib/cadet/assessments/problem_type.ex new file mode 100644 index 000000000..b453d4c7c --- /dev/null +++ b/lib/cadet/assessments/problem_type.ex @@ -0,0 +1,6 @@ +import EctoEnum + +defenum(Cadet.Assessments.ProblemType, :type, [ + :programming, + :multiple_choice +]) diff --git a/lib/cadet/assessments/question.ex b/lib/cadet/assessments/question.ex new file mode 100644 index 000000000..2059e6779 --- /dev/null +++ b/lib/cadet/assessments/question.ex @@ -0,0 +1,69 @@ +defmodule Cadet.Assessments.Question do + @moduledoc """ + Questions model contains domain logic for questions management + including programming and multiple choice questions. + """ + use Cadet, :model + + alias Cadet.Assessments.Assessment + alias Cadet.Assessments.ProblemType + alias Cadet.Assessments.QuestionTypes.ProgrammingQuestion + alias Cadet.Assessments.QuestionTypes.MCQQuestion + + schema "questions" do + field(:title, :string) + field(:display_order, :integer) + field(:weight, :integer) + field(:question, :map) + field(:type, ProblemType) + field(:raw_question, :string, virtual: true) + belongs_to(:assessment, Assessment) + timestamps() + end + + @required_fields ~w(title weight question type)a + @optional_fields ~w(display_order raw_question)a + + def changeset(question, params) do + question + |> cast(params, @required_fields ++ @optional_fields) + |> validate_required(@required_fields) + |> validate_number(:weight, greater_than_or_equal_to: 0) + |> put_question + end + + defp put_question(changeset) do + {:ok, json} = + changeset + |> get_change(:raw_question) + |> Kernel.||("{}") + |> Poison.decode() + + type = get_change(changeset, :type) + + case type do + :programming -> + put_change( + changeset, + :question, + %ProgrammingQuestion{} + |> ProgrammingQuestion.changeset(json) + |> apply_changes + |> Map.from_struct() + ) + + :multiple_choice -> + put_change( + changeset, + :question, + %MCQQuestion{} + |> MCQQuestion.changeset(json) + |> apply_changes + |> Map.from_struct() + ) + + _ -> + changeset + end + end +end diff --git a/lib/cadet/assessments/question_types/library.ex b/lib/cadet/assessments/question_types/library.ex index 0ff3f9504..8d9116d5e 100644 --- a/lib/cadet/assessments/question_types/library.ex +++ b/lib/cadet/assessments/question_types/library.ex @@ -2,15 +2,13 @@ defmodule Cadet.Assessments.QuestionTypes.Library do @moduledoc """ The library entity represents a library to be used in a programming question. """ - use Ecto.Schema - - import Ecto.Changeset + use Cadet, :model embedded_schema do - field(:version, :integer) - field(:globals, {:array, :string}) - field(:externals, {:array, :string}) - field(:files, {:array, :string}) + field(:version, :integer, default: 1) + field(:globals, {:array, :string}, default: []) + field(:externals, {:array, :string}, default: []) + field(:files, {:array, :string}, default: []) end @required_fields ~w(version)a diff --git a/lib/cadet/assessments/question_types/mcq_choice.ex b/lib/cadet/assessments/question_types/mcq_choice.ex index 42cbeb7c2..b4aab4238 100644 --- a/lib/cadet/assessments/question_types/mcq_choice.ex +++ b/lib/cadet/assessments/question_types/mcq_choice.ex @@ -2,10 +2,7 @@ defmodule Cadet.Assessments.QuestionTypes.MCQChoice do @moduledoc """ The Assessments.QuestionTypes.MCQChoice entity represents an MCQ Choice. """ - use Ecto.Schema - - import Ecto.Changeset - # TODO: use Cadet context after !34 is merged + use Cadet, :model embedded_schema do field(:content, :string) diff --git a/lib/cadet/assessments/question_types/mcq_question.ex b/lib/cadet/assessments/question_types/mcq_question.ex index cbe069d42..4e9a54c19 100644 --- a/lib/cadet/assessments/question_types/mcq_question.ex +++ b/lib/cadet/assessments/question_types/mcq_question.ex @@ -3,9 +3,7 @@ defmodule Cadet.Assessments.QuestionTypes.MCQQuestion do The Assessments.QuestionTypes.MCQQuestion entity represents an MCQ Question. It comprises of content and choices. """ - use Ecto.Schema - - import Ecto.Changeset + use Cadet, :model alias Cadet.Assessments.QuestionTypes.MCQChoice @@ -21,9 +19,9 @@ defmodule Cadet.Assessments.QuestionTypes.MCQQuestion do def changeset(question, params \\ %{}) do question |> cast(params, @required_fields ++ @optional_fields) - |> put_question() + |> put_question |> cast_embed(:choices, with: &MCQChoice.changeset/2, required: true) - |> validate_one_correct_answer() + |> validate_one_correct_answer |> validate_required(@required_fields ++ ~w(choices)a) end diff --git a/lib/cadet/assessments/question_types/programming_question.ex b/lib/cadet/assessments/question_types/programming_question.ex index a3c81288b..05f6ed192 100644 --- a/lib/cadet/assessments/question_types/programming_question.ex +++ b/lib/cadet/assessments/question_types/programming_question.ex @@ -2,9 +2,7 @@ defmodule Cadet.Assessments.QuestionTypes.ProgrammingQuestion do @moduledoc """ The ProgrammingQuestion entity represents a Programming question. """ - use Ecto.Schema - - import Ecto.Changeset + use Cadet, :model alias Cadet.Assessments.QuestionTypes.Library @@ -23,7 +21,7 @@ defmodule Cadet.Assessments.QuestionTypes.ProgrammingQuestion do def changeset(question, params \\ %{}) do question |> cast(params, @required_fields ++ @optional_fields) - |> put_programmingquestion() + |> put_programmingquestion |> cast_embed(:library, required: true, with: &Library.changeset/2) |> validate_required(@required_fields) end diff --git a/lib/cadet/assessments/submission.ex b/lib/cadet/assessments/submission.ex new file mode 100644 index 000000000..0d64689fe --- /dev/null +++ b/lib/cadet/assessments/submission.ex @@ -0,0 +1,41 @@ +defmodule Cadet.Assessments.Submission do + @moduledoc false + use Cadet, :model + + alias Cadet.Assessments.SubmissionStatus + alias Cadet.Accounts.User + alias Cadet.Assessments.Assessment + alias Cadet.Assessments.Answer + + schema "submissions" do + field(:status, SubmissionStatus, default: :attempting) + field(:submitted_at, Timex.Ecto.DateTime) + field(:override_xp, :integer) + + belongs_to(:assessment, Assessment) + belongs_to(:student, User) + belongs_to(:grader, User) + has_many(:answers, Answer) + + timestamps() + end + + @required_fields ~w(status)a + @optional_fields ~w(override_xp submitted_at)a + + def changeset(submission, params) do + params = convert_date(params, :submitted_at) + + submission + |> cast(params, @required_fields ++ @optional_fields) + |> validate_required(@required_fields) + |> validate_role(:student, :student) + |> validate_role(:grader, :staff) + end + + def validate_role(changeset, user, role) do + validate_change(changeset, user, fn ^user, user -> + if user.role == role, do: [], else: [{user, "does not have the role #{role}"}] + end) + end +end diff --git a/lib/cadet/assessments/submission_status.ex b/lib/cadet/assessments/submission_status.ex new file mode 100644 index 000000000..be3185ae8 --- /dev/null +++ b/lib/cadet/assessments/submission_status.ex @@ -0,0 +1,7 @@ +import EctoEnum + +defenum(Cadet.Assessments.SubmissionStatus, :status, [ + :attempting, + :submitted, + :graded +]) diff --git a/lib/cadet/assessments/upload.ex b/lib/cadet/assessments/upload.ex index b5259ecf8..d8f15e11e 100644 --- a/lib/cadet/assessments/upload.ex +++ b/lib/cadet/assessments/upload.ex @@ -2,8 +2,7 @@ defmodule Cadet.Assessments.Upload do @moduledoc """ Uploaded PDF file for the mission """ - use Arc.Definition - use Arc.Ecto.Definition + use Cadet, :remote_assets @versions [:original] diff --git a/lib/cadet/factory.ex b/lib/cadet/factory.ex index 5c33172a1..bdeefb1a1 100644 --- a/lib/cadet/factory.ex +++ b/lib/cadet/factory.ex @@ -12,6 +12,8 @@ defmodule Cadet.Factory do alias Cadet.Course.Point alias Cadet.Course.Group alias Cadet.Course.Material + alias Cadet.Assessments.Assessment + alias Cadet.Assessments.Question def user_factory do %User{ @@ -77,4 +79,25 @@ defmodule Cadet.Factory do path: "test/fixtures/upload.txt" } end + + def assessment_factory do + %Assessment{ + title: "assessment", + category: Enum.random([:mission, :sidequest, :contest, :path]), + open_at: Timex.now(), + close_at: Timex.shift(Timex.now(), days: Enum.random(1..30)), + max_xp: 100, + is_published: false + } + end + + def question_factory do + %Question{ + title: "question", + weight: Enum.random(1..10), + question: %{}, + type: Enum.random([:programming, :multiple_choice]), + assessment: build(:assessment) + } + end end diff --git a/lib/cadet/helpers/context_helper.ex b/lib/cadet/helpers/context_helper.ex new file mode 100644 index 000000000..9f09b7672 --- /dev/null +++ b/lib/cadet/helpers/context_helper.ex @@ -0,0 +1,20 @@ +defmodule Cadet.ContextHelper do + @moduledoc """ + Contains utility functions that may be commonly used across the Cadet project. + """ + + alias Cadet.Repo + + def simple_update(queryable, id, opts \\ []) do + params = opts[:params] || [] + using = opts[:using] || fn x, _ -> x end + model = Repo.get(queryable, id) + + if model == nil do + {:error, :not_found} + else + changeset = using.(model, params) + Repo.update(changeset) + end + end +end diff --git a/lib/cadet/helpers/display_helper.ex b/lib/cadet/helpers/display_helper.ex new file mode 100644 index 000000000..da49b72b4 --- /dev/null +++ b/lib/cadet/helpers/display_helper.ex @@ -0,0 +1,15 @@ +defmodule Cadet.DisplayHelper do + @moduledoc """ + Contains utility functions that may be used for modules that need to be displayed to the user. + """ + import Ecto.Changeset + + def put_display_order(changeset, collection) do + if Enum.empty?(collection) do + change(changeset, %{display_order: 1}) + else + last = Enum.max_by(collection, & &1.display_order) + change(changeset, %{display_order: last.display_order + 1}) + end + end +end diff --git a/lib/cadet/helpers/model_helper.ex b/lib/cadet/helpers/model_helper.ex new file mode 100644 index 000000000..6ca911b12 --- /dev/null +++ b/lib/cadet/helpers/model_helper.ex @@ -0,0 +1,37 @@ +defmodule Cadet.ModelHelper do + @moduledoc """ + This module contains helper for the models. + """ + + import Ecto.Changeset + + alias Timex.Timezone + + def convert_date(params, field) do + if is_binary(params[field]) && params[field] != "" do + timezone = Timezone.get("Asia/Singapore", Timex.now()) + + date = + params[field] + |> String.to_integer() + |> Timex.from_unix() + |> Timezone.convert(timezone) + + Map.put(params, field, date) + else + params + end + end + + def put_json(changeset, field, json_field) do + change = get_change(changeset, json_field) + + if change do + json = Poison.decode!(change) + + put_change(changeset, field, json) + else + changeset + end + end +end diff --git a/mix.exs b/mix.exs index ae6357444..30bf44796 100644 --- a/mix.exs +++ b/mix.exs @@ -65,8 +65,7 @@ defmodule Cadet.Mixfile do {:dialyxir, "~> 1.0.0-rc.2", only: [:dev, :test], runtime: false}, {:excoveralls, "~> 0.8", only: :test}, {:exvcr, "~> 0.10", only: :test}, - {:phoenix_live_reload, "~> 1.0", only: :dev}, - {:pre_commit, "~> 0.3.4", only: [:dev, :test]} + {:phoenix_live_reload, "~> 1.0", only: :dev} ] end diff --git a/priv/repo/migrations/20180119002258_create_missions.exs b/priv/repo/migrations/20180119002258_create_assessments.exs similarity index 51% rename from priv/repo/migrations/20180119002258_create_missions.exs rename to priv/repo/migrations/20180119002258_create_assessments.exs index 076464225..186d86556 100644 --- a/priv/repo/migrations/20180119002258_create_missions.exs +++ b/priv/repo/migrations/20180119002258_create_assessments.exs @@ -6,7 +6,7 @@ defmodule Cadet.Repo.Migrations.CreateMissions do def up do Category.create_type() - create table(:missions) do + create table(:assessments) do add(:order, :string, null: false) add(:category, :category, null: false) add(:title, :string, null: false) @@ -15,18 +15,23 @@ defmodule Cadet.Repo.Migrations.CreateMissions do add(:open_at, :timestamp, null: false) add(:close_at, :timestamp, null: false) add(:cover_picture, :string) + add(:mission_pdf, :string) + add(:is_published, :boolean, null: false) + add(:max_xp, :integer) + add(:priority, :integer) + timestamps() end - create(index(:missions, [:order], using: :hash)) - create(index(:missions, [:open_at])) - create(index(:missions, [:close_at])) + create(index(:assessments, [:order], using: :hash)) + create(index(:assessments, [:open_at])) + create(index(:assessments, [:close_at])) end def down do - drop(index(:missions, [:order])) - drop(index(:missions, [:open_at])) - drop(index(:missions, [:close_at])) - drop(table(:missions)) + drop(index(:assessments, [:order])) + drop(index(:assessments, [:open_at])) + drop(index(:assessments, [:close_at])) + drop(table(:assessments)) Category.drop_type() end diff --git a/priv/repo/migrations/20180526050817_create_questions.exs b/priv/repo/migrations/20180526050817_create_questions.exs new file mode 100644 index 000000000..80a55d3a8 --- /dev/null +++ b/priv/repo/migrations/20180526050817_create_questions.exs @@ -0,0 +1,27 @@ +defmodule Cadet.Repo.Migrations.CreateQuestions do + use Ecto.Migration + + alias Cadet.Assessments.ProblemType + + def up do + ProblemType.create_type() + + create table(:questions) do + add(:display_order, :integer) + add(:type, :type, null: false) + add(:title, :string) + add(:library, :map) + add(:raw_library, :text) + add(:question, :map, null: false) + add(:raw_question, :string) + add(:assessment_id, references(:assessments)) + timestamps() + end + end + + def down do + drop(table(:questions)) + + ProblemType.drop_type() + end +end diff --git a/priv/repo/migrations/20180526053445_add_weight_to_questions.exs b/priv/repo/migrations/20180526053445_add_weight_to_questions.exs new file mode 100644 index 000000000..f7d6fead5 --- /dev/null +++ b/priv/repo/migrations/20180526053445_add_weight_to_questions.exs @@ -0,0 +1,9 @@ +defmodule Cadet.Repo.Migrations.AddWeightToQuestions do + use Ecto.Migration + + def change do + alter table(:questions) do + add(:weight, :integer) + end + end +end diff --git a/priv/repo/migrations/20180526084150_create_submissions.exs b/priv/repo/migrations/20180526084150_create_submissions.exs new file mode 100644 index 000000000..778fe8d83 --- /dev/null +++ b/priv/repo/migrations/20180526084150_create_submissions.exs @@ -0,0 +1,19 @@ +defmodule Cadet.Repo.Migrations.CreateSubmissions do + use Ecto.Migration + + alias Cadet.Assessments.SubmissionStatus + + def change do + SubmissionStatus.create_type() + + create table(:submissions) do + add(:status, :status) + add(:submitted_at, :datetime) + add(:override_xp, :integer) + add(:assessment_id, references(:assessments)) + add(:student_id, references(:users)) + add(:grader_id, references(:users)) + timestamps() + end + end +end diff --git a/test/cadet/assessments/answer_test.exs b/test/cadet/assessments/answer_test.exs new file mode 100644 index 000000000..6290314ff --- /dev/null +++ b/test/cadet/assessments/answer_test.exs @@ -0,0 +1,40 @@ +defmodule Cadet.Assessments.AnswerTest do + use Cadet.ChangesetCase, async: true + + alias Cadet.Assessments.Answer + + valid_changesets Answer do + %{ + marks: 2, + answer: %{}, + type: :programming + } + + %{ + marks: 1, + answer: %{}, + type: :multiple_choice + } + + %{ + marks: 1, + answer: %{}, + type: :multiple_choice + } + + %{ + marks: 100, + answer: %{}, + type: :programming, + raw_answer: Poison.encode!(%{answer: "This is a sample json"}) + } + end + + invalid_changesets Answer do + %{ + marks: -2, + answer: %{}, + type: :programming + } + end +end diff --git a/test/cadet/assessments/answer_types/mcq_answer_test.exs b/test/cadet/assessments/answer_types/mcq_answer_test.exs index 67da94ecf..b8e548e8d 100644 --- a/test/cadet/assessments/answer_types/mcq_answer_test.exs +++ b/test/cadet/assessments/answer_types/mcq_answer_test.exs @@ -1,7 +1,6 @@ defmodule Cadet.Assessments.AnswerTypes.MCQAnswerTest do use Cadet.ChangesetCase, async: true - import Ecto.Changeset alias Cadet.Assessments.AnswerTypes.MCQAnswer valid_changesets MCQAnswer do diff --git a/test/cadet/assessments/assessment_test.exs b/test/cadet/assessments/assessment_test.exs new file mode 100644 index 000000000..59a91b77d --- /dev/null +++ b/test/cadet/assessments/assessment_test.exs @@ -0,0 +1,44 @@ +defmodule Cadet.Assessments.AssessmentTest do + use Cadet.ChangesetCase, async: true + + alias Cadet.Assessments.Assessment + + valid_changesets Assessment do + %{ + category: :mission, + title: "mission", + open_at: Timex.now() |> Timex.to_unix() |> Integer.to_string(), + close_at: Timex.now() |> Timex.shift(days: 7) |> Timex.to_unix() |> Integer.to_string(), + max_xp: 100 + } + + %{ + category: :mission, + title: "mission", + open_at: Timex.now() |> Timex.to_unix() |> Integer.to_string(), + close_at: Timex.now() |> Timex.shift(days: 7) |> Timex.to_unix() |> Integer.to_string(), + max_xp: 100, + cover_picture: build_upload("test/fixtures/upload.png", "image/png"), + mission_pdf: build_upload("test/fixtures/upload.pdf", "application/pdf") + } + end + + invalid_changesets Assessment do + %{ + category: :mission, + title: "mission", + open_at: Timex.now() |> Timex.to_unix() |> Integer.to_string(), + close_at: Timex.now() |> Timex.shift(days: 7) |> Timex.to_unix() |> Integer.to_string(), + max_xp: -100 + } + + %{category: :mission, title: "mission", max_xp: 100} + + %{ + title: "mission", + open_at: Timex.now(), + close_at: Timex.shift(Timex.now(), days: 7), + max_xp: 100 + } + end +end diff --git a/test/cadet/assessments/assessments_test.exs b/test/cadet/assessments/assessments_test.exs new file mode 100644 index 000000000..513bc42e2 --- /dev/null +++ b/test/cadet/assessments/assessments_test.exs @@ -0,0 +1,210 @@ +defmodule Cadet.AssessmentsTest do + use Cadet.DataCase + + alias Cadet.Assessments + + test "all assessments" do + assessments = Enum.map(insert_list(5, :assessment), & &1.id) + + result = Enum.map(Assessments.all_assessments(), & &1.id) + assert result == assessments + end + + test "all open assessments" do + open_assessment = insert(:assessment, is_published: true, category: :mission) + closed_assessment = insert(:assessment, is_published: false, category: :mission) + result = Enum.map(Assessments.all_open_assessments(:mission), fn m -> m.id end) + assert open_assessment.id in result + refute closed_assessment.id in result + end + + test "create assessment" do + {:ok, assessment} = + Assessments.create_assessment(%{ + title: "assessment", + category: :mission, + open_at: Timex.now(), + close_at: Timex.shift(Timex.now(), days: 7) + }) + + assert %{title: "assessment", category: :mission} = assessment + end + + test "create sidequest" do + {:ok, assessment} = + Assessments.create_assessment(%{ + title: "sidequest", + category: :sidequest, + open_at: Timex.now(), + close_at: Timex.shift(Timex.now(), days: 7) + }) + + assert %{title: "sidequest", category: :sidequest} = assessment + end + + test "create contest" do + {:ok, assessment} = + Assessments.create_assessment(%{ + title: "contest", + category: :contest, + open_at: Timex.now(), + close_at: Timex.shift(Timex.now(), days: 7) + }) + + assert %{title: "contest", category: :contest} = assessment + end + + test "create path" do + {:ok, assessment} = + Assessments.create_assessment(%{ + title: "path", + category: :path, + open_at: Timex.now(), + close_at: Timex.shift(Timex.now(), days: 7) + }) + + assert %{title: "path", category: :path} = assessment + end + + test "create programming question" do + assessment = insert(:assessment) + + {:ok, question} = + Assessments.create_question_for_assessment( + %{ + title: "question", + weight: 5, + type: :programming, + question: %{}, + raw_question: + Poison.encode!(%{ + content: "asd", + solution_template: "template", + solution: "soln", + library: %{version: 1} + }) + }, + assessment.id + ) + + assert %{title: "question", weight: 5, type: :programming} = question + end + + test "create multiple choice question" do + assessment = insert(:assessment) + + {:ok, question} = + Assessments.create_question_for_assessment( + %{ + title: "question", + weight: 5, + type: :multiple_choice, + question: %{}, + raw_question: + Poison.encode!(%{content: "asd", choices: [%{is_correct: true, content: "asd"}]}) + }, + assessment.id + ) + + assert %{title: "question", weight: 5, type: :multiple_choice} = question + end + + test "create question when there already exists questions" do + assessment = insert(:assessment) + _ = insert(:question, assessment: assessment, display_order: 1) + + {:ok, question} = + Assessments.create_question_for_assessment( + %{ + title: "question", + weight: 5, + type: :multiple_choice, + question: %{}, + raw_question: + Poison.encode!(%{content: "asd", choices: [%{is_correct: true, content: "asd"}]}) + }, + assessment.id + ) + + assert %{display_order: 2} = question + end + + test "create invalid question" do + assessment = insert(:assessment) + + assert {:error, _} = Assessments.create_question_for_assessment(%{}, assessment.id) + end + + test "publish assessment" do + assessment = insert(:assessment, is_published: false) + + {:ok, assessment} = Assessments.publish_assessment(assessment.id) + assert assessment.is_published == true + end + + test "update assessment" do + assessment = insert(:assessment, title: "assessment") + + Assessments.update_assessment(assessment.id, %{title: "changed_assessment"}) + + assessment = Assessments.get_assessment(assessment.id) + + assert assessment.title == "changed_assessment" + end + + test "all assessments with category" do + assessment = insert(:assessment, category: :mission) + sidequest = insert(:assessment, category: :sidequest) + contest = insert(:assessment, category: :contest) + path = insert(:assessment, category: :path) + assert assessment.id in Enum.map(Assessments.all_assessments(:mission), fn m -> m.id end) + assert sidequest.id in Enum.map(Assessments.all_assessments(:sidequest), fn m -> m.id end) + assert contest.id in Enum.map(Assessments.all_assessments(:contest), fn m -> m.id end) + assert path.id in Enum.map(Assessments.all_assessments(:path), fn m -> m.id end) + end + + test "due assessments" do + assessment_before_now = + insert( + :assessment, + open_at: Timex.shift(Timex.now(), weeks: -1), + close_at: Timex.shift(Timex.now(), days: -2), + is_published: true + ) + + assessment_in_timerange = + insert( + :assessment, + open_at: Timex.shift(Timex.now(), days: -1), + close_at: Timex.shift(Timex.now(), days: 4), + is_published: true + ) + + assessment_far = + insert( + :assessment, + open_at: Timex.shift(Timex.now(), days: -2), + close_at: Timex.shift(Timex.now(), weeks: 2), + is_published: true + ) + + result = Enum.map(Assessments.assessments_due_soon(), fn m -> m.id end) + + assert assessment_in_timerange.id in result + refute assessment_before_now.id in result + refute assessment_far.id in result + end + + test "update question" do + question = insert(:question) + Assessments.update_question(question.id, %{weight: 10}) + question = Assessments.get_question(question.id) + assert question.weight == 10 + end + + test "delete question" do + question = insert(:question) + Assessments.delete_question(question.id) + assert Assessments.get_question(question.id) == nil + end +end diff --git a/test/cadet/assessments/question_test.exs b/test/cadet/assessments/question_test.exs new file mode 100644 index 000000000..77c6a18b4 --- /dev/null +++ b/test/cadet/assessments/question_test.exs @@ -0,0 +1,50 @@ +defmodule Cadet.Assessments.QuestionTest do + use Cadet.ChangesetCase, async: true + + alias Cadet.Assessments.Question + + valid_changesets Question do + %{ + display_order: 2, + title: "question", + weight: 5, + question: %{}, + type: :programming + } + + %{ + display_order: 1, + title: "mcq", + weight: 5, + question: %{}, + type: :multiple_choice + } + + %{ + display_order: 5, + title: "sample title", + weight: 4, + question: %{}, + type: :programming, + raw_library: Poison.encode!(%{week: 5, globals: [], externals: [], files: []}), + raw_question: Poison.encode!(%{question: "This is a sample json"}) + } + end + + invalid_changesets Question do + %{ + display_order: 2, + title: "question", + weight: -5, + question: %{}, + type: :programming + } + + %{ + display_order: 2, + weight: 5, + question: %{}, + type: :multiple_choice + } + end +end diff --git a/test/cadet/assessments/question_types/programming_question_test.exs b/test/cadet/assessments/question_types/programming_question_test.exs index de5b8b33b..660869cfc 100644 --- a/test/cadet/assessments/question_types/programming_question_test.exs +++ b/test/cadet/assessments/question_types/programming_question_test.exs @@ -13,7 +13,12 @@ defmodule Cadet.Assessments.QuestionTypes.ProgrammingQuestionTest do %{ raw_programmingquestion: - "{\"solution_template\":\"asd\",\"solution_header\":\"asd\",\"solution\":\"asd\",\"content\":\"asd\",\"library\":{\"version\":1}}" + Poison.encode!(%{ + content: "asd", + solution_template: "asd", + solution: "asd", + library: %{version: 1} + }) } end @@ -24,7 +29,6 @@ defmodule Cadet.Assessments.QuestionTypes.ProgrammingQuestionTest do content: "asd", solution_template: "asd", solution_header: "asd", - solution: "asd", library: %{globals: ["a"]} } end diff --git a/test/cadet/assessments/submission_test.exs b/test/cadet/assessments/submission_test.exs new file mode 100644 index 000000000..f13fda4e6 --- /dev/null +++ b/test/cadet/assessments/submission_test.exs @@ -0,0 +1,13 @@ +defmodule Cadet.Assessments.SubmissionTest do + use Cadet.ChangesetCase, async: true + + alias Cadet.Assessments.Submission + + valid_changesets Submission do + %{ + status: :submitted, + submitted_at: Timex.now() |> Timex.to_unix() |> Integer.to_string(), + override_xp: 100 + } + end +end diff --git a/test/fixtures/upload.pdf b/test/fixtures/upload.pdf new file mode 100644 index 000000000..cdacbbebb Binary files /dev/null and b/test/fixtures/upload.pdf differ diff --git a/test/fixtures/upload.png b/test/fixtures/upload.png new file mode 100644 index 000000000..c5916f289 Binary files /dev/null and b/test/fixtures/upload.png differ