• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

source-academy / backend / e0330f2cf38b2d8af12bffd20f4cac2158d607fc-PR-1236

31 Mar 2025 09:12AM UTC coverage: 19.982% (-73.6%) from 93.607%
e0330f2cf38b2d8af12bffd20f4cac2158d607fc-PR-1236

Pull #1236

github

RichDom2185
Redate migrations to maintain total ordering
Pull Request #1236: Added Exam mode

12 of 57 new or added lines in 8 files covered. (21.05%)

2430 existing lines in 97 files now uncovered.

671 of 3358 relevant lines covered (19.98%)

3.03 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/lib/cadet/jobs/autograder/grading_job.ex
1
defmodule Cadet.Autograder.GradingJob do
2
  @moduledoc """
3
  This module contains logic for finding answers to be graded and
4
  processing/dispatching them as appropriate
5
  """
6
  use Cadet, :context
7

8
  import Ecto.Query
9

10
  require Logger
11

12
  alias Cadet.Accounts.{Team, TeamMember}
13
  alias Cadet.Assessments
14
  alias Cadet.Assessments.{Answer, Assessment, Question, Submission, SubmissionVotes}
15
  alias Cadet.Autograder.Utilities
16
  alias Cadet.Courses.AssessmentConfig
17
  alias Cadet.Jobs.Log
18

19
  def close_and_make_empty_submission(assessment = %Assessment{id: id}) do
20
    id
UNCOV
21
    |> Utilities.fetch_submissions(assessment.course_id)
×
UNCOV
22
    |> Enum.map(fn %{student_id: student_id, submission: submission} ->
×
UNCOV
23
      if submission do
×
UNCOV
24
        update_submission_status_to_submitted(submission)
×
25
      else
UNCOV
26
        insert_empty_submission(%{student_id: student_id, assessment: assessment})
×
27
      end
28
    end)
29
  end
30

31
  def grade_all_due_yesterday do
32
    # 1435 = 1 day - 5 minutes
UNCOV
33
    if Log.log_execution("grading_job", Timex.Duration.from_minutes(1435)) do
×
UNCOV
34
      Logger.info("Started autograding")
×
35

UNCOV
36
      for assessment <- Utilities.fetch_assessments_due_yesterday() do
×
37
        assessment
38
        |> close_and_make_empty_submission()
UNCOV
39
        |> Enum.each(&grade_individual_submission(&1, assessment))
×
40
      end
41
    else
UNCOV
42
      Logger.info("Not grading - raced")
×
43
    end
44
  end
45

46
  @doc """
47
  Exposed as public function in case future mix tasks are needed to regrade
48
  certain submissions. Manual grading can also be triggered from iex with this
49
  function.
50

51
  Takes in submission to be graded. Submission will be graded regardless of its
52
  assessment's close_by date or submission status.
53

54
  Every answer will be regraded regardless of its current autograding status.
55
  """
56
  def force_grade_individual_submission(submission = %Submission{}, overwrite \\ false) do
UNCOV
57
    assessment =
×
UNCOV
58
      if Ecto.assoc_loaded?(submission.assessment) do
×
UNCOV
59
        submission.assessment
×
60
      else
UNCOV
61
        submission |> Repo.preload(:assessment) |> Map.get(:assessment)
×
62
      end
63

UNCOV
64
    assessment = preprocess_assessment_for_grading(assessment)
×
UNCOV
65
    grade_individual_submission(submission, assessment, true, overwrite)
×
66
  end
67

68
  # This function requires that assessment questions are already preloaded in sorted
69
  # order for autograding to function correctly.
70
  defp grade_individual_submission(
71
         %Submission{id: submission_id},
72
         %Assessment{questions: questions},
UNCOV
73
         regrade \\ false,
×
74
         overwrite \\ false
75
       ) do
UNCOV
76
    answers =
×
77
      Answer
78
      |> where(submission_id: ^submission_id)
UNCOV
79
      |> order_by(:question_id)
×
80
      |> Repo.all()
81

UNCOV
82
    grade_submission_question_answer_lists(
×
83
      submission_id,
84
      questions,
85
      answers,
86
      regrade,
87
      overwrite
88
    )
89
  end
90

91
  defp preprocess_assessment_for_grading(assessment = %Assessment{}) do
UNCOV
92
    if Ecto.assoc_loaded?(assessment.questions) do
×
UNCOV
93
      Utilities.sort_assessment_questions(assessment)
×
94
    else
UNCOV
95
      questions =
×
96
        Question
UNCOV
97
        |> where(assessment_id: ^assessment.id)
×
UNCOV
98
        |> order_by(:id)
×
99
        |> Repo.all()
100

UNCOV
101
      Map.put(assessment, :questions, questions)
×
102
    end
103
  end
104

105
  defp insert_empty_submission(%{student_id: student_id, assessment: assessment}) do
UNCOV
106
    if Assessments.is_team_assessment?(assessment.id) do
×
107
      # Get current team if any
108
      team =
×
109
        Team
110
        |> where(assessment_id: ^assessment.id)
×
111
        |> join(:inner, [t], tm in assoc(t, :team_members))
112
        |> where([_, tm], tm.student_id == ^student_id)
×
113
        |> Repo.one()
114

115
      team =
×
116
        if team do
117
          team
×
118
        else
119
          # Student is not in any team
120
          # Create new team just for the student
121
          team =
×
122
            %Team{}
123
            |> Team.changeset(%{
124
              assessment_id: assessment.id
×
125
            })
126
            |> Repo.insert!()
127

128
          %TeamMember{}
129
          |> TeamMember.changeset(%{
130
            team_id: team.id,
×
131
            student_id: student_id
132
          })
133
          |> Repo.insert!()
×
134

135
          team
×
136
        end
137

138
      find_or_create_team_submission(team.id, assessment)
×
139
    else
140
      # Individual assessment
141
      %Submission{}
142
      |> Submission.changeset(%{
143
        student_id: student_id,
144
        assessment: assessment,
145
        status: :submitted
146
      })
UNCOV
147
      |> Repo.insert!()
×
148
    end
149
  end
150

151
  defp find_or_create_team_submission(team_id, assessment) when is_ecto_id(team_id) do
152
    submission =
×
153
      Submission
154
      |> where(team_id: ^team_id, assessment_id: ^assessment.id)
×
155
      |> Repo.one()
156

157
    if submission do
×
158
      submission
×
159
    else
160
      %Submission{}
161
      |> Submission.changeset(%{
162
        team_id: team_id,
163
        assessment: assessment,
164
        status: :submitted
165
      })
166
      |> Repo.insert!()
×
167
    end
168
  end
169

170
  defp update_submission_status_to_submitted(submission = %Submission{status: status}) do
UNCOV
171
    if status != :submitted do
×
UNCOV
172
      Cadet.Accounts.Notifications.write_notification_when_student_submits(submission)
×
173

174
      submission
175
      |> Submission.changeset(%{status: :submitted})
UNCOV
176
      |> Repo.update!()
×
177
    else
UNCOV
178
      submission
×
179
    end
180
  end
181

182
  def grade_answer(answer = %Answer{}, question = %Question{type: type}, overwrite \\ false) do
UNCOV
183
    case type do
×
184
      :programming ->
UNCOV
185
        Utilities.dispatch_programming_answer(answer, question, overwrite)
×
186

187
      :mcq ->
UNCOV
188
        grade_mcq_answer(answer, question)
×
189

190
      :voting ->
UNCOV
191
        grade_voting_answer(answer, question)
×
192
    end
193
  end
194

195
  defp grade_voting_answer(answer = %Answer{submission_id: submission_id}, question = %Question{}) do
UNCOV
196
    is_nil_entries =
×
197
      Submission
UNCOV
198
      |> where(id: ^submission_id)
×
199
      |> join(:inner, [s], sv in SubmissionVotes,
UNCOV
200
        on: sv.voter_id == s.student_id and sv.question_id == ^question.id
×
201
      )
UNCOV
202
      |> where([_, sv], is_nil(sv.score))
×
203
      |> Repo.exists?()
204

UNCOV
205
    xp = if is_nil_entries, do: 0, else: question.max_xp
×
206

207
    answer
208
    |> Answer.autograding_changeset(%{
209
      xp_adjustment: 0,
210
      xp: xp,
211
      autograding_status: :success
212
    })
UNCOV
213
    |> Repo.update()
×
214
  end
215

216
  defp grade_mcq_answer(answer = %Answer{}, question = %Question{question: question_content}) do
UNCOV
217
    correct_choice =
×
218
      question_content["choices"]
UNCOV
219
      |> Enum.find(&Map.get(&1, "is_correct"))
×
220
      |> Map.get("choice_id")
221

UNCOV
222
    correct? = answer.answer["choice_id"] == correct_choice
×
UNCOV
223
    xp = if correct?, do: question.max_xp, else: 0
×
224

225
    answer
226
    |> Answer.autograding_changeset(%{
227
      xp_adjustment: 0,
228
      xp: xp,
229
      autograding_status: :success
230
    })
UNCOV
231
    |> Repo.update()
×
232
  end
233

234
  defp insert_empty_answer(
235
         submission_id,
236
         %Question{id: question_id, type: question_type}
237
       )
238
       when is_ecto_id(submission_id) do
UNCOV
239
    answer_content =
×
240
      case question_type do
UNCOV
241
        :programming -> %{code: "// Question was left blank by the student."}
×
UNCOV
242
        :mcq -> %{choice_id: 0}
×
UNCOV
243
        :voting -> %{completed: false}
×
244
      end
245

246
    %Answer{}
247
    |> Answer.changeset(%{
248
      answer: answer_content,
249
      question_id: question_id,
250
      submission_id: submission_id,
251
      type: question_type
252
    })
253
    |> Answer.autograding_changeset(%{grade: 0, autograding_status: :success})
UNCOV
254
    |> Repo.insert()
×
255
  end
256

257
  # Two finger walk down question and answer lists.
258
  # Both lists MUST be pre-sorted by id and question_id respectively
259
  defp grade_submission_question_answer_lists(
260
         submission_id,
261
         questions,
262
         answers,
263
         regrade,
264
         overwrite
265
       )
266

267
  defp grade_submission_question_answer_lists(
268
         submission_id,
269
         [question = %Question{} | question_tail],
270
         answers = [answer = %Answer{} | answer_tail],
271
         regrade,
272
         overwrite
273
       )
274
       when is_boolean(regrade) and is_boolean(overwrite) and is_ecto_id(submission_id) do
UNCOV
275
    if question.id == answer.question_id do
×
UNCOV
276
      if regrade || answer.autograding_status in [:none, :failed] do
×
UNCOV
277
        grade_answer(answer, question, overwrite)
×
278
      end
279

UNCOV
280
      grade_submission_question_answer_lists(
×
281
        submission_id,
282
        question_tail,
283
        answer_tail,
284
        regrade,
285
        overwrite
286
      )
287
    else
UNCOV
288
      insert_empty_answer(submission_id, question)
×
289

UNCOV
290
      grade_submission_question_answer_lists(
×
291
        submission_id,
292
        question_tail,
293
        answers,
294
        regrade,
295
        overwrite
296
      )
297
    end
298
  end
299

300
  defp grade_submission_question_answer_lists(
301
         submission_id,
302
         [question = %Question{} | question_tail],
303
         [],
304
         regrade,
305
         overwrite
306
       )
307
       when is_boolean(regrade) and is_boolean(overwrite) and is_ecto_id(submission_id) do
UNCOV
308
    insert_empty_answer(submission_id, question)
×
309

UNCOV
310
    grade_submission_question_answer_lists(
×
311
      submission_id,
312
      question_tail,
313
      [],
314
      regrade,
315
      overwrite
316
    )
317
  end
318

319
  defp grade_submission_question_answer_lists(submission_id, [], [], _, _) do
UNCOV
320
    submission = Repo.get(Submission, submission_id)
×
UNCOV
321
    assessment = Repo.get(Assessment, submission.assessment_id)
×
UNCOV
322
    assessment_config = Repo.get_by(AssessmentConfig, id: assessment.config_id)
×
UNCOV
323
    is_grading_auto_published = assessment_config.is_grading_auto_published
×
UNCOV
324
    is_manually_graded = assessment_config.is_manually_graded
×
325

UNCOV
326
    if Assessments.is_fully_autograded?(submission_id) and is_grading_auto_published and
×
UNCOV
327
         not is_manually_graded do
×
UNCOV
328
      Assessments.publish_grading(submission_id)
×
329
    end
330
  end
331
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc