Skip to content

Commit ddc0618

Browse files
authored
Add grading controller (#101)
* Add factories and seeds * Update api spec according to changes from Vignesh * Implement index * Hotfix tests * Implement show sans test * Add tests * Update context and model in preparation of implementing update * Fix duplicate factory * Implement update sans test * Add typespec and is_ecto_id macro guard clause * Add and refactor tests * Fix according to code review * Change to list comprehension for readability
1 parent e1d893e commit ddc0618

File tree

19 files changed

+701
-65
lines changed

19 files changed

+701
-65
lines changed

lib/cadet/accounts/query.ex

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ defmodule Cadet.Accounts.Query do
44
"""
55
import Ecto.Query
66

7-
alias Cadet.Accounts.Authorization
7+
alias Cadet.Accounts.{Authorization, User}
8+
alias Cadet.Course.Group
89

910
def user_nusnet_ids(user_id) do
1011
Authorization
@@ -18,6 +19,13 @@ defmodule Cadet.Accounts.Query do
1819
|> of_uid(uid)
1920
end
2021

22+
@spec students_of(User.t()) :: User.t()
23+
def students_of(%User{id: id, role: :staff}) do
24+
User
25+
|> join(:inner, [u], g in Group, u.group_id == g.id)
26+
|> where([_, g], g.leader_id == ^id)
27+
end
28+
2129
defp nusnet_ids(query) do
2230
query |> where([a], a.provider == "nusnet_id")
2331
end

lib/cadet/assessments/answer.ex

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ defmodule Cadet.Assessments.Answer do
1212
field(:xp, :integer, default: 0)
1313
field(:answer, :map)
1414
field(:type, QuestionType, virtual: true)
15-
field(:raw_answer, :string, virtual: true)
1615
field(:comment, :string)
1716
field(:adjustment, :integer, default: 0)
1817
belongs_to(:submission, Submission)
@@ -35,6 +34,24 @@ defmodule Cadet.Assessments.Answer do
3534
|> validate_answer_content()
3635
end
3736

37+
@spec grading_changeset(%__MODULE__{} | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
38+
def grading_changeset(answer, params) do
39+
answer
40+
|> cast(params, ~w(adjustment comment)a)
41+
|> validate_xp_adjustment_total()
42+
end
43+
44+
@spec validate_xp_adjustment_total(Ecto.Changeset.t()) :: Ecto.Changeset.t()
45+
defp validate_xp_adjustment_total(changeset) do
46+
answer = apply_changes(changeset)
47+
48+
if answer.xp + answer.adjustment >= 0 do
49+
changeset
50+
else
51+
add_error(changeset, :adjustment, "should not make total point < 0")
52+
end
53+
end
54+
3855
defp add_question_type_from_model(changeset, params) do
3956
with question when is_map(question) <- Map.get(params, :question),
4057
nil <- get_change(changeset, :type),

lib/cadet/assessments/assessment.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ defmodule Cadet.Assessments.Assessment do
1212
alias Cadet.Assessments.Upload
1313

1414
schema "assessments" do
15+
field(:max_xp, :integer, virtual: true)
1516
field(:title, :string)
1617
field(:is_published, :boolean, default: false)
1718
field(:type, AssessmentType)

lib/cadet/assessments/assessments.ex

Lines changed: 92 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ defmodule Cadet.Assessments do
1010
alias Timex.Duration
1111

1212
alias Cadet.Accounts.User
13-
alias Cadet.Assessments.{Answer, Assessment, Question, Submission}
13+
alias Cadet.Assessments.{Answer, Assessment, Query, Question, Submission}
1414

1515
@submit_answer_roles ~w(student)a
16+
@grading_roles ~w(staff)a
1617

1718
def all_assessments() do
1819
Repo.all(Assessment)
@@ -89,7 +90,7 @@ defmodule Cadet.Assessments do
8990
end
9091

9192
def create_question_for_assessment(params, assessment_id)
92-
when is_binary(assessment_id) or is_number(assessment_id) do
93+
when is_ecto_id(assessment_id) do
9394
assessment = get_assessment(assessment_id)
9495
create_question_for_assessment(params, assessment)
9596
end
@@ -151,6 +152,95 @@ defmodule Cadet.Assessments do
151152
end
152153
end
153154

155+
@spec all_submissions_by_grader(User.t()) ::
156+
{:ok, [Submission.t()]} | {:error, {:unauthorized, String.t()}}
157+
def all_submissions_by_grader(grader = %User{role: role}) do
158+
if role in @grading_roles do
159+
students = Cadet.Accounts.Query.students_of(grader)
160+
161+
submissions =
162+
Submission
163+
|> join(:inner, [s], x in subquery(Query.submissions_xp()), s.id == x.submission_id)
164+
|> join(:inner, [s], st in subquery(students), s.student_id == st.id)
165+
|> join(
166+
:inner,
167+
[s],
168+
a in subquery(Query.all_assessments_with_max_xp()),
169+
s.assessment_id == a.id
170+
)
171+
|> select([s, x, st, a], %Submission{s | xp: x.xp, student: st, assessment: a})
172+
|> Repo.all()
173+
174+
{:ok, submissions}
175+
else
176+
{:error, {:unauthorized, "User is not permitted to grade."}}
177+
end
178+
end
179+
180+
@spec get_answers_in_submission(integer() | String.t(), User.t()) ::
181+
{:ok, [Answer.t()]} | {:error, {:unauthorized, String.t()}}
182+
def get_answers_in_submission(id, grader = %User{role: role}) when is_ecto_id(id) do
183+
if role in @grading_roles do
184+
students = Cadet.Accounts.Query.students_of(grader)
185+
186+
answers =
187+
Answer
188+
|> where(submission_id: ^id)
189+
|> join(:inner, [a], s in Submission, a.submission_id == s.id)
190+
|> join(:inner, [a, s], t in subquery(students), t.id == s.student_id)
191+
|> join(:inner, [a], q in assoc(a, :question))
192+
|> preload([a, ..., q], question: q)
193+
|> Repo.all()
194+
195+
{:ok, answers}
196+
else
197+
{:error, {:unauthorized, "User is not permitted to grade."}}
198+
end
199+
end
200+
201+
@spec update_grading_info(
202+
%{submission_id: integer() | String.t(), question_id: integer() | String.t()},
203+
%{},
204+
User.t()
205+
) ::
206+
{:ok, nil}
207+
| {:error, {:unauthorized | :bad_request | :internal_server_error, String.t()}}
208+
def update_grading_info(
209+
%{submission_id: submission_id, question_id: question_id},
210+
attrs,
211+
grader = %User{role: role}
212+
)
213+
when is_ecto_id(submission_id) and is_ecto_id(question_id) do
214+
if role in @grading_roles do
215+
students = Cadet.Accounts.Query.students_of(grader)
216+
217+
answer =
218+
Answer
219+
|> where([a], a.submission_id == ^submission_id and a.question_id == ^question_id)
220+
|> join(:inner, [a], s in assoc(a, :submission))
221+
|> join(:inner, [a, s], t in subquery(students), t.id == s.student_id)
222+
|> Repo.one()
223+
224+
with {:answer_found?, true} <- {:answer_found?, is_map(answer)},
225+
{:valid, changeset = %Ecto.Changeset{valid?: true}} <-
226+
{:valid, Answer.grading_changeset(answer, attrs)},
227+
{:ok, _} <- Repo.update(changeset) do
228+
{:ok, nil}
229+
else
230+
{:answer_found?, false} ->
231+
{:error, {:bad_request, "Answer not found or user not permitted to grade."}}
232+
233+
{:valid, changeset} ->
234+
{:error, {:bad_request, full_error_messages(changeset.errors)}}
235+
236+
{:error, _} ->
237+
{:error, {:internal_server_error, "Please try again later."}}
238+
end
239+
else
240+
{:error, {:unauthorized, "User is not permitted to grade."}}
241+
end
242+
end
243+
154244
defp find_submission(user = %User{}, assessment = %Assessment{}) do
155245
submission =
156246
Submission
@@ -211,25 +301,4 @@ defmodule Cadet.Assessments do
211301
%{code: raw_answer}
212302
end
213303
end
214-
215-
# TODO: Decide what to do with these methods
216-
# def create_multiple_choice_question(json_attr) when is_binary(json_attr) do
217-
# %MCQQuestion{}
218-
# |> MCQQuestion.changeset(%{raw_mcqquestion: json_attr})
219-
# end
220-
221-
# def create_multiple_choice_question(attr = %{}) do
222-
# %MCQQuestion{}
223-
# |> MCQQuestion.changeset(attr)
224-
# end
225-
226-
# def create_programming_question(json_attr) when is_binary(json_attr) do
227-
# %ProgrammingQuestion{}
228-
# |> ProgrammingQuestion.changeset(%{raw_programmingquestion: json_attr})
229-
# end
230-
231-
# def create_programming_question(attr = %{}) do
232-
# %ProgrammingQuestion{}
233-
# |> ProgrammingQuestion.changeset(attr)
234-
# end
235304
end

lib/cadet/assessments/library.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@ defmodule Cadet.Assessments.Library do
55
use Cadet, :model
66

77
embedded_schema do
8-
field(:version, :integer, default: 1)
8+
field(:chapter, :integer, default: 1)
99
field(:globals, {:array, :string}, default: [])
1010
field(:externals, {:array, :string}, default: [])
1111
field(:files, {:array, :string}, default: [])
1212
end
1313

14-
@required_fields ~w(version)a
14+
@required_fields ~w(chapter)a
1515
@optional_fields ~w(globals externals files)a
1616

1717
def changeset(library, params \\ %{}) do

lib/cadet/assessments/query.ex

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
defmodule Cadet.Assessments.Query do
2+
@moduledoc """
3+
Generate queries related to the Assessments context
4+
"""
5+
import Ecto.Query
6+
7+
alias Cadet.Assessments.{Answer, Assessment, Question, Submission}
8+
9+
@spec all_submissions_with_xp :: Submission.t()
10+
def all_submissions_with_xp do
11+
Submission
12+
|> join(:inner, [s], q in subquery(submissions_xp()), s.id == q.submission_id)
13+
|> select([s, q], %Submission{s | xp: q.xp})
14+
end
15+
16+
@spec all_assessments_with_max_xp :: Assessment.t()
17+
def all_assessments_with_max_xp do
18+
Assessment
19+
|> join(:inner, [a], q in subquery(assessments_max_xp()), a.id == q.assessment_id)
20+
|> select([a, q], %Assessment{a | max_xp: q.max_xp})
21+
end
22+
23+
@spec submissions_xp :: %{submission_id: integer(), xp: integer()}
24+
def submissions_xp do
25+
Answer
26+
|> group_by(:submission_id)
27+
|> select([a], %{
28+
submission_id: a.submission_id,
29+
xp: fragment("? + ?", sum(a.xp), sum(a.adjustment))
30+
})
31+
end
32+
33+
@spec assessments_max_xp :: %{assessment_id: integer(), max_xp: integer()}
34+
def assessments_max_xp do
35+
Question
36+
|> group_by(:assessment_id)
37+
|> select([q], %{assessment_id: q.assessment_id, max_xp: sum(q.max_xp)})
38+
end
39+
end

lib/cadet/course/course.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ defmodule Cadet.Course do
77

88
alias Cadet.Accounts.User
99
alias Cadet.Course.Announcement
10-
alias Cadet.Course.Group
10+
# alias Cadet.Course.Group
1111
alias Cadet.Course.Material
1212
alias Cadet.Course.Upload
1313

lib/cadet/course/query.ex

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ defmodule Cadet.Course.Query do
44
"""
55
import Ecto.Query
66

7-
alias Cadet.Accounts.User
87
alias Cadet.Course.Material
98

109
def material_folder_files(folder_id) do

lib/cadet/factory.ex

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,19 @@ defmodule Cadet.Factory do
77
@dialyzer {:no_return, fields_for: 1}
88

99
alias Cadet.Accounts.{Authorization, User}
10-
alias Cadet.Course.{Announcement, Material}
11-
alias Cadet.Assessments.{Assessment, Question, Submission}
10+
alias Cadet.Assessments.{Answer, Assessment, Question, Submission}
11+
alias Cadet.Course.{Announcement, Group, Material}
1212

1313
def user_factory do
1414
%User{
1515
name: "John Smith",
16+
role: :staff
17+
}
18+
end
19+
20+
def student_factory do
21+
%User{
22+
name: sequence("student"),
1623
role: :student
1724
}
1825
end
@@ -25,6 +32,12 @@ defmodule Cadet.Factory do
2532
}
2633
end
2734

35+
def group_factory do
36+
%Group{
37+
name: sequence("group")
38+
}
39+
end
40+
2841
def announcement_factory do
2942
%Announcement{
3043
title: sequence(:title, &"Announcement #{&1}"),
@@ -78,10 +91,30 @@ defmodule Cadet.Factory do
7891

7992
def question_factory do
8093
%Question{
81-
title: "question",
94+
title: sequence("question"),
8295
question: %{},
8396
type: Enum.random([:programming, :multiple_choice]),
8497
assessment: build(:assessment, %{is_published: true})
8598
}
8699
end
100+
101+
def programming_question_factory do
102+
%{
103+
content: sequence("ProgrammingQuestion"),
104+
solution_template: "f => f(f);",
105+
solution: "(f => f(f))(f => f(f));"
106+
}
107+
end
108+
109+
def answer_factory do
110+
%Answer{
111+
answer: %{}
112+
}
113+
end
114+
115+
def programming_answer_factory do
116+
%{
117+
code: sequence(:code, &"alert(#{&1})")
118+
}
119+
end
87120
end

lib/cadet/helpers/context_helper.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,10 @@ defmodule Cadet.ContextHelper do
1717
Repo.update(changeset)
1818
end
1919
end
20+
21+
defmacro is_ecto_id(id) do
22+
quote do
23+
is_integer(unquote(id)) or is_binary(unquote(id))
24+
end
25+
end
2026
end

0 commit comments

Comments
 (0)