diff --git a/lib/cadet/accounts/query.ex b/lib/cadet/accounts/query.ex index 389e1fcb7..b9d6f5e7b 100644 --- a/lib/cadet/accounts/query.ex +++ b/lib/cadet/accounts/query.ex @@ -4,7 +4,8 @@ defmodule Cadet.Accounts.Query do """ import Ecto.Query - alias Cadet.Accounts.Authorization + alias Cadet.Accounts.{Authorization, User} + alias Cadet.Course.Group def user_nusnet_ids(user_id) do Authorization @@ -18,6 +19,13 @@ defmodule Cadet.Accounts.Query do |> of_uid(uid) end + @spec students_of(User.t()) :: User.t() + def students_of(%User{id: id, role: :staff}) do + User + |> join(:inner, [u], g in Group, u.group_id == g.id) + |> where([_, g], g.leader_id == ^id) + end + defp nusnet_ids(query) do query |> where([a], a.provider == "nusnet_id") end diff --git a/lib/cadet/assessments/answer.ex b/lib/cadet/assessments/answer.ex index 08eefcbc2..bebcbd174 100644 --- a/lib/cadet/assessments/answer.ex +++ b/lib/cadet/assessments/answer.ex @@ -12,7 +12,6 @@ defmodule Cadet.Assessments.Answer do field(:xp, :integer, default: 0) field(:answer, :map) field(:type, QuestionType, virtual: true) - field(:raw_answer, :string, virtual: true) field(:comment, :string) field(:adjustment, :integer, default: 0) belongs_to(:submission, Submission) @@ -35,6 +34,24 @@ defmodule Cadet.Assessments.Answer do |> validate_answer_content() end + @spec grading_changeset(%__MODULE__{} | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() + def grading_changeset(answer, params) do + answer + |> cast(params, ~w(adjustment comment)a) + |> validate_xp_adjustment_total() + end + + @spec validate_xp_adjustment_total(Ecto.Changeset.t()) :: Ecto.Changeset.t() + defp validate_xp_adjustment_total(changeset) do + answer = apply_changes(changeset) + + if answer.xp + answer.adjustment >= 0 do + changeset + else + add_error(changeset, :adjustment, "should not make total point < 0") + end + end + defp add_question_type_from_model(changeset, params) do with question when is_map(question) <- Map.get(params, :question), nil <- get_change(changeset, :type), diff --git a/lib/cadet/assessments/assessment.ex b/lib/cadet/assessments/assessment.ex index 21cfcc978..ea3827f8e 100644 --- a/lib/cadet/assessments/assessment.ex +++ b/lib/cadet/assessments/assessment.ex @@ -12,6 +12,7 @@ defmodule Cadet.Assessments.Assessment do alias Cadet.Assessments.Upload schema "assessments" do + field(:max_xp, :integer, virtual: true) field(:title, :string) field(:is_published, :boolean, default: false) field(:type, AssessmentType) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 8f297f1fd..5379c6b93 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -10,9 +10,10 @@ defmodule Cadet.Assessments do alias Timex.Duration alias Cadet.Accounts.User - alias Cadet.Assessments.{Answer, Assessment, Question, Submission} + alias Cadet.Assessments.{Answer, Assessment, Query, Question, Submission} @submit_answer_roles ~w(student)a + @grading_roles ~w(staff)a def all_assessments() do Repo.all(Assessment) @@ -89,7 +90,7 @@ defmodule Cadet.Assessments do end def create_question_for_assessment(params, assessment_id) - when is_binary(assessment_id) or is_number(assessment_id) do + when is_ecto_id(assessment_id) do assessment = get_assessment(assessment_id) create_question_for_assessment(params, assessment) end @@ -151,6 +152,95 @@ defmodule Cadet.Assessments do end end + @spec all_submissions_by_grader(User.t()) :: + {:ok, [Submission.t()]} | {:error, {:unauthorized, String.t()}} + def all_submissions_by_grader(grader = %User{role: role}) do + if role in @grading_roles do + students = Cadet.Accounts.Query.students_of(grader) + + submissions = + Submission + |> join(:inner, [s], x in subquery(Query.submissions_xp()), s.id == x.submission_id) + |> join(:inner, [s], st in subquery(students), s.student_id == st.id) + |> join( + :inner, + [s], + a in subquery(Query.all_assessments_with_max_xp()), + s.assessment_id == a.id + ) + |> select([s, x, st, a], %Submission{s | xp: x.xp, student: st, assessment: a}) + |> Repo.all() + + {:ok, submissions} + else + {:error, {:unauthorized, "User is not permitted to grade."}} + end + end + + @spec get_answers_in_submission(integer() | String.t(), User.t()) :: + {:ok, [Answer.t()]} | {:error, {:unauthorized, String.t()}} + def get_answers_in_submission(id, grader = %User{role: role}) when is_ecto_id(id) do + if role in @grading_roles do + students = Cadet.Accounts.Query.students_of(grader) + + answers = + Answer + |> where(submission_id: ^id) + |> join(:inner, [a], s in Submission, a.submission_id == s.id) + |> join(:inner, [a, s], t in subquery(students), t.id == s.student_id) + |> join(:inner, [a], q in assoc(a, :question)) + |> preload([a, ..., q], question: q) + |> Repo.all() + + {:ok, answers} + else + {:error, {:unauthorized, "User is not permitted to grade."}} + end + end + + @spec update_grading_info( + %{submission_id: integer() | String.t(), question_id: integer() | String.t()}, + %{}, + User.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, + grader = %User{role: role} + ) + when is_ecto_id(submission_id) and is_ecto_id(question_id) do + if role in @grading_roles do + students = Cadet.Accounts.Query.students_of(grader) + + answer = + Answer + |> where([a], a.submission_id == ^submission_id and a.question_id == ^question_id) + |> join(:inner, [a], s in assoc(a, :submission)) + |> join(:inner, [a, s], t in subquery(students), t.id == s.student_id) + |> Repo.one() + + with {:answer_found?, true} <- {:answer_found?, is_map(answer)}, + {:valid, changeset = %Ecto.Changeset{valid?: true}} <- + {:valid, Answer.grading_changeset(answer, attrs)}, + {:ok, _} <- Repo.update(changeset) do + {:ok, nil} + 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.errors)}} + + {:error, _} -> + {:error, {:internal_server_error, "Please try again later."}} + end + else + {:error, {:unauthorized, "User is not permitted to grade."}} + end + end + defp find_submission(user = %User{}, assessment = %Assessment{}) do submission = Submission @@ -211,25 +301,4 @@ defmodule Cadet.Assessments do %{code: raw_answer} end 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}) - # end - - # def create_multiple_choice_question(attr = %{}) do - # %MCQQuestion{} - # |> MCQQuestion.changeset(attr) - # end - - # def create_programming_question(json_attr) when is_binary(json_attr) do - # %ProgrammingQuestion{} - # |> ProgrammingQuestion.changeset(%{raw_programmingquestion: json_attr}) - # end - - # def create_programming_question(attr = %{}) do - # %ProgrammingQuestion{} - # |> ProgrammingQuestion.changeset(attr) - # end end diff --git a/lib/cadet/assessments/library.ex b/lib/cadet/assessments/library.ex index 556cb5cbf..f8431e872 100644 --- a/lib/cadet/assessments/library.ex +++ b/lib/cadet/assessments/library.ex @@ -5,13 +5,13 @@ defmodule Cadet.Assessments.Library do use Cadet, :model embedded_schema do - field(:version, :integer, default: 1) + field(:chapter, :integer, default: 1) field(:globals, {:array, :string}, default: []) field(:externals, {:array, :string}, default: []) field(:files, {:array, :string}, default: []) end - @required_fields ~w(version)a + @required_fields ~w(chapter)a @optional_fields ~w(globals externals files)a def changeset(library, params \\ %{}) do diff --git a/lib/cadet/assessments/query.ex b/lib/cadet/assessments/query.ex new file mode 100644 index 000000000..80db693e2 --- /dev/null +++ b/lib/cadet/assessments/query.ex @@ -0,0 +1,39 @@ +defmodule Cadet.Assessments.Query do + @moduledoc """ + Generate queries related to the Assessments context + """ + import Ecto.Query + + alias Cadet.Assessments.{Answer, Assessment, Question, Submission} + + @spec all_submissions_with_xp :: Submission.t() + def all_submissions_with_xp do + Submission + |> join(:inner, [s], q in subquery(submissions_xp()), s.id == q.submission_id) + |> select([s, q], %Submission{s | xp: q.xp}) + end + + @spec all_assessments_with_max_xp :: Assessment.t() + def all_assessments_with_max_xp do + Assessment + |> join(:inner, [a], q in subquery(assessments_max_xp()), a.id == q.assessment_id) + |> select([a, q], %Assessment{a | max_xp: q.max_xp}) + end + + @spec submissions_xp :: %{submission_id: integer(), xp: integer()} + def submissions_xp do + Answer + |> group_by(:submission_id) + |> select([a], %{ + submission_id: a.submission_id, + xp: fragment("? + ?", sum(a.xp), sum(a.adjustment)) + }) + end + + @spec assessments_max_xp :: %{assessment_id: integer(), max_xp: integer()} + def assessments_max_xp do + Question + |> group_by(:assessment_id) + |> select([q], %{assessment_id: q.assessment_id, max_xp: sum(q.max_xp)}) + end +end diff --git a/lib/cadet/course/course.ex b/lib/cadet/course/course.ex index c5c5c4d56..dc76feb56 100644 --- a/lib/cadet/course/course.ex +++ b/lib/cadet/course/course.ex @@ -7,7 +7,7 @@ defmodule Cadet.Course do alias Cadet.Accounts.User alias Cadet.Course.Announcement - alias Cadet.Course.Group + # alias Cadet.Course.Group alias Cadet.Course.Material alias Cadet.Course.Upload diff --git a/lib/cadet/course/query.ex b/lib/cadet/course/query.ex index e1168302b..b0eef7f79 100644 --- a/lib/cadet/course/query.ex +++ b/lib/cadet/course/query.ex @@ -4,7 +4,6 @@ defmodule Cadet.Course.Query do """ import Ecto.Query - alias Cadet.Accounts.User alias Cadet.Course.Material def material_folder_files(folder_id) do diff --git a/lib/cadet/factory.ex b/lib/cadet/factory.ex index c07289617..4779b622b 100644 --- a/lib/cadet/factory.ex +++ b/lib/cadet/factory.ex @@ -7,12 +7,19 @@ defmodule Cadet.Factory do @dialyzer {:no_return, fields_for: 1} alias Cadet.Accounts.{Authorization, User} - alias Cadet.Course.{Announcement, Material} - alias Cadet.Assessments.{Assessment, Question, Submission} + alias Cadet.Assessments.{Answer, Assessment, Question, Submission} + alias Cadet.Course.{Announcement, Group, Material} def user_factory do %User{ name: "John Smith", + role: :staff + } + end + + def student_factory do + %User{ + name: sequence("student"), role: :student } end @@ -25,6 +32,12 @@ defmodule Cadet.Factory do } end + def group_factory do + %Group{ + name: sequence("group") + } + end + def announcement_factory do %Announcement{ title: sequence(:title, &"Announcement #{&1}"), @@ -78,10 +91,30 @@ defmodule Cadet.Factory do def question_factory do %Question{ - title: "question", + title: sequence("question"), question: %{}, type: Enum.random([:programming, :multiple_choice]), assessment: build(:assessment, %{is_published: true}) } end + + def programming_question_factory do + %{ + content: sequence("ProgrammingQuestion"), + solution_template: "f => f(f);", + solution: "(f => f(f))(f => f(f));" + } + end + + def answer_factory do + %Answer{ + answer: %{} + } + end + + def programming_answer_factory do + %{ + code: sequence(:code, &"alert(#{&1})") + } + end end diff --git a/lib/cadet/helpers/context_helper.ex b/lib/cadet/helpers/context_helper.ex index 9f09b7672..864cb4cac 100644 --- a/lib/cadet/helpers/context_helper.ex +++ b/lib/cadet/helpers/context_helper.ex @@ -17,4 +17,10 @@ defmodule Cadet.ContextHelper do Repo.update(changeset) end end + + defmacro is_ecto_id(id) do + quote do + is_integer(unquote(id)) or is_binary(unquote(id)) + end + end end diff --git a/lib/cadet/helpers/display_helper.ex b/lib/cadet/helpers/display_helper.ex index da49b72b4..0e0ccd519 100644 --- a/lib/cadet/helpers/display_helper.ex +++ b/lib/cadet/helpers/display_helper.ex @@ -12,4 +12,11 @@ defmodule Cadet.DisplayHelper do change(changeset, %{display_order: last.display_order + 1}) end end + + @spec full_error_messages(keyword({String.t(), term()})) :: String.t() + def full_error_messages(errors) do + errors + |> Enum.map(fn {key, {message, _}} -> "#{key} #{message}" end) + |> Enum.join(", ") + end end diff --git a/lib/cadet_web/controllers/grading_controller.ex b/lib/cadet_web/controllers/grading_controller.ex index eb10e796a..a22f80e15 100644 --- a/lib/cadet_web/controllers/grading_controller.ex +++ b/lib/cadet_web/controllers/grading_controller.ex @@ -1,8 +1,46 @@ defmodule CadetWeb.GradingController do use CadetWeb, :controller - use PhoenixSwagger + alias Cadet.Assessments + + def index(conn, _) do + user = conn.assigns[:current_user] + + case Assessments.all_submissions_by_grader(user) do + {:ok, submissions} -> render(conn, "index.json", submissions: submissions) + {:error, {status, message}} -> send_resp(conn, status, message) + end + end + + def show(conn, %{"submissionid" => submission_id}) do + user = conn.assigns[:current_user] + + case Assessments.get_answers_in_submission(submission_id, user) do + {:ok, answers} -> render(conn, "show.json", answers: answers) + {:error, {status, message}} -> send_resp(conn, status, message) + end + end + + def update( + conn, + params = %{ + "submissionid" => submission_id, + "questionid" => question_id + } + ) do + user = conn.assigns[:current_user] + + case Assessments.update_grading_info( + %{submission_id: submission_id, question_id: question_id}, + params["grading"], + user + ) do + {:ok, _} -> send_resp(conn, :ok, "OK") + {:error, {status, message}} -> send_resp(conn, status, message) + end + end + swagger_path :index do get("/grading") @@ -12,10 +50,6 @@ defmodule CadetWeb.GradingController do produces("application/json") - parameters do - filter(:query, :string, "Filter only specific types e.g. done/pending") - end - response(200, "OK", Schema.ref(:Submissions)) response(401, "Unauthorised") end @@ -72,8 +106,30 @@ defmodule CadetWeb.GradingController do swagger_schema do properties do submissionId(:integer, "submission id", required: true) - missionId(:integer, "mission id", required: true) - studentId(:integer, "student id", required: true) + xp(:integer, "xp given") + adjustment(:integer, "adjustment given") + assessment(Schema.ref(:AssessmentInfo)) + student(Schema.ref(:StudentInfo)) + end + end, + AssessmentInfo: + swagger_schema do + properties do + id(:integer, "assessment id", required: true) + type(:string, "Either mission/sidequest/path/contest", required: true) + + max_xp( + :integer, + "The max amount of XP to be earned from this assessment", + required: true + ) + end + end, + StudentInfo: + swagger_schema do + properties do + id(:integer, "student id", required: true) + name(:string, "student name", required: true) end end, GradingInfo: @@ -97,8 +153,14 @@ defmodule CadetWeb.GradingController do Grade: swagger_schema do properties do - comment(:string, "comment given") - xp(:integer, "xp given") + grading( + Schema.new do + properties do + comment(:string, "comment given") + adjustment(:integer, "adjustment given") + end + end + ) end end } diff --git a/lib/cadet_web/views/grading_view.ex b/lib/cadet_web/views/grading_view.ex new file mode 100644 index 000000000..01ad63985 --- /dev/null +++ b/lib/cadet_web/views/grading_view.ex @@ -0,0 +1,46 @@ +defmodule CadetWeb.GradingView do + use CadetWeb, :view + + def render("index.json", %{submissions: submissions}) do + render_many(submissions, CadetWeb.GradingView, "submission.json", as: :submission) + end + + def render("show.json", %{answers: answers}) do + render_many(answers, CadetWeb.GradingView, "grading_info.json", as: :answer) + end + + def render("submission.json", %{submission: submission}) do + %{ + xp: submission.xp, + submissionId: submission.id, + student: %{ + name: submission.student.name, + id: submission.student.id + }, + assessment: %{ + type: submission.assessment.type, + max_xp: submission.assessment.max_xp, + id: submission.assessment.id + } + } + end + + def render("grading_info.json", %{answer: answer}) do + %{ + question: %{ + solution_template: answer.question.question["solution_template"], + questionType: answer.question.type, + questionId: answer.question.id, + library: answer.question.library, + content: answer.question.question["content"], + answer: answer.answer["code"] + }, + max_xp: answer.question.max_xp, + grade: %{ + xp: answer.xp, + adjustment: answer.adjustment, + comment: answer.comment + } + } + end +end diff --git a/priv/repo/migrations/20180526050817_create_questions.exs b/priv/repo/migrations/20180526050817_create_questions.exs index 8f87d50d5..542a479df 100644 --- a/priv/repo/migrations/20180526050817_create_questions.exs +++ b/priv/repo/migrations/20180526050817_create_questions.exs @@ -12,7 +12,7 @@ defmodule Cadet.Repo.Migrations.CreateQuestions do add(:title, :string) add(:library, :map) add(:question, :map, null: false) - add(:max_xp, :integer) + add(:max_xp, :integer, default: 0) add(:assessment_id, references(:assessments), null: false) timestamps() end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 513cb95c4..4f08abb39 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -12,24 +12,38 @@ import Cadet.Factory if Application.get_env(:cadet, :environment) == :dev do - seeded_users = [ - %{ - name: "TestStudent", - role: :student - }, - %{ - name: "TestStaff", - role: :staff - }, - %{ - name: "TestAdmin", - role: :admin - } - ] + # User and Group + avenger = insert(:user, %{name: "avenger", role: :staff}) + mentor = insert(:user, %{name: "mentor", role: :staff}) + group = insert(:group, %{leader: avenger, mentor: mentor}) + students = insert_list(5, :student, %{group: group}) + admin = insert(:user, %{name: "admin", role: :admin}) + Enum.each([avenger, mentor] ++ students, &insert(:nusnet_id, %{user: &1})) - Enum.each(seeded_users, fn attr -> - user = insert(:user, attr) + # Assessments + mission = insert(:assessment, %{title: "mission", type: :mission, is_published: true}) - insert(:nusnet_id, %{user: user}) - end) + questions = + insert_list(3, :question, %{ + type: :programming, + question: build(:programming_question), + assessment: mission, + max_xp: 200 + }) + + submissions = + students + |> Enum.take(2) + |> Enum.map(&insert(:submission, %{assessment: mission, student: &1})) + + # Answers + for submission <- submissions, + question <- questions do + insert(:answer, %{ + xp: 200, + question: question, + submission: submission, + answer: build(:programming_answer) + }) + end end diff --git a/test/cadet/accounts/accounts_test.exs b/test/cadet/accounts/accounts_test.exs index 29d80d9e4..654458edc 100644 --- a/test/cadet/accounts/accounts_test.exs +++ b/test/cadet/accounts/accounts_test.exs @@ -49,7 +49,7 @@ defmodule Cadet.AccountsTest do end test "get existing user" do - user = insert(:user, name: "Teddy") + user = insert(:user, name: "Teddy", role: :student) result = Accounts.get_user(user.id) assert %{name: "Teddy", role: :student} = result end diff --git a/test/cadet/assessments/query_test.exs b/test/cadet/assessments/query_test.exs new file mode 100644 index 000000000..62a555896 --- /dev/null +++ b/test/cadet/assessments/query_test.exs @@ -0,0 +1,66 @@ +defmodule Cadet.Assessments.QueryTest do + use Cadet.DataCase + + alias Cadet.Assessments.Query + + test "all_submissions_with_xp/1" do + submission = insert(:submission) + assessment = insert(:assessment) + questions = insert_list(5, :question, assessment: assessment) + + Enum.each(questions, &insert(:answer, submission: submission, xp: 200, question: &1)) + + result = + Query.all_submissions_with_xp() + |> where(id: ^submission.id) + |> Repo.one() + + submission_id = submission.id + + assert %{xp: 1000, id: ^submission_id} = result + end + + test "all_assessments_with_max_xp" do + assessment = insert(:assessment) + insert_list(5, :question, assessment: assessment, max_xp: 200) + + result = + Query.all_assessments_with_max_xp() + |> where(id: ^assessment.id) + |> Repo.one() + + assessment_id = assessment.id + + assert %{max_xp: 1000, id: ^assessment_id} = result + end + + test "submissions_xp" do + submission = insert(:submission) + assessment = insert(:assessment) + questions = insert_list(5, :question, assessment: assessment) + + Enum.each( + questions, + &insert(:answer, submission: submission, xp: 200, adjustment: -100, question: &1) + ) + + result = + Query.submissions_xp() + |> Repo.all() + |> Enum.find(&(&1.submission_id == submission.id)) + + assert result.xp == 500 + end + + test "assessments_max_xp" do + assessment = insert(:assessment) + insert_list(5, :question, assessment: assessment, max_xp: 200) + + result = + Query.assessments_max_xp() + |> Repo.all() + |> Enum.find(&(&1.assessment_id == assessment.id)) + + assert result.max_xp == 1000 + end +end diff --git a/test/cadet_web/controllers/grading_controller_test.exs b/test/cadet_web/controllers/grading_controller_test.exs index f588af8ce..7091535dc 100644 --- a/test/cadet_web/controllers/grading_controller_test.exs +++ b/test/cadet_web/controllers/grading_controller_test.exs @@ -2,6 +2,8 @@ defmodule CadetWeb.GradingControllerTest do use CadetWeb.ConnCase alias CadetWeb.GradingController + alias Cadet.Assessments.Answer + alias Cadet.Repo test "swagger" do GradingController.swagger_definitions() @@ -9,4 +11,263 @@ defmodule CadetWeb.GradingControllerTest do GradingController.swagger_path_show(nil) GradingController.swagger_path_update(nil) end + + describe "GET /, unauthenticated" do + test "unauthorized", %{conn: conn} do + conn = get(conn, build_url()) + assert response(conn, 401) =~ "Unauthorised" + end + end + + describe "GET /:submissionid, unauthenticated" do + test "unauthorized", %{conn: conn} do + conn = get(conn, build_url(1)) + assert response(conn, 401) =~ "Unauthorised" + end + end + + describe "POST /:submissionid/:questionid, unauthenticated" do + test "unauthorized", %{conn: conn} do + conn = post(conn, build_url(1, 3), %{}) + assert response(conn, 401) =~ "Unauthorised" + end + end + + describe "GET /, student" do + @tag authenticate: :student + test "unauthorized", %{conn: conn} do + conn = get(conn, build_url()) + assert response(conn, 401) =~ "User is not permitted to grade." + end + end + + describe "GET /:submissionid, student" do + @tag authenticate: :student + test "unauthorized", %{conn: conn} do + conn = get(conn, build_url(1)) + assert response(conn, 401) =~ "User is not permitted to grade." + end + end + + describe "POST /:submissionid/:questionid, student" do + @tag authenticate: :student + test "unauthorized", %{conn: conn} do + conn = post(conn, build_url(1, 3), %{}) + assert response(conn, 401) =~ "User is not permitted to grade." + end + end + + describe "GET /, staff" do + @tag authenticate: :staff + test "avenger gets his students submissions", %{conn: conn} do + %{ + mission: mission, + submissions: submissions + } = seed_db(conn) + + conn = get(conn, build_url()) + + expected = + Enum.map(submissions, fn submission -> + %{ + "xp" => 600, + "submissionId" => submission.id, + "student" => %{ + "name" => submission.student.name, + "id" => submission.student.id + }, + "assessment" => %{ + "type" => "mission", + "max_xp" => 600, + "id" => mission.id + } + } + end) + + assert ^expected = Enum.sort_by(json_response(conn, 200), & &1["submissionId"]) + end + + @tag authenticate: :staff + test "pure mentor gets an empty list", %{conn: conn} do + %{mentor: mentor} = seed_db(conn) + + conn = + conn + |> sign_in(mentor) + |> get(build_url()) + + assert json_response(conn, 200) == [] + end + end + + describe "GET /:submissionid, staff" do + @tag authenticate: :staff + test "successful", %{conn: conn} do + %{ + submissions: submissions, + answers: answers + } = seed_db(conn) + + submission = List.first(submissions) + + conn = get(conn, build_url(submission.id)) + + expected = + answers + |> Enum.filter(&(&1.submission.id == submission.id)) + |> Enum.map( + &%{ + "question" => %{ + "solution_template" => &1.question.question.solution_template, + "questionType" => "#{&1.question.type}", + "questionId" => &1.question.id, + "library" => &1.question.library, + "content" => &1.question.question.content, + "answer" => &1.answer.code + }, + "max_xp" => &1.question.max_xp, + "grade" => %{ + "xp" => &1.xp, + "adjustment" => &1.adjustment, + "comment" => &1.comment + } + } + ) + + assert ^expected = Enum.sort_by(json_response(conn, 200), & &1["question"]["questionId"]) + end + + @tag authenticate: :staff + test "pure mentor gets an empty list", %{conn: conn} do + %{mentor: mentor} = seed_db(conn) + + conn = + conn + |> sign_in(mentor) + |> get(build_url()) + + assert json_response(conn, 200) == [] + end + end + + describe "POST /:submissionid/:questionid, staff" do + @tag authenticate: :staff + test "successful", %{conn: conn} do + %{answers: answers} = seed_db(conn) + + answer = List.first(answers) + + conn = + post(conn, build_url(answer.submission.id, answer.question.id), %{ + "grading" => %{"adjustment" => -10, "comment" => "Never gonna give you up"} + }) + + assert response(conn, 200) == "OK" + assert %{adjustment: -10, comment: "Never gonna give you up"} = Repo.get(Answer, answer.id) + end + + @tag authenticate: :staff + test "invalid param fails", %{conn: conn} do + %{answers: answers} = seed_db(conn) + + answer = List.first(answers) + + conn = + post(conn, build_url(answer.submission.id, answer.question.id), %{ + "grading" => %{"adjustment" => -9_999_999_999} + }) + + assert response(conn, 400) == "adjustment should not make total point < 0" + end + + @tag authenticate: :staff + test "staff who isn't the grader of said answer fails", %{conn: conn} do + %{mentor: mentor, answers: answers} = seed_db(conn) + + answer = List.first(answers) + + conn = + conn + |> sign_in(mentor) + |> post(build_url(answer.submission.id, answer.question.id), %{ + "grading" => %{"adjustment" => -100} + }) + + assert response(conn, 400) == "Answer not found or user not permitted to grade." + end + end + + describe "GET /, admin" do + @tag authenticate: :admin + test "unauthorized", %{conn: conn} do + conn = get(conn, build_url()) + assert response(conn, 401) =~ "User is not permitted to grade." + end + end + + describe "GET /:submissionid, admin" do + @tag authenticate: :student + test "unauthorized", %{conn: conn} do + conn = get(conn, build_url(1)) + assert response(conn, 401) =~ "User is not permitted to grade." + end + end + + describe "POST /:submissionid/:questionid, admin" do + @tag authenticate: :admin + test "unauthorized", %{conn: conn} do + conn = post(conn, build_url(1, 3), %{}) + assert response(conn, 401) =~ "User is not permitted to grade." + end + end + + defp build_url, do: "/v1/grading/" + defp build_url(submission_id), do: "#{build_url()}#{submission_id}/" + defp build_url(submission_id, question_id), do: "#{build_url(submission_id)}#{question_id}" + + defp seed_db(conn) do + grader = conn.assigns[:current_user] + mentor = insert(:user, role: :staff) + + group = + insert(:group, %{leader_id: grader.id, leader: grader, mentor_id: mentor.id, mentor: mentor}) + + students = insert_list(5, :student, %{group: group}) + mission = insert(:assessment, %{title: "mission", type: :mission, is_published: true}) + + questions = + insert_list(3, :question, %{ + type: :programming, + question: build(:programming_question), + assessment: mission, + max_xp: 200 + }) + + submissions = + students + |> Enum.take(2) + |> Enum.map(&insert(:submission, %{assessment: mission, student: &1})) + + answers = + for submission <- submissions, + question <- questions do + insert(:answer, %{ + xp: 200, + question: question, + submission: submission, + answer: build(:programming_answer) + }) + end + + %{ + grader: grader, + mentor: mentor, + group: group, + students: students, + mission: mission, + questions: questions, + submissions: submissions, + answers: answers + } + end end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 6e227b18b..8cb0f6402 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -26,6 +26,11 @@ defmodule CadetWeb.ConnCase do # The default endpoint for testing @endpoint CadetWeb.Endpoint + + # Helper function + def sign_in(conn, user) do + CadetWeb.ConnCase.sign_in(conn, user) + end end end @@ -51,14 +56,17 @@ defmodule CadetWeb.ConnCase do nil end - conn = - conn - |> Cadet.Auth.Guardian.Plug.sign_in(user) - |> assign(:current_user, user) + conn = sign_in(conn, user) {:ok, conn: conn} else {:ok, conn: conn} end end + + def sign_in(conn, user) do + conn + |> Cadet.Auth.Guardian.Plug.sign_in(user) + |> assign(:current_user, user) + end end