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

source-academy / backend / 9c2823cf899c9314fd4ccec9dd6c3b589d83e839

04 Dec 2025 05:47PM UTC coverage: 88.716% (-0.9%) from 89.621%
9c2823cf899c9314fd4ccec9dd6c3b589d83e839

push

github

web-flow
AI-powered marking (#1248)

* feat: v1 of AI-generated comments

* feat: added logging of inputs and outputs

* Update generate_ai_comments.ex

* feat: function to save outputs to database

* Format answers json before sending to LLM

* Add LLM Prompt to question params when submitting assessment xml file

* Add LLM Prompt to api response when grading view is open

* feat: added llm_prompt from qn to raw_prompt

* feat: enabling/disabling of LLM feature by course level

* feat: added llm_grading boolean field to course creation API

* feat: added api key storage in courses & edit api key/enable llm grading

* feat: encryption for llm_api_key

* feat: added final comment editing route

* feat: added logging of chosen comments

* fix: bugs when certain fields were missing

* feat: updated tests

* formatting

* fix: error handling when calling openai API

* fix: credo issues

* formatting

* Address some comments

* Fix formatting

* rm IO.inspect

* a

* Use case instead of if

* Streamlines generate_ai_comments to only send the selected question and its relevant info + use the correct llm_prompt

* Remove unncessary field

* default: false for llm_grading

* Add proper linking between ai_comments table and submissions. Return it to submission retrieval as well

* Resolve some migration comments

* Add llm_model and llm_api_url to the DB + schema

* Moves api key, api url, llm model and course prompt to course level

* Add encryption_key to env

* Do not hardcode formatting instructions

* Add Assessment level prompts to the XML

* Return some additional info for composing of prompts

* Remove un-used 'save comments'

* Fix existing assessment tests

* Fix generate_ai_comments test cases

* Fix bug preventing avengers from generating ai comments

* Fix up tests + error msgs

* Formatting

* some mix credo suggestions

* format

* Fix credo issue

* bug fix + credo fixes

* Fix tests

* format

* Modify test.exs

* Update lib/cadet_web/controllers/gener... (continued)

118 of 174 new or added lines in 9 files covered. (67.82%)

1 existing line in 1 file now uncovered.

3758 of 4236 relevant lines covered (88.72%)

7103.93 hits per line

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

89.84
/lib/cadet/assessments/assessments.ex
1
defmodule Cadet.Assessments do
2
  @moduledoc """
3
  Assessments context contains domain logic for assessments management such as
4
  missions, sidequests, paths, etc.
5
  """
6
  use Cadet, [:context, :display]
7
  alias Cadet.Incentives.{Achievement, Achievements, GoalProgress}
8
  import Ecto.Query
9

10
  require Logger
11

12
  alias Cadet.Accounts.{
13
    Notification,
14
    Notifications,
15
    User,
16
    Teams,
17
    Team,
18
    TeamMember,
19
    CourseRegistration,
20
    CourseRegistrations
21
  }
22

23
  alias Cadet.Assessments.{Answer, Assessment, Query, Question, Submission, SubmissionVotes}
24
  alias Cadet.Autograder.GradingJob
25
  alias Cadet.Courses.{Group, AssessmentConfig}
26
  alias Cadet.Jobs.Log
27
  alias Cadet.ProgramAnalysis.Lexer
28
  alias Ecto.{Multi, Changeset}
29
  alias Timex.Duration
30

31
  require Decimal
32

33
  @open_all_assessment_roles ~w(staff admin)a
34

35
  # These roles can save and finalise answers for closed assessments and
36
  # submitted answers
37
  @bypass_closed_roles ~w(staff admin)a
38

39
  def delete_assessment(id) do
40
    Logger.info("Attempting to delete assessment #{id}")
6✔
41
    assessment = Repo.get(Assessment, id)
6✔
42

43
    is_voted_on =
6✔
44
      Question
45
      |> where(type: :voting)
6✔
46
      |> join(:inner, [q], asst in assoc(q, :assessment))
47
      |> where(
6✔
48
        [q, asst],
49
        q.question["contest_number"] == ^assessment.number and
6✔
50
          asst.course_id == ^assessment.course_id
6✔
51
      )
52
      |> Repo.exists?()
53

54
    if is_voted_on do
6✔
55
      Logger.error("Cannot delete assessment #{id} - contest voting is still active")
1✔
56
      {:error, {:bad_request, "Contest voting for this contest is still up"}}
57
    else
58
      Logger.info("Deleting submissions associated with assessment #{id}")
5✔
59

60
      Submission
61
      |> where(assessment_id: ^id)
5✔
62
      |> delete_submission_association(id)
5✔
63

64
      Logger.info("Deleting questions associated with assessment #{id}")
5✔
65

66
      Question
67
      |> where(assessment_id: ^id)
5✔
68
      |> Repo.all()
69
      |> Enum.each(fn q ->
5✔
70
        delete_submission_votes_association(q)
2✔
71
      end)
72

73
      Logger.info("Deleting assessment #{id}")
5✔
74
      result = Repo.delete(assessment)
5✔
75

76
      case result do
5✔
77
        {:ok, _} ->
78
          Logger.info("Successfully deleted assessment #{id}")
5✔
79

80
        {:error, changeset} ->
81
          Logger.error("Failed to delete assessment #{id}: #{full_error_messages(changeset)}")
×
82
      end
83

84
      result
5✔
85
    end
86
  end
87

88
  defp delete_submission_votes_association(question) do
89
    Logger.info("Deleting submission votes for question #{question.id}")
3✔
90

91
    SubmissionVotes
92
    |> where(question_id: ^question.id)
3✔
93
    |> Repo.delete_all()
3✔
94
  end
95

96
  defp delete_submission_association(submissions, assessment_id) do
97
    Logger.info("Deleting answers for submissions associated with assessment #{assessment_id}")
6✔
98

99
    submissions
100
    |> Repo.all()
101
    |> Enum.each(fn submission ->
6✔
102
      Answer
103
      |> where(submission_id: ^submission.id)
1✔
104
      |> Repo.delete_all()
1✔
105
    end)
106

107
    Logger.info("Deleting notifications for assessment #{assessment_id}")
6✔
108

109
    Notification
110
    |> where(assessment_id: ^assessment_id)
6✔
111
    |> Repo.delete_all()
6✔
112

113
    Logger.info("Deleting submissions for assessment #{assessment_id}")
6✔
114
    Repo.delete_all(submissions)
6✔
115
  end
116

117
  @spec user_max_xp(CourseRegistration.t()) :: integer()
118
  def user_max_xp(cr = %CourseRegistration{id: cr_id}) do
119
    Logger.info("Calculating maximum XP for user #{cr.user_id} in course #{cr.course_id}")
2✔
120

121
    result =
2✔
122
      Submission
123
      |> where(status: ^:submitted)
124
      |> where(student_id: ^cr_id)
2✔
125
      |> join(
126
        :inner,
127
        [s],
128
        a in subquery(Query.all_assessments_with_max_xp()),
129
        on: s.assessment_id == a.id
130
      )
131
      |> select([_, a], sum(a.max_xp))
2✔
132
      |> Repo.one()
133
      |> decimal_to_integer()
134

135
    Logger.info("Calculated maximum XP for user #{cr.user_id}: #{result}")
2✔
136
    result
2✔
137
  end
138

139
  def assessments_total_xp(%CourseRegistration{id: cr_id}) do
140
    Logger.info("Calculating total XP for assessments for user #{cr_id}")
17✔
141
    teams = find_teams(cr_id)
17✔
142
    submission_ids = get_submission_ids(cr_id, teams)
17✔
143

144
    Logger.info("Fetching XP for submissions")
17✔
145

146
    submission_xp =
17✔
147
      Submission
148
      |> where(
149
        [s],
150
        s.id in subquery(submission_ids)
151
      )
152
      |> where(is_grading_published: true)
17✔
153
      |> join(:inner, [s], a in Answer, on: s.id == a.submission_id)
154
      |> group_by([s], s.id)
155
      |> select([s, a], %{
17✔
156
        # grouping by submission, so s.xp_bonus will be the same, but we need an
157
        # aggregate function
158
        total_xp: sum(a.xp) + sum(a.xp_adjustment) + max(s.xp_bonus)
159
      })
160

161
    total =
17✔
162
      submission_xp
163
      |> subquery
164
      |> select([s], %{
17✔
165
        total_xp: sum(s.total_xp)
166
      })
167
      |> Repo.one()
168

169
    Logger.info("Total XP calculated: #{decimal_to_integer(total.total_xp)}")
17✔
170
    # for {key, val} <- total, into: %{}, do: {key, decimal_to_integer(val)}
171
    decimal_to_integer(total.total_xp)
17✔
172
  end
173

174
  def user_total_xp(course_id, user_id, course_reg_id) do
175
    Logger.info("Calculating total XP for user #{user_id} in course #{course_id}")
14✔
176
    user_course = CourseRegistrations.get_user_course(user_id, course_id)
14✔
177

178
    total_achievement_xp = Achievements.achievements_total_xp(course_id, course_reg_id)
14✔
179
    total_assessment_xp = assessments_total_xp(user_course)
14✔
180

181
    Logger.info("Total XP for user #{user_id}: #{total_achievement_xp + total_assessment_xp}")
14✔
182
    total_achievement_xp + total_assessment_xp
14✔
183
  end
184

185
  def all_user_total_xp(course_id, options \\ %{}) do
186
    Logger.info("Fetching total XP for all users in course #{course_id}")
56✔
187

188
    include_admin_staff_users = fn q ->
56✔
189
      if options[:include_admin_staff],
56✔
190
        do: q,
×
191
        else: where(q, [_, cr], cr.role == "student")
56✔
192
    end
193

194
    # get all users even if they have 0 xp
195
    course_userid_query =
56✔
196
      User
197
      |> join(:inner, [u], cr in CourseRegistration, on: cr.user_id == u.id)
198
      |> where([_, cr], cr.course_id == ^course_id)
56✔
199
      |> include_admin_staff_users.()
200
      |> select([u, cr], %{
56✔
201
        id: u.id,
202
        cr_id: cr.id
203
      })
204

205
    achievements_xp_query =
56✔
206
      from(u in User,
56✔
207
        join: cr in CourseRegistration,
208
        on: cr.user_id == u.id and cr.course_id == ^course_id,
209
        left_join: a in Achievement,
210
        on: a.course_id == cr.course_id,
211
        left_join: j in assoc(a, :goals),
212
        left_join: g in assoc(j, :goal),
213
        left_join: p in GoalProgress,
214
        on: p.goal_uuid == g.uuid and p.course_reg_id == cr.id,
215
        where:
216
          a.course_id == ^course_id and p.completed and
217
            p.count == g.target_count,
218
        group_by: [u.id, u.name, u.username, cr.id],
219
        select: %{
220
          user_id: u.id,
221
          achievements_xp:
222
            fragment(
223
              "CASE WHEN bool_and(?) THEN ? ELSE ? END",
224
              a.is_variable_xp,
225
              sum(p.count),
226
              max(a.xp)
227
            )
228
        }
229
      )
230

231
    submissions_xp_query =
56✔
232
      course_userid_query
233
      |> subquery()
234
      |> join(:left, [u], tm in TeamMember, on: tm.student_id == u.cr_id)
56✔
235
      |> join(:left, [u, tm], s in Submission, on: s.student_id == u.cr_id or s.team_id == tm.id)
56✔
236
      |> join(:left, [u, tm, s], a in Answer, on: s.id == a.submission_id)
237
      |> where([_, _, s, _], s.is_grading_published == true)
238
      |> group_by([u, _, s, _], [u.id, s.id])
239
      |> select([u, _, s, a], %{
56✔
240
        user_id: u.id,
241
        submission_xp: sum(a.xp) + sum(a.xp_adjustment) + max(s.xp_bonus)
242
      })
243
      |> subquery()
244
      |> group_by([t], t.user_id)
245
      |> select([t], %{
56✔
246
        user_id: t.user_id,
247
        submission_xp: sum(t.submission_xp)
248
      })
249

250
    total_xp_query =
56✔
251
      course_userid_query
252
      |> subquery()
253
      |> join(:inner, [cu], u in User, on: cu.id == u.id)
56✔
254
      |> join(:left, [cu, _], ax in subquery(achievements_xp_query), on: cu.id == ax.user_id)
56✔
255
      |> join(:left, [cu, _, _], sx in subquery(submissions_xp_query), on: cu.id == sx.user_id)
256
      |> select([_, u, ax, sx], %{
257
        user_id: u.id,
258
        name: u.name,
259
        username: u.username,
260
        total_xp:
261
          fragment(
262
            "COALESCE(?, 0) + COALESCE(?, 0)",
263
            ax.achievements_xp,
264
            sx.submission_xp
265
          )
266
      })
267
      |> order_by(desc: fragment("total_xp"))
56✔
268

269
    # add rank index
270
    ranked_xp_query =
56✔
271
      from(t in subquery(total_xp_query),
56✔
272
        select_merge: %{
273
          rank: fragment("RANK() OVER (ORDER BY total_xp DESC)")
274
        },
275
        limit: ^options[:limit],
276
        offset: ^options[:offset]
277
      )
278

279
    count_query =
56✔
280
      total_xp_query
281
      |> subquery()
282
      |> select([t], count(t.user_id))
56✔
283

284
    {status, {rows, total_count}} =
56✔
285
      Repo.transaction(fn ->
286
        users =
56✔
287
          Enum.map(Repo.all(ranked_xp_query), fn user ->
288
            %{user | total_xp: Decimal.to_integer(user.total_xp)}
1,043✔
289
          end)
290

291
        count = Repo.one(count_query)
56✔
292
        {users, count}
293
      end)
294

295
    Logger.info("Successfully fetched total XP for #{total_count} users")
56✔
296

297
    %{
56✔
298
      users: rows,
299
      total_count: total_count
300
    }
301
  end
302

303
  defp decimal_to_integer(decimal) do
304
    if Decimal.is_decimal(decimal) do
19✔
305
      Decimal.to_integer(decimal)
17✔
306
    else
307
      0
308
    end
309
  end
310

311
  def user_current_story(cr = %CourseRegistration{}) do
312
    {:ok, %{result: story}} =
2✔
313
      Multi.new()
314
      |> Multi.run(:unattempted, fn _repo, _ ->
2✔
315
        {:ok, get_user_story_by_type(cr, :unattempted)}
316
      end)
317
      |> Multi.run(:result, fn _repo, %{unattempted: unattempted_story} ->
318
        if unattempted_story do
2✔
319
          {:ok, %{play_story?: true, story: unattempted_story}}
320
        else
321
          {:ok, %{play_story?: false, story: get_user_story_by_type(cr, :attempted)}}
322
        end
323
      end)
324
      |> Repo.transaction()
325

326
    story
2✔
327
  end
328

329
  @spec get_user_story_by_type(CourseRegistration.t(), :unattempted | :attempted) ::
330
          String.t() | nil
331
  def get_user_story_by_type(%CourseRegistration{id: cr_id}, type)
332
      when is_atom(type) do
333
    filter_and_sort = fn query ->
2✔
334
      case type do
2✔
335
        :unattempted ->
336
          query
337
          |> where([_, s], is_nil(s.id))
338
          |> order_by([a], asc: a.open_at)
2✔
339

340
        :attempted ->
341
          query |> order_by([a], desc: a.close_at)
×
342
      end
343
    end
344

345
    Assessment
346
    |> where(is_published: true)
347
    |> where([a], not is_nil(a.story))
348
    |> where([a], a.open_at <= from_now(0, "second") and a.close_at >= from_now(0, "second"))
2✔
349
    |> join(:left, [a], s in Submission, on: s.assessment_id == a.id and s.student_id == ^cr_id)
2✔
350
    |> filter_and_sort.()
351
    |> order_by([a], a.config_id)
352
    |> select([a], a.story)
2✔
353
    |> first()
354
    |> Repo.one()
2✔
355
  end
356

357
  def assessment_with_questions_and_answers(
358
        assessment = %Assessment{password: nil},
359
        cr = %CourseRegistration{},
360
        nil
361
      ) do
362
    assessment_with_questions_and_answers(assessment, cr)
87✔
363
  end
364

365
  def assessment_with_questions_and_answers(
366
        assessment = %Assessment{password: nil},
367
        cr = %CourseRegistration{},
368
        _
369
      ) do
370
    assessment_with_questions_and_answers(assessment, cr)
3✔
371
  end
372

373
  def assessment_with_questions_and_answers(
374
        assessment = %Assessment{password: password},
375
        cr = %CourseRegistration{},
376
        given_password
377
      ) do
378
    cond do
11✔
379
      Timex.compare(Timex.now(), assessment.close_at) >= 0 ->
11✔
380
        assessment_with_questions_and_answers(assessment, cr)
1✔
381

382
      match?({:ok, _}, find_submission(cr, assessment)) ->
10✔
383
        assessment_with_questions_and_answers(assessment, cr)
1✔
384

385
      given_password == nil ->
9✔
386
        {:error, {:forbidden, "Missing Password."}}
387

388
      password == given_password ->
6✔
389
        find_or_create_submission(cr, assessment)
3✔
390
        assessment_with_questions_and_answers(assessment, cr)
3✔
391

392
      true ->
3✔
393
        {:error, {:forbidden, "Invalid Password."}}
394
    end
395
  end
396

397
  def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}, password)
398
      when is_ecto_id(id) do
399
    Logger.info(
101✔
400
      "Fetching assessment #{id} with questions and answers for user #{cr.user_id} in course #{cr.course_id}"
×
401
    )
402

403
    role = cr.role
101✔
404

405
    assessment =
101✔
406
      if role in @open_all_assessment_roles do
407
        Assessment
408
        |> where(id: ^id)
409
        |> preload(:config)
66✔
410
        |> Repo.one()
66✔
411
      else
412
        Assessment
413
        |> where(id: ^id)
414
        |> where(is_published: true)
415
        |> preload(:config)
35✔
416
        |> Repo.one()
35✔
417
      end
418

419
    if assessment do
101✔
420
      result = assessment_with_questions_and_answers(assessment, cr, password)
100✔
421

422
      case result do
100✔
423
        {:ok, _} ->
424
          Logger.info("Successfully retrieved assessment #{id} for user #{cr.user_id}")
93✔
425

426
        {:error, {status, message}} ->
427
          Logger.error(
7✔
428
            "Failed to retrieve assessment #{id} for user #{cr.user_id}: #{status} - #{message}"
7✔
429
          )
430
      end
431

432
      result
100✔
433
    else
434
      Logger.error("Assessment #{id} not found or not published for user #{cr.user_id}")
1✔
435
      {:error, {:bad_request, "Assessment not found"}}
436
    end
437
  end
438

439
  def assessment_with_questions_and_answers(
440
        assessment = %Assessment{id: id},
441
        course_reg = %CourseRegistration{role: role, id: user_id}
442
      ) do
443
    Logger.info(
99✔
444
      "Fetching assessment with questions and answers for assessment #{id} and user #{user_id}"
×
445
    )
446

447
    team_id =
99✔
448
      case find_team(id, user_id) do
449
        {:ok, nil} ->
450
          Logger.info("No team found for user #{user_id} in assessment #{id}")
96✔
451
          -1
452

453
        {:ok, team} ->
454
          Logger.info("Team found for user #{user_id} in assessment #{id}: Team ID #{team.id}")
1✔
455

456
          team.id
1✔
457

458
        {:error, :team_not_found} ->
459
          Logger.error("Team not found for user #{user_id} in assessment #{id}")
2✔
460
          -1
461
      end
462

463
    if Timex.compare(Timex.now(), assessment.open_at) >= 0 or role in @open_all_assessment_roles do
99✔
464
      Logger.info("Assessment #{id} is open or user #{user_id} has access")
98✔
465

466
      answer_query =
98✔
467
        Answer
468
        |> join(:inner, [a], s in assoc(a, :submission))
469
        |> where([_, s], s.student_id == ^course_reg.id or s.team_id == ^team_id)
98✔
470

471
      visible_entries =
98✔
472
        Assessment
473
        |> join(:inner, [a], c in assoc(a, :course))
474
        |> where([a, c], a.id == ^id)
475
        |> select([a, c], c.top_contest_leaderboard_display)
98✔
476
        |> Repo.one()
477

478
      Logger.debug("Visible entries for assessment #{id}: #{visible_entries}")
98✔
479

480
      questions =
98✔
481
        Question
482
        |> where(assessment_id: ^id)
98✔
483
        |> join(:left, [q], a in subquery(answer_query), on: q.id == a.question_id)
98✔
484
        |> join(:left, [_, a], g in assoc(a, :grader))
98✔
485
        |> join(:left, [_, _, g], u in assoc(g, :user))
486
        |> select([q, a, g, u], {q, a, g, u})
487
        |> order_by(:display_order)
98✔
488
        |> Repo.all()
489
        |> Enum.map(fn
490
          {q, nil, _, _} -> %{q | answer: %Answer{grader: nil}}
540✔
491
          {q, a, nil, _} -> %{q | answer: %Answer{a | grader: nil}}
216✔
492
          {q, a, g, u} -> %{q | answer: %Answer{a | grader: %CourseRegistration{g | user: u}}}
47✔
493
        end)
494
        |> load_contest_voting_entries(course_reg, assessment, visible_entries)
495

496
      Logger.debug("Questions loaded for assessment #{id}")
98✔
497

498
      is_grading_published =
98✔
499
        Submission
500
        |> where(assessment_id: ^id)
501
        |> where([s], s.student_id == ^course_reg.id or s.team_id == ^team_id)
98✔
502
        |> select([s], s.is_grading_published)
98✔
503
        |> Repo.one()
504

505
      Logger.debug("Grading published status for assessment #{id}: #{is_grading_published}")
98✔
506

507
      assessment =
98✔
508
        assessment
509
        |> Map.put(:questions, questions)
510
        |> Map.put(:is_grading_published, is_grading_published)
511

512
      Logger.info(
98✔
513
        "Successfully fetched assessment #{id} with questions and answers for user #{course_reg.id}"
×
514
      )
515

516
      {:ok, assessment}
517
    else
518
      Logger.error("Assessment #{id} is not open for user #{course_reg.id}")
1✔
519
      {:error, {:forbidden, "Assessment not open"}}
520
    end
521
  end
522

523
  def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}) do
524
    assessment_with_questions_and_answers(id, cr, nil)
92✔
525
  end
526

527
  @doc """
528
  Returns a list of assessments with all fields and an indicator showing whether it has been attempted
529
  by the supplied user
530
  """
531
  def all_assessments(cr = %CourseRegistration{}) do
532
    Logger.info("Retrieving all assessments for user #{cr.user_id} in course #{cr.course_id}")
18✔
533

534
    teams = find_teams(cr.id)
18✔
535
    Logger.debug("Found teams for user #{cr.user_id}: #{inspect(teams)}")
18✔
536

537
    submission_ids = get_submission_ids(cr.id, teams)
18✔
538
    Logger.debug("Submission IDs for user #{cr.user_id}: #{inspect(submission_ids)}")
18✔
539

540
    submission_aggregates =
18✔
541
      Submission
542
      |> join(:left, [s], ans in Answer, on: ans.submission_id == s.id)
543
      |> where(
544
        [s],
545
        s.id in subquery(submission_ids)
546
      )
547
      |> group_by([s], s.assessment_id)
548
      |> select([s, ans], %{
18✔
549
        assessment_id: s.assessment_id,
550
        # s.xp_bonus should be the same across the group, but we need an aggregate function here
551
        xp: fragment("? + ? + ?", sum(ans.xp), sum(ans.xp_adjustment), max(s.xp_bonus)),
552
        graded_count: ans.id |> count() |> filter(not is_nil(ans.grader_id))
553
      })
554

555
    Logger.debug("Submission aggregates query prepared for user #{cr.user_id}")
18✔
556

557
    submission_status =
18✔
558
      Submission
559
      |> where(
560
        [s],
561
        s.id in subquery(submission_ids)
562
      )
563
      |> select([s], [:assessment_id, :status, :is_grading_published])
18✔
564

565
    Logger.debug("Submission status query prepared for user #{cr.user_id}")
18✔
566

567
    assessments =
18✔
568
      cr.course_id
18✔
569
      |> Query.all_assessments_with_aggregates()
570
      |> subquery()
571
      |> join(
18✔
572
        :left,
573
        [a],
574
        sa in subquery(submission_aggregates),
575
        on: a.id == sa.assessment_id
576
      )
577
      |> join(:left, [a, _], s in subquery(submission_status), on: a.id == s.assessment_id)
578
      |> select([a, sa, s], %{
18✔
579
        a
580
        | xp: sa.xp,
581
          graded_count: sa.graded_count,
582
          user_status: s.status,
583
          is_grading_published: s.is_grading_published
584
      })
585
      |> filter_published_assessments(cr)
586
      |> order_by(:open_at)
587
      |> preload(:config)
18✔
588
      |> Repo.all()
589

590
    Logger.info(
18✔
591
      "Successfully retrieved #{length(assessments)} assessments for user #{cr.user_id}"
×
592
    )
593

594
    {:ok, assessments}
595
  end
596

597
  defp get_submission_ids(cr_id, teams) do
598
    Logger.debug("Fetching submission IDs for user #{cr_id} and teams #{inspect(teams)}")
35✔
599

600
    from(s in Submission,
35✔
601
      where: s.student_id == ^cr_id or s.team_id in ^Enum.map(teams, & &1.id),
1✔
602
      select: s.id
603
    )
604
  end
605

606
  defp is_voting_assigned(assessment_ids) do
607
    Logger.debug("Checking if voting is assigned for assessment IDs: #{inspect(assessment_ids)}")
18✔
608

609
    voting_assigned_question_ids =
18✔
610
      SubmissionVotes
611
      |> select([v], v.question_id)
18✔
612
      |> Repo.all()
613

614
    Logger.debug("Voting assigned question IDs: #{inspect(voting_assigned_question_ids)}")
18✔
615

616
    # Map of assessment_id to boolean
617
    voting_assigned_assessment_ids =
18✔
618
      Question
619
      |> where(type: :voting)
620
      |> where([q], q.id in ^voting_assigned_question_ids)
621
      |> where([q], q.assessment_id in ^assessment_ids)
622
      |> select([q], q.assessment_id)
623
      |> distinct(true)
18✔
624
      |> Repo.all()
625

626
    Logger.debug("Voting assigned assessment IDs: #{inspect(voting_assigned_assessment_ids)}")
18✔
627

628
    Enum.reduce(assessment_ids, %{}, fn id, acc ->
18✔
629
      Map.put(acc, id, Enum.member?(voting_assigned_assessment_ids, id))
92✔
630
    end)
631
  end
632

633
  @doc """
634
  A helper function which removes grading information from all assessments
635
  if it's grading is not published.
636
  """
637
  def format_all_assessments(assessments) do
638
    is_voting_assigned_map =
18✔
639
      assessments
640
      |> Enum.map(& &1.id)
92✔
641
      |> is_voting_assigned()
642

643
    Enum.map(assessments, fn a ->
18✔
644
      a = Map.put(a, :is_voting_published, Map.get(is_voting_assigned_map, a.id, false))
92✔
645

646
      if a.is_grading_published do
92✔
647
        a
8✔
648
      else
649
        a
650
        |> Map.put(:xp, 0)
651
        |> Map.put(:graded_count, 0)
84✔
652
      end
653
    end)
654
  end
655

656
  @doc """
657
  A helper function which removes grading information from the assessment
658
  if it's grading is not published.
659
  """
660
  def format_assessment_with_questions_and_answers(assessment) do
661
    if assessment.is_grading_published do
89✔
662
      assessment
6✔
663
    else
664
      %{
665
        assessment
666
        | questions:
83✔
667
            Enum.map(assessment.questions, fn q ->
83✔
668
              %{
669
                q
670
                | answer: %{
730✔
671
                    q.answer
730✔
672
                    | xp: 0,
673
                      xp_adjustment: 0,
674
                      autograding_status: :none,
675
                      autograding_results: [],
676
                      grader: nil,
677
                      grader_id: nil,
678
                      comments: nil
679
                  }
680
              }
681
            end)
682
      }
683
    end
684
  end
685

686
  def filter_published_assessments(assessments, cr) do
687
    role = cr.role
18✔
688

689
    case role do
18✔
690
      :student ->
691
        Logger.debug("Filtering assessments for student role")
11✔
692
        where(assessments, is_published: true)
11✔
693

694
      _ ->
695
        Logger.debug("No filtering applied for role #{role}")
7✔
696
        assessments
7✔
697
    end
698
  end
699

700
  def create_assessment(params) do
701
    %Assessment{}
702
    |> Assessment.changeset(params)
703
    |> Repo.insert()
1✔
704
  end
705

706
  @doc """
707
  The main function that inserts or updates assessments from the XML Parser
708
  """
709
  @spec insert_or_update_assessments_and_questions(map(), [map()], boolean()) ::
710
          {:ok, any()}
711
          | {:error, Ecto.Multi.name(), any(), %{optional(Ecto.Multi.name()) => any()}}
712
  def insert_or_update_assessments_and_questions(
713
        assessment_params,
714
        questions_params,
715
        force_update
716
      ) do
717
    Logger.info(
27✔
718
      "Starting insert_or_update_assessments_and_questions with force_update: #{force_update}"
×
719
    )
720

721
    assessment_multi =
27✔
722
      Multi.insert_or_update(
723
        Multi.new(),
724
        :assessment,
725
        insert_or_update_assessment_changeset(assessment_params, force_update)
726
      )
727

728
    if force_update and invalid_force_update(assessment_multi, questions_params) do
27✔
729
      Logger.error("Force update failed: Question count is different")
1✔
730
      {:error, "Question count is different"}
731
    else
732
      Logger.info("Processing questions for assessment")
26✔
733

734
      questions_params
735
      |> Enum.with_index(1)
736
      |> Enum.reduce(assessment_multi, fn {question_params, index}, multi ->
737
        Multi.run(multi, "question#{index}", fn _repo, %{assessment: %Assessment{id: id}} ->
78✔
738
          Logger.debug("Processing question #{index} for assessment #{id}")
53✔
739

740
          question =
53✔
741
            Question
742
            |> where([q], q.display_order == ^index and q.assessment_id == ^id)
53✔
743
            |> Repo.one()
744

745
          # the is_nil(question) check allows for force updating of brand new assessments
746
          if !force_update or is_nil(question) do
53✔
747
            Logger.info("Inserting new question at display_order #{index}")
53✔
748

749
            {status, new_question} =
53✔
750
              question_params
751
              |> Map.put(:display_order, index)
752
              |> build_question_changeset_for_assessment_id(id)
753
              |> Repo.insert()
754

755
            if status == :ok and new_question.type == :voting do
53✔
756
              Logger.info("Inserting voting entries for question #{new_question.id}")
16✔
757

758
              insert_voting(
16✔
759
                assessment_params.course_id,
16✔
760
                question_params.question.contest_number,
16✔
761
                new_question.id
16✔
762
              )
763
            else
764
              {status, new_question}
765
            end
766
          else
767
            Logger.info("Updating existing question at display_order #{index}")
×
768

769
            params =
×
770
              question_params
771
              |> Map.put_new(:max_xp, 0)
772
              |> Map.put(:display_order, index)
773

774
            if question_params.type != Atom.to_string(question.type) do
×
775
              Logger.error("Question type mismatch for question #{question.id}")
×
776

777
              {:error,
778
               create_invalid_changeset_with_error(
779
                 :question,
780
                 "Question types should remain the same"
781
               )}
782
            else
783
              question
784
              |> Question.changeset(params)
785
              |> Repo.update()
×
786
            end
787
          end
788
        end)
789
      end)
790
      |> Repo.transaction()
26✔
791
    end
792
  end
793

794
  # Function that checks if the force update is invalid. The force update is only invalid
795
  # if the new question count is different from the old question count.
796
  defp invalid_force_update(assessment_multi, questions_params) do
797
    Logger.info("Checking for invalid force update")
1✔
798

799
    assessment_id =
1✔
800
      (assessment_multi.operations
1✔
801
       |> List.first()
802
       |> elem(1)
803
       |> elem(1)).data.id
1✔
804

805
    if assessment_id do
1✔
806
      open_date = Repo.get(Assessment, assessment_id).open_at
1✔
807
      # check if assessment is already opened
808
      if Timex.compare(open_date, Timex.now()) >= 0 do
1✔
809
        Logger.info("Assessment #{assessment_id} is not yet open")
×
810
        false
811
      else
812
        existing_questions_count =
1✔
813
          Question
814
          |> where([q], q.assessment_id == ^assessment_id)
1✔
815
          |> Repo.all()
816
          |> Enum.count()
817

818
        new_questions_count = Enum.count(questions_params)
1✔
819

820
        Logger.info(
1✔
821
          "Existing questions count: #{existing_questions_count}, New questions count: #{new_questions_count}"
×
822
        )
823

824
        existing_questions_count != new_questions_count
1✔
825
      end
826
    else
827
      Logger.info("No assessment ID found in multi")
×
828
      false
829
    end
830
  end
831

832
  @spec insert_or_update_assessment_changeset(map(), boolean()) :: Ecto.Changeset.t()
833
  defp insert_or_update_assessment_changeset(
834
         params = %{number: number, course_id: course_id},
835
         force_update
836
       ) do
837
    Assessment
838
    |> where(number: ^number)
839
    |> where(course_id: ^course_id)
27✔
840
    |> Repo.one()
841
    |> case do
27✔
842
      nil ->
843
        Logger.info("Inserting new assessment")
16✔
844
        Assessment.changeset(%Assessment{}, params)
16✔
845

846
      %{id: assessment_id} = assessment ->
847
        Logger.info("Updating existing assessment #{assessment_id}")
11✔
848

849
        answers_exist =
11✔
850
          Answer
851
          |> join(:inner, [a], q in assoc(a, :question))
11✔
852
          |> join(:inner, [a, q], asst in assoc(q, :assessment))
853
          |> where([a, q, asst], asst.id == ^assessment_id)
11✔
854
          |> Repo.exists?()
855

856
        if answers_exist do
11✔
857
          Logger.info("Existing answers found for assessment #{assessment_id}")
5✔
858
        end
859

860
        # Maintain the same open/close date when updating an assessment
861
        params =
11✔
862
          params
863
          |> Map.delete(:open_at)
864
          |> Map.delete(:close_at)
865
          |> Map.delete(:is_published)
866

867
        cond do
11✔
868
          not answers_exist ->
869
            Logger.info("No existing answers found for assessment #{assessment_id}")
6✔
870

871
            Logger.info("Deleting all related submission_votes for assessment #{assessment_id}")
6✔
872
            # Delete all related submission_votes
873
            SubmissionVotes
874
            |> join(:inner, [sv, q], q in assoc(sv, :question))
875
            |> where([sv, q], q.assessment_id == ^assessment_id)
6✔
876
            |> Repo.delete_all()
6✔
877

878
            Logger.info("Deleting all related questions for assessment #{assessment_id}")
6✔
879
            # Delete all existing questions
880
            Question
881
            |> where(assessment_id: ^assessment_id)
6✔
882
            |> Repo.delete_all()
6✔
883

884
            Assessment.changeset(assessment, params)
6✔
885

886
          force_update ->
5✔
887
            Assessment.changeset(assessment, params)
×
888

889
          true ->
5✔
890
            # if the assessment has submissions, don't edit
891
            create_invalid_changeset_with_error(:assessment, "has submissions")
5✔
892
        end
893
    end
894
  end
895

896
  @spec build_question_changeset_for_assessment_id(map(), number() | String.t()) ::
897
          Ecto.Changeset.t()
898
  defp build_question_changeset_for_assessment_id(params, assessment_id)
899
       when is_ecto_id(assessment_id) do
900
    params_with_assessment_id = Map.put_new(params, :assessment_id, assessment_id)
53✔
901

902
    Question.changeset(%Question{}, params_with_assessment_id)
53✔
903
  end
904

905
  def reassign_voting(assessment_id, is_reassigning_voting) do
906
    Logger.info(
9✔
907
      "Reassigning voting for assessment #{assessment_id}, is_reassigning_voting: #{is_reassigning_voting}"
×
908
    )
909

910
    if is_reassigning_voting do
9✔
911
      if is_voting_published(assessment_id) do
1✔
912
        Logger.info("Deleting existing submissions for assessment #{assessment_id}")
1✔
913

914
        Submission
915
        |> where(assessment_id: ^assessment_id)
1✔
916
        |> delete_submission_association(assessment_id)
1✔
917

918
        Logger.info("Deleting all related submission_votes for assessment #{assessment_id}")
1✔
919

920
        Question
921
        |> where(assessment_id: ^assessment_id)
1✔
922
        |> Repo.all()
923
        |> Enum.each(fn q ->
1✔
924
          delete_submission_votes_association(q)
1✔
925
        end)
926
      end
927

928
      voting_assigned_question_ids =
1✔
929
        SubmissionVotes
930
        |> select([v], v.question_id)
1✔
931
        |> Repo.all()
932

933
      unpublished_voting_questions =
1✔
934
        Question
935
        |> where(type: :voting)
936
        |> where([q], q.id not in ^voting_assigned_question_ids)
937
        |> where(assessment_id: ^assessment_id)
1✔
938
        |> join(:inner, [q], asst in assoc(q, :assessment))
939
        |> select([q, asst], %{course_id: asst.course_id, question: q.question, id: q.id})
1✔
940
        |> Repo.all()
941

942
      Logger.info("Assigning voting for #{length(unpublished_voting_questions)} questions")
1✔
943

944
      for q <- unpublished_voting_questions do
1✔
945
        Logger.debug("Inserting voting for question #{q.id}")
1✔
946
        insert_voting(q.course_id, q.question["contest_number"], q.id)
1✔
947
      end
948

949
      {:ok, "voting assigned"}
950
    else
951
      Logger.info("No changes to voting for assessment #{assessment_id}")
8✔
952
      {:ok, "no change to voting"}
953
    end
954
  end
955

956
  defp is_voting_published(assessment_id) do
957
    Logger.info("Checking if voting is published for assessment #{assessment_id}")
1✔
958

959
    voting_assigned_question_ids =
1✔
960
      SubmissionVotes
961
      |> select([v], v.question_id)
1✔
962

963
    Question
964
    |> where(type: :voting)
965
    |> where(assessment_id: ^assessment_id)
966
    |> where([q], q.id in subquery(voting_assigned_question_ids))
1✔
967
    |> Repo.exists?() || false
1✔
968
  end
969

970
  def update_final_contest_entries do
971
    # 1435 = 1 day - 5 minutes
972
    if Log.log_execution("update_final_contest_entries", Duration.from_minutes(1435)) do
1✔
973
      Logger.info("Started update of contest entry pools")
1✔
974
      questions = fetch_unassigned_voting_questions()
1✔
975

976
      for q <- questions do
1✔
977
        insert_voting(q.course_id, q.question["contest_number"], q.question_id)
2✔
978
      end
979

980
      Logger.info("Successfully update contest entry pools")
1✔
981
    end
982
  end
983

984
  # fetch voting questions where entries have not been assigned
985
  def fetch_unassigned_voting_questions do
986
    Logger.info("Fetching unassigned voting questions")
2✔
987

988
    voting_assigned_question_ids =
2✔
989
      SubmissionVotes
990
      |> select([v], v.question_id)
2✔
991
      |> Repo.all()
992

993
    Logger.info("Found #{length(voting_assigned_question_ids)} voting assigned questions")
2✔
994

995
    valid_assessments =
2✔
996
      Assessment
997
      |> select([a], %{number: a.number, course_id: a.course_id})
2✔
998
      |> Repo.all()
999

1000
    Logger.info("Found #{length(valid_assessments)} valid assessments")
2✔
1001

1002
    valid_questions =
2✔
1003
      Question
1004
      |> where(type: :voting)
1005
      |> where([q], q.id not in ^voting_assigned_question_ids)
2✔
1006
      |> join(:inner, [q], asst in assoc(q, :assessment))
1007
      |> select([q, asst], %{course_id: asst.course_id, question: q.question, question_id: q.id})
2✔
1008
      |> Repo.all()
1009

1010
    Logger.info("Found #{length(valid_questions)} valid questions")
2✔
1011

1012
    # fetch only voting where there is a corresponding contest
1013
    Enum.filter(valid_questions, fn q ->
2✔
1014
      Enum.any?(
6✔
1015
        valid_assessments,
1016
        fn a -> a.number == q.question["contest_number"] and a.course_id == q.course_id end
36✔
1017
      )
1018
    end)
1019
  end
1020

1021
  @doc """
1022
  Generates and assigns contest entries for users with given usernames.
1023
  """
1024
  def insert_voting(
1025
        course_id,
1026
        contest_number,
1027
        question_id
1028
      ) do
1029
    Logger.info("Inserting voting for question #{question_id} in contest #{contest_number}")
26✔
1030
    contest_assessment = Repo.get_by(Assessment, number: contest_number, course_id: course_id)
26✔
1031

1032
    if is_nil(contest_assessment) do
26✔
1033
      Logger.error("Contest assessment not found")
2✔
1034
      changeset = change(%Assessment{}, %{number: ""})
2✔
1035

1036
      error_changeset =
2✔
1037
        Ecto.Changeset.add_error(
1038
          changeset,
1039
          :number,
1040
          "invalid contest number"
1041
        )
1042

1043
      {:error, error_changeset}
1044
    else
1045
      if Timex.compare(contest_assessment.close_at, Timex.now()) < 0 do
24✔
1046
        Logger.info("Contest has closed for assessment #{contest_assessment.id}")
6✔
1047
        compile_entries(course_id, contest_assessment, question_id)
6✔
1048
      else
1049
        Logger.info("Contest has not closed for assessment #{contest_assessment.id}")
18✔
1050
        # contest has not closed, do nothing
1051
        {:ok, nil}
1052
      end
1053
    end
1054
  end
1055

1056
  def compile_entries(
1057
        course_id,
1058
        contest_assessment,
1059
        question_id
1060
      ) do
1061
    Logger.info(
6✔
1062
      "Compiling entries for question #{question_id} in contest #{contest_assessment.id}"
×
1063
    )
1064

1065
    # Returns contest submission ids with answers that contain "return"
1066
    contest_submission_ids =
6✔
1067
      Submission
1068
      |> join(:inner, [s], ans in assoc(s, :answers))
6✔
1069
      |> join(:inner, [s, ans], cr in assoc(s, :student))
1070
      |> where([s, ans, cr], cr.role == "student")
1071
      |> where([s, _], s.assessment_id == ^contest_assessment.id and s.status == "submitted")
6✔
1072
      |> where(
1073
        [_, ans, cr],
1074
        fragment(
1075
          "?->>'code' like ?",
1076
          ans.answer,
1077
          "%return%"
1078
        )
1079
      )
1080
      |> select([s, _ans], {s.student_id, s.id})
6✔
1081
      |> Repo.all()
1082
      |> Enum.into(%{})
1083

1084
    contest_submission_ids_length = Enum.count(contest_submission_ids)
6✔
1085

1086
    Logger.info(
6✔
1087
      "Found #{contest_submission_ids_length} valid contest submissions with 'return' in their code"
×
1088
    )
1089

1090
    voter_ids =
6✔
1091
      CourseRegistration
1092
      |> where(role: "student", course_id: ^course_id)
1093
      |> select([cr], cr.id)
6✔
1094
      |> Repo.all()
1095

1096
    Logger.info("Found #{length(voter_ids)} voter IDs")
6✔
1097

1098
    votes_per_user = min(contest_submission_ids_length, 10)
6✔
1099

1100
    votes_per_submission =
6✔
1101
      if Enum.empty?(contest_submission_ids) do
×
1102
        0
1103
      else
1104
        trunc(Float.ceil(votes_per_user * length(voter_ids) / contest_submission_ids_length))
6✔
1105
      end
1106

1107
    Logger.info("Setting votes per submission to #{votes_per_submission}")
6✔
1108

1109
    submission_id_list =
6✔
1110
      contest_submission_ids
1111
      |> Enum.map(fn {_, s_id} -> s_id end)
37✔
1112
      |> Enum.shuffle()
1113
      |> List.duplicate(votes_per_submission)
1114
      |> List.flatten()
1115

1116
    {_submission_map, submission_votes_changesets} =
6✔
1117
      voter_ids
1118
      |> Enum.reduce({submission_id_list, []}, fn voter_id, acc ->
254✔
1119
        {submission_list, submission_votes} = acc
46✔
1120

1121
        user_contest_submission_id = Map.get(contest_submission_ids, voter_id)
46✔
1122

1123
        {votes, rest} =
46✔
1124
          submission_list
1125
          |> Enum.reduce_while({MapSet.new(), submission_list}, fn s_id, acc ->
1126
            {user_votes, submissions} = acc
366✔
1127

1128
            max_votes =
366✔
1129
              if votes_per_user == contest_submission_ids_length and
×
1130
                   not is_nil(user_contest_submission_id) do
366✔
1131
                # no. of submssions is less than 10. Unable to find
1132
                votes_per_user - 1
315✔
1133
              else
1134
                votes_per_user
51✔
1135
              end
1136

1137
            if MapSet.size(user_votes) < max_votes do
366✔
1138
              if s_id != user_contest_submission_id and not MapSet.member?(user_votes, s_id) do
320✔
1139
                new_user_votes = MapSet.put(user_votes, s_id)
254✔
1140
                new_submissions = List.delete(submissions, s_id)
254✔
1141
                {:cont, {new_user_votes, new_submissions}}
1142
              else
1143
                {:cont, {user_votes, submissions}}
1144
              end
1145
            else
1146
              {:halt, acc}
1147
            end
1148
          end)
1149

1150
        votes = MapSet.to_list(votes)
46✔
1151

1152
        new_submission_votes =
46✔
1153
          votes
1154
          |> Enum.map(fn s_id ->
1155
            %SubmissionVotes{
254✔
1156
              voter_id: voter_id,
1157
              submission_id: s_id,
1158
              question_id: question_id
1159
            }
1160
          end)
1161
          |> Enum.concat(submission_votes)
1162

1163
        {rest, new_submission_votes}
1164
      end)
1165

1166
    submission_votes_changesets
1167
    |> Enum.with_index()
1168
    |> Enum.reduce(Multi.new(), fn {changeset, index}, multi ->
1169
      Multi.insert(multi, Integer.to_string(index), changeset)
254✔
1170
    end)
1171
    |> Repo.transaction()
6✔
1172
  end
1173

1174
  def update_assessment(id, params) when is_ecto_id(id) do
1175
    Logger.info("Updating assessment ID #{id} with params: #{inspect(params)}")
10✔
1176

1177
    simple_update(
10✔
1178
      Assessment,
1179
      id,
1180
      using: &Assessment.changeset/2,
1181
      params: params
1182
    )
1183
  end
1184

1185
  def update_question(id, params) when is_ecto_id(id) do
1186
    Logger.info("Updating question ID #{id} with params: #{inspect(params)}")
1✔
1187

1188
    simple_update(
1✔
1189
      Question,
1190
      id,
1191
      using: &Question.changeset/2,
1192
      params: params
1193
    )
1194
  end
1195

1196
  def publish_assessment(id) when is_ecto_id(id) do
1197
    Logger.info("Publishing assessment: #{id}")
1✔
1198
    update_assessment(id, %{is_published: true})
1✔
1199
  end
1200

1201
  def create_question_for_assessment(params, assessment_id) when is_ecto_id(assessment_id) do
1202
    Logger.info(
5✔
1203
      "Creating question for assessment ID #{assessment_id} with params: #{inspect(params)}"
×
1204
    )
1205

1206
    assessment =
5✔
1207
      Assessment
1208
      |> where(id: ^assessment_id)
5✔
1209
      |> join(:left, [a], q in assoc(a, :questions))
1210
      |> preload([_, q], questions: q)
5✔
1211
      |> Repo.one()
1212

1213
    if assessment do
5✔
1214
      params_with_assessment_id = Map.put_new(params, :assessment_id, assessment.id)
5✔
1215

1216
      result =
5✔
1217
        %Question{}
1218
        |> Question.changeset(params_with_assessment_id)
1219
        |> put_display_order(assessment.questions)
5✔
1220
        |> Repo.insert()
1221

1222
      case result do
5✔
1223
        {:ok, _} ->
1224
          Logger.info("Successfully created question for assessment")
4✔
1225

1226
        {:error, changeset} ->
1227
          Logger.error(
1✔
1228
            "Failed to create question for assessment: #{full_error_messages(changeset)}"
1✔
1229
          )
1230
      end
1231

1232
      result
5✔
1233
    else
1234
      Logger.error("Assessment not found")
×
1235
      {:error, "Assessment not found"}
1236
    end
1237
  end
1238

1239
  def get_question(id) when is_ecto_id(id) do
1240
    Logger.info("Fetching question #{id}")
60✔
1241

1242
    Question
1243
    |> where(id: ^id)
60✔
1244
    |> join(:inner, [q], assessment in assoc(q, :assessment))
1245
    |> preload([_, a], assessment: a)
60✔
1246
    |> Repo.one()
60✔
1247
  end
1248

1249
  def delete_question(id) when is_ecto_id(id) do
1250
    Logger.info("Deleting question #{id}")
1✔
1251

1252
    question = Repo.get(Question, id)
1✔
1253
    Repo.delete(question)
1✔
1254
  end
1255

1256
  def get_contest_voting_question(assessment_id) do
1257
    Logger.info("Fetching contest voting question for assessment #{assessment_id}")
2✔
1258

1259
    Question
1260
    |> where(type: :voting)
1261
    |> where(assessment_id: ^assessment_id)
2✔
1262
    |> Repo.one()
2✔
1263
  end
1264

1265
  @doc """
1266
  Public internal api to submit new answers for a question. Possible return values are:
1267
  `{:ok, nil}` -> success
1268
  `{:error, error}` -> failed. `error` is in the format of `{http_response_code, error message}`
1269

1270
  Note: In the event of `find_or_create_submission` failing due to a race condition, error will be:
1271
   `{:bad_request, "Missing or invalid parameter(s)"}`
1272

1273
  """
1274
  def answer_question(
1275
        question = %Question{},
1276
        cr = %CourseRegistration{id: cr_id},
1277
        raw_answer,
1278
        force_submit
1279
      ) do
1280
    Logger.info("Attempting to answer question #{question.id} for user #{cr_id}")
54✔
1281

1282
    with {:ok, _team} <- find_team(question.assessment.id, cr_id),
54✔
1283
         {:ok, submission} <- find_or_create_submission(cr, question.assessment),
53✔
1284
         {:status, true} <- {:status, force_submit or submission.status != :submitted},
53✔
1285
         {:ok, _answer} <- insert_or_update_answer(submission, question, raw_answer, cr_id) do
52✔
1286
      Logger.info("Successfully answered question #{question.id} for user #{cr_id}")
43✔
1287
      update_submission_status_router(submission, question)
43✔
1288

1289
      {:ok, nil}
1290
    else
1291
      {:status, _} ->
1292
        Logger.error("Failed to answer question #{question.id} - submission already finalized")
1✔
1293
        {:error, {:forbidden, "Assessment submission already finalised"}}
1294

1295
      {:error, :race_condition} ->
1296
        Logger.error("Race condition encountered while answering question #{question.id}")
×
1297
        {:error, {:internal_server_error, "Please try again later."}}
1298

1299
      {:error, :team_not_found} ->
1300
        Logger.error("Team not found for question #{question.id} and user #{cr_id}")
1✔
1301
        {:error, {:bad_request, "Your existing Team has been deleted!"}}
1302

1303
      {:error, :invalid_vote} ->
1304
        Logger.error("Invalid vote for question #{question.id} by user #{cr_id}")
3✔
1305
        {:error, {:bad_request, "Invalid vote! Vote is not saved."}}
1306

1307
      _ ->
1308
        Logger.error("Failed to answer question #{question.id} - invalid parameters")
6✔
1309
        {:error, {:bad_request, "Missing or invalid parameter(s)"}}
1310
    end
1311
  end
1312

1313
  def is_team_assessment?(assessment_id) when is_ecto_id(assessment_id) do
1314
    Logger.info("Checking if assessment #{assessment_id} is a team assessment")
653✔
1315

1316
    max_team_size =
653✔
1317
      Assessment
1318
      |> where(id: ^assessment_id)
1319
      |> select([a], a.max_team_size)
653✔
1320
      |> Repo.one()
1321

1322
    Logger.info("Assessment #{assessment_id} has max team size #{max_team_size}")
653✔
1323
    max_team_size > 1
653✔
1324
  end
1325

1326
  defp find_teams(cr_id) when is_ecto_id(cr_id) do
1327
    Logger.info("Finding teams for user #{cr_id}")
35✔
1328

1329
    teams =
35✔
1330
      Team
1331
      |> join(:inner, [t], tm in assoc(t, :team_members))
1332
      |> where([_, tm], tm.student_id == ^cr_id)
35✔
1333
      |> Repo.all()
1334

1335
    Logger.info("Found #{length(teams)} teams for user #{cr_id}")
35✔
1336
    teams
35✔
1337
  end
1338

1339
  defp find_team(assessment_id, cr_id)
1340
       when is_ecto_id(assessment_id) and is_ecto_id(cr_id) do
1341
    Logger.info("Finding team for assessment #{assessment_id} and user #{cr_id}")
602✔
1342

1343
    query =
602✔
1344
      from(t in Team,
602✔
1345
        where: t.assessment_id == ^assessment_id,
1346
        join: tm in assoc(t, :team_members),
1347
        where: tm.student_id == ^cr_id,
1348
        limit: 1
1349
      )
1350

1351
    if is_team_assessment?(assessment_id) do
602✔
1352
      case Repo.one(query) do
10✔
1353
        nil ->
1354
          Logger.error("Team not found for assessment #{assessment_id} and user #{cr_id}")
3✔
1355
          {:error, :team_not_found}
1356

1357
        team ->
1358
          Logger.info("Found team #{team.id} for assessment #{assessment_id} and user #{cr_id}")
7✔
1359
          {:ok, team}
1360
      end
1361
    else
1362
      # team is nil for individual assessments
1363
      Logger.info("Assessment #{assessment_id} is not a team assessment")
592✔
1364
      {:ok, nil}
1365
    end
1366
  end
1367

1368
  def get_submission(assessment_id, %CourseRegistration{id: cr_id})
1369
      when is_ecto_id(assessment_id) do
1370
    Logger.info("Getting submission for assessment #{assessment_id} and user #{cr_id}")
364✔
1371
    {:ok, team} = find_team(assessment_id, cr_id)
364✔
1372

1373
    case team do
364✔
1374
      %Team{} ->
1375
        Logger.info("Getting team submission for team #{team.id} of user #{cr_id}")
1✔
1376

1377
        Submission
1378
        |> where(assessment_id: ^assessment_id)
1379
        |> where(team_id: ^team.id)
1✔
1380
        |> join(:inner, [s], a in assoc(s, :assessment))
1381
        |> preload([_, a], assessment: a)
1✔
1382
        |> Repo.one()
1✔
1383

1384
      nil ->
1385
        Logger.info("Getting individual submission for user #{cr_id}")
363✔
1386

1387
        Submission
1388
        |> where(assessment_id: ^assessment_id)
1389
        |> where(student_id: ^cr_id)
363✔
1390
        |> join(:inner, [s], a in assoc(s, :assessment))
1391
        |> preload([_, a], assessment: a)
363✔
1392
        |> Repo.one()
363✔
1393
    end
1394
  end
1395

1396
  def get_submission_by_id(submission_id) when is_ecto_id(submission_id) do
1397
    Logger.info("Getting submission with ID #{submission_id}")
1✔
1398

1399
    Submission
1400
    |> where(id: ^submission_id)
1✔
1401
    |> join(:inner, [s], a in assoc(s, :assessment))
1402
    |> preload([_, a], assessment: a)
1✔
1403
    |> Repo.one()
1✔
1404
  end
1405

1406
  def finalise_submission(submission = %Submission{}) do
1407
    Logger.info(
360✔
1408
      "Finalizing submission #{submission.id} for assessment #{submission.assessment_id}"
×
1409
    )
1410

1411
    with {:status, :attempted} <- {:status, submission.status},
360✔
1412
         {:ok, updated_submission} <- update_submission_status(submission) do
358✔
1413
      # Couple with update_submission_status and update_xp_bonus to ensure notification is sent
1414
      submission = Repo.preload(submission, assessment: [:config])
358✔
1415

1416
      if submission.assessment.config.is_manually_graded do
358✔
1417
        Notifications.write_notification_when_student_submits(submission)
358✔
1418
      end
1419

1420
      # Send email notification to avenger
1421
      %{notification_type: "assessment_submission", submission_id: updated_submission.id}
358✔
1422
      |> Cadet.Workers.NotificationWorker.new()
1423
      |> Oban.insert()
358✔
1424

1425
      # Begin autograding job
1426
      GradingJob.force_grade_individual_submission(updated_submission)
358✔
1427
      update_xp_bonus(updated_submission)
358✔
1428

1429
      Logger.info("Successfully finalized submission #{submission.id}")
358✔
1430
      {:ok, nil}
1431
    else
1432
      {:status, :attempting} ->
1433
        Logger.error(
1✔
1434
          "Cannot finalize submission #{submission.id} - some questions have not been attempted"
1✔
1435
        )
1436

1437
        {:error, {:bad_request, "Some questions have not been attempted"}}
1438

1439
      {:status, :submitted} ->
1440
        Logger.error(
1✔
1441
          "Cannot finalize submission #{submission.id} - assessment has already been submitted"
1✔
1442
        )
1443

1444
        {:error, {:forbidden, "Assessment has already been submitted"}}
1445

1446
      _ ->
1447
        Logger.error("Failed to finalize submission #{submission.id} - unknown error")
×
1448
        {:error, {:internal_server_error, "Please try again later."}}
1449
    end
1450
  end
1451

1452
  def unsubmit_submission(
1453
        submission_id,
1454
        cr = %CourseRegistration{id: course_reg_id, role: role}
1455
      )
1456
      when is_ecto_id(submission_id) do
1457
    Logger.info("Unsubmitting submission #{submission_id} for user #{course_reg_id}")
9✔
1458

1459
    submission =
9✔
1460
      Submission
1461
      |> join(:inner, [s], a in assoc(s, :assessment))
1462
      |> preload([_, a], assessment: a)
9✔
1463
      |> Repo.get(submission_id)
1464

1465
    # allows staff to unsubmit own assessment
1466
    bypass = role in @bypass_closed_roles and submission.student_id == course_reg_id
9✔
1467
    Logger.info("Bypass restrictions: #{bypass}")
9✔
1468

1469
    with {:submission_found?, true} <- {:submission_found?, is_map(submission)},
9✔
1470
         {:is_open?, true} <- {:is_open?, bypass or is_open?(submission.assessment)},
9✔
1471
         {:status, :submitted} <- {:status, submission.status},
8✔
1472
         {:allowed_to_unsubmit?, true} <-
7✔
1473
           {:allowed_to_unsubmit?,
1474
            role == :admin or bypass or is_nil(submission.student_id) or
7✔
1475
              Cadet.Accounts.Query.avenger_of?(cr, submission.student_id)},
3✔
1476
         {:is_grading_published?, false} <-
6✔
1477
           {:is_grading_published?, submission.is_grading_published} do
6✔
1478
      Logger.info("All checks passed for unsubmitting submission #{submission_id}")
5✔
1479

1480
      Multi.new()
1481
      |> Multi.run(
1482
        :rollback_submission,
1483
        fn _repo, _ ->
1484
          Logger.info("Rolling back submission #{submission_id}")
5✔
1485

1486
          submission
1487
          |> Submission.changeset(%{
1488
            status: :attempted,
1489
            xp_bonus: 0,
1490
            unsubmitted_by_id: course_reg_id,
1491
            unsubmitted_at: Timex.now()
1492
          })
1493
          |> Repo.update()
5✔
1494
        end
1495
      )
1496
      |> Multi.run(:rollback_answers, fn _repo, _ ->
1497
        Logger.info("Rolling back answers for submission #{submission_id}")
5✔
1498

1499
        Answer
1500
        |> join(:inner, [a], q in assoc(a, :question))
5✔
1501
        |> join(:inner, [a, _], s in assoc(a, :submission))
1502
        |> preload([_, q, s], question: q, submission: s)
1503
        |> where(submission_id: ^submission.id)
5✔
1504
        |> Repo.all()
1505
        |> Enum.reduce_while({:ok, nil}, fn answer, acc ->
5✔
1506
          case acc do
4✔
1507
            {:error, _} ->
1508
              Logger.error(
×
1509
                "Error encountered while rolling back answers for submission #{submission_id}"
×
1510
              )
1511

1512
              {:halt, acc}
1513

1514
            {:ok, _} ->
1515
              Logger.info("Rolling back answer #{answer.id} for submission #{submission_id}")
4✔
1516

1517
              {:cont,
1518
               answer
1519
               |> Answer.grading_changeset(%{
1520
                 xp: 0,
1521
                 xp_adjustment: 0,
1522
                 autograding_status: :none,
1523
                 autograding_results: []
1524
               })
1525
               |> Repo.update()}
1526
          end
1527
        end)
1528
      end)
1529
      |> Repo.transaction()
5✔
1530

1531
      case submission.student_id do
5✔
1532
        # Team submission, handle notifications for team members
1533
        nil ->
1534
          Logger.info("Handling unsubmit notifications for team submission #{submission.id}")
1✔
1535
          team = Repo.get(Team, submission.team_id)
1✔
1536

1537
          query =
1✔
1538
            from(t in Team,
1✔
1539
              join: tm in TeamMember,
1540
              on: t.id == tm.team_id,
1541
              join: cr in CourseRegistration,
1542
              on: tm.student_id == cr.id,
1543
              where: t.id == ^team.id,
1✔
1544
              select: cr.id
1545
            )
1546

1547
          team_members = Repo.all(query)
1✔
1548

1549
          Enum.each(team_members, fn tm_id ->
1✔
1550
            Logger.info("Sending unsubmit notification to team member #{tm_id}")
2✔
1551

1552
            Notifications.handle_unsubmit_notifications(
2✔
1553
              submission.assessment.id,
2✔
1554
              Repo.get(CourseRegistration, tm_id)
1555
            )
1556
          end)
1557

1558
        student_id ->
1559
          Logger.info(
4✔
1560
            "Handling unsubmit notifications for individual submission #{submission.id}"
×
1561
          )
1562

1563
          Notifications.handle_unsubmit_notifications(
4✔
1564
            submission.assessment.id,
4✔
1565
            Repo.get(CourseRegistration, student_id)
1566
          )
1567
      end
1568

1569
      Logger.info("Removing grading notifications for submission #{submission.id}")
5✔
1570

1571
      # Remove grading notifications for submissions
1572
      Notification
1573
      |> where(submission_id: ^submission_id, type: :submitted)
1574
      |> select([n], n.id)
5✔
1575
      |> Repo.all()
1576
      |> Notifications.acknowledge(cr)
5✔
1577

1578
      Logger.info("Successfully unsubmitting submission #{submission_id}")
5✔
1579
      {:ok, nil}
1580
    else
1581
      {:submission_found?, false} ->
1582
        Logger.error("Submission #{submission_id} not found")
×
1583
        {:error, {:not_found, "Submission not found"}}
1584

1585
      {:is_open?, false} ->
1586
        Logger.error("Assessment for submission #{submission_id} is not open")
1✔
1587
        {:error, {:forbidden, "Assessment not open"}}
1588

1589
      {:status, :attempting} ->
1590
        Logger.error("Submission #{submission_id} is still attempting")
×
1591
        {:error, {:bad_request, "Some questions have not been attempted"}}
1592

1593
      {:status, :attempted} ->
1594
        Logger.error("Submission #{submission_id} has already been attempted")
1✔
1595
        {:error, {:bad_request, "Assessment has not been submitted"}}
1596

1597
      {:allowed_to_unsubmit?, false} ->
1598
        Logger.error(
1✔
1599
          "User #{course_reg_id} is not allowed to unsubmit submission #{submission_id}"
1✔
1600
        )
1601

1602
        {:error, {:forbidden, "Only Avenger of student or Admin is permitted to unsubmit"}}
1603

1604
      {:is_grading_published?, true} ->
1605
        Logger.error("Grading for submission #{submission_id} has already been published")
1✔
1606
        {:error, {:forbidden, "Grading has not been unpublished"}}
1607

1608
      _ ->
1609
        Logger.error("An unknown error occurred while unsubmitting submission #{submission_id}")
×
1610
        {:error, {:internal_server_error, "Please try again later."}}
1611
    end
1612
  end
1613

1614
  defp can_publish?(submission_id, cr = %CourseRegistration{id: course_reg_id, role: role}) do
1615
    Logger.info(
7✔
1616
      "Checking if submission #{submission_id} can be published by user #{course_reg_id}"
×
1617
    )
1618

1619
    submission =
7✔
1620
      Submission
1621
      |> join(:inner, [s], a in assoc(s, :assessment))
7✔
1622
      |> join(:inner, [_, a], c in assoc(a, :config))
1623
      |> preload([_, a, c], assessment: {a, config: c})
7✔
1624
      |> Repo.get(submission_id)
1625

1626
    Logger.debug("Fetched submission: #{inspect(submission)}")
7✔
1627

1628
    # allows staff to unpublish own assessment
1629
    bypass = role in @bypass_closed_roles and submission.student_id == course_reg_id
7✔
1630
    Logger.info("Bypass restrictions: #{bypass}")
7✔
1631

1632
    # assumption: if team assessment, all team members are under the same avenger
1633
    effective_student_id =
7✔
1634
      if is_nil(submission.student_id) do
7✔
1635
        Logger.info("Fetching first team member for team submission #{submission.team_id}")
×
1636
        Teams.get_first_member(submission.team_id).student_id
×
1637
      else
1638
        submission.student_id
7✔
1639
      end
1640

1641
    Logger.info("Effective student ID: #{effective_student_id}")
7✔
1642

1643
    with {:submission_found?, true} <- {:submission_found?, is_map(submission)},
7✔
1644
         {:status, :submitted} <- {:status, submission.status},
7✔
1645
         {:is_manually_graded?, true} <-
6✔
1646
           {:is_manually_graded?, submission.assessment.config.is_manually_graded},
6✔
1647
         {:fully_graded?, true} <- {:fully_graded?, is_fully_graded?(submission_id)},
6✔
1648
         {:allowed_to_publish?, true} <-
6✔
1649
           {:allowed_to_publish?,
1650
            role == :admin or bypass or
6✔
1651
              Cadet.Accounts.Query.avenger_of?(cr, effective_student_id)} do
4✔
1652
      Logger.info("All checks passed!")
4✔
1653
      {:ok, submission}
1654
    else
1655
      err ->
1656
        case err do
3✔
1657
          {:submission_found?, false} ->
1658
            Logger.error("Submission #{submission_id} not found")
×
1659

1660
          {:status, _} ->
1661
            Logger.error("Submission #{submission_id} is not in a submitted state")
1✔
1662

1663
          {:is_manually_graded?, false} ->
1664
            Logger.error("Submission #{submission_id} is not manually graded")
×
1665

1666
          {:fully_graded?, false} ->
1667
            Logger.error("Submission #{submission_id} is not fully graded")
×
1668

1669
          {:allowed_to_publish?, false} ->
1670
            Logger.error(
2✔
1671
              "User #{course_reg_id} is not allowed to publish submission #{submission_id}"
2✔
1672
            )
1673

1674
          error ->
1675
            Logger.error("Unknown error occurred while publishing submission: #{inspect(error)}")
×
1676
        end
1677

1678
        err
3✔
1679
    end
1680
  end
1681

1682
  @doc """
1683
    Unpublishes grading for a submission and send notification to student.
1684
    Requires admin or staff who is group leader of student.
1685

1686
    Only manually graded assessments can be individually unpublished. We can only
1687
    unpublish all submissions for auto-graded assessments.
1688

1689
    Returns `{:ok, nil}` on success, otherwise `{:error, {status, message}}`.
1690
  """
1691
  def unpublish_grading(submission_id, cr = %CourseRegistration{})
1692
      when is_ecto_id(submission_id) do
1693
    Logger.info("Attempting to unpublish grading for submission #{submission_id}")
3✔
1694

1695
    case can_publish?(submission_id, cr) do
3✔
1696
      {:ok, submission} ->
1697
        Logger.info("Unpublishing grading for submission #{submission_id}")
2✔
1698

1699
        submission
1700
        |> Submission.changeset(%{is_grading_published: false})
1701
        |> Repo.update()
2✔
1702

1703
        # assumption: if team assessment, all team members are under the same avenger
1704
        effective_student_id =
2✔
1705
          if is_nil(submission.student_id) do
2✔
1706
            Logger.info("Fetching first team member for team submission #{submission.team_id}")
×
1707
            Teams.get_first_member(submission.team_id).student_id
×
1708
          else
1709
            submission.student_id
2✔
1710
          end
1711

1712
        Logger.info(
2✔
1713
          "Sending unpublish grades notification for assessment #{submission.assessment.id} to student #{effective_student_id}"
×
1714
        )
1715

1716
        Notifications.handle_unpublish_grades_notifications(
2✔
1717
          submission.assessment.id,
2✔
1718
          Repo.get(CourseRegistration, effective_student_id)
1719
        )
1720

1721
        Logger.info("Successfully unpublished grading for submission #{submission_id}")
2✔
1722
        {:ok, nil}
1723

1724
      {:submission_found?, false} ->
1725
        Logger.error("Submission #{submission_id} not found")
×
1726
        {:error, {:not_found, "Submission not found"}}
1727

1728
      {:allowed_to_publish?, false} ->
1729
        Logger.error(
1✔
1730
          "User #{cr.id} is not allowed to unpublish grading for submission #{submission_id}"
1✔
1731
        )
1732

1733
        {:error,
1734
         {:forbidden, "Only Avenger of student or Admin is permitted to unpublish grading"}}
1735

1736
      {:is_manually_graded?, false} ->
1737
        Logger.error(
×
1738
          "Submission #{submission_id} is not manually graded and cannot be unpublished"
×
1739
        )
1740

1741
        {:error,
1742
         {:bad_request, "Only manually graded assessments can be individually unpublished"}}
1743

1744
      err ->
1745
        Logger.error(
×
1746
          "Unknown error occurred while unpublishing grading for submission #{submission_id}: #{inspect(err)}"
×
1747
        )
1748

1749
        {:error, {:internal_server_error, "Please try again later."}}
1750
    end
1751
  end
1752

1753
  @doc """
1754
    Publishes grading for a submission and send notification to student.
1755
    Requires admin or staff who is group leader of student and all answers to be graded.
1756

1757
    Only manually graded assessments can be individually published. We can only
1758
    publish all submissions for auto-graded assessments.
1759

1760
    Returns `{:ok, nil}` on success, otherwise `{:error, {status, message}}`.
1761
  """
1762
  def publish_grading(submission_id, cr = %CourseRegistration{})
1763
      when is_ecto_id(submission_id) do
1764
    Logger.info("Attempting to publish grading for submission #{submission_id}")
4✔
1765

1766
    case can_publish?(submission_id, cr) do
4✔
1767
      {:ok, submission} ->
1768
        Logger.info("Publishing grading for submission #{submission_id}")
2✔
1769

1770
        submission
1771
        |> Submission.changeset(%{is_grading_published: true})
1772
        |> Repo.update()
2✔
1773

1774
        Logger.info("Updating XP bonus for submission #{submission_id}")
2✔
1775
        update_xp_bonus(submission)
2✔
1776

1777
        Logger.info("Writing notification for published grading for submission #{submission_id}")
2✔
1778

1779
        Notifications.write_notification_when_published(
2✔
1780
          submission.id,
2✔
1781
          :published_grading
1782
        )
1783

1784
        Logger.info("Acknowledging notifications for submission #{submission_id}")
2✔
1785

1786
        Notification
1787
        |> where(submission_id: ^submission.id, type: :submitted)
2✔
1788
        |> select([n], n.id)
2✔
1789
        |> Repo.all()
1790
        |> Notifications.acknowledge(cr)
2✔
1791

1792
        Logger.info("Successfully published grading for submission #{submission_id}")
2✔
1793
        {:ok, nil}
1794

1795
      {:submission_found?, false} ->
1796
        Logger.error("Submission #{submission_id} not found")
×
1797
        {:error, {:not_found, "Submission not found"}}
1798

1799
      {:status, :attempting} ->
1800
        Logger.error("Submission #{submission_id} is still attempting")
×
1801
        {:error, {:bad_request, "Some questions have not been attempted"}}
1802

1803
      {:status, :attempted} ->
1804
        Logger.error("Submission #{submission_id} has not been submitted")
1✔
1805
        {:error, {:bad_request, "Assessment has not been submitted"}}
1806

1807
      {:allowed_to_publish?, false} ->
1808
        Logger.error(
1✔
1809
          "User #{cr.id} is not allowed to publish grading for submission #{submission_id}"
1✔
1810
        )
1811

1812
        {:error, {:forbidden, "Only Avenger of student or Admin is permitted to publish grading"}}
1813

1814
      {:is_manually_graded?, false} ->
1815
        Logger.error("Submission #{submission_id} is not manually graded and cannot be published")
×
1816
        {:error, {:bad_request, "Only manually graded assessments can be individually published"}}
1817

1818
      {:fully_graded?, false} ->
1819
        Logger.error("Some answers in submission #{submission_id} are not graded")
×
1820
        {:error, {:bad_request, "Some answers are not graded"}}
1821

1822
      err ->
1823
        Logger.error(
×
1824
          "Unknown error occurred while publishing grading for submission #{submission_id}: #{inspect(err)}"
×
1825
        )
1826

1827
        {:error, {:internal_server_error, "Please try again later."}}
1828
    end
1829
  end
1830

1831
  @doc """
1832
    Publishes grading for a submission and send notification to student.
1833
    This function is used by the auto-grading system to publish grading. Bypasses Course Reg checks.
1834

1835
    Returns `{:ok, nil}` on success, otherwise `{:error, {status, message}}`.
1836
  """
1837
  def publish_grading(submission_id)
1838
      when is_ecto_id(submission_id) do
1839
    Logger.info("Attempting to publish grading for submission #{submission_id}")
14✔
1840

1841
    submission =
14✔
1842
      Submission
1843
      |> join(:inner, [s], a in assoc(s, :assessment))
1844
      |> preload([_, a], assessment: a)
14✔
1845
      |> Repo.get(submission_id)
1846

1847
    with {:submission_found?, true} <- {:submission_found?, is_map(submission)},
14✔
1848
         {:status, :submitted} <- {:status, submission.status} do
14✔
1849
      Logger.info("Publishing grading for submission #{submission_id}")
14✔
1850

1851
      submission
1852
      |> Submission.changeset(%{is_grading_published: true})
1853
      |> Repo.update()
14✔
1854

1855
      Logger.info("Writing notification for published grading for submission #{submission_id}")
14✔
1856

1857
      Notifications.write_notification_when_published(
14✔
1858
        submission.id,
14✔
1859
        :published_grading
1860
      )
1861

1862
      Logger.info("Successfully published grading for submission #{submission_id}")
14✔
1863
      {:ok, nil}
1864
    else
1865
      {:submission_found?, false} ->
1866
        Logger.error("Submission #{submission_id} not found")
×
1867
        {:error, {:not_found, "Submission not found"}}
1868

1869
      {:status, :attempting} ->
1870
        Logger.error("Student is still attempting submission #{submission_id}")
×
1871
        {:error, {:bad_request, "Some questions have not been attempted"}}
1872

1873
      {:status, :attempted} ->
1874
        Logger.error("Submission #{submission_id} has not been submitted")
×
1875
        {:error, {:bad_request, "Assessment has not been submitted"}}
1876

1877
      err ->
1878
        Logger.error(
×
1879
          "Unknown error occurred while publishing grading for submission #{submission_id}: #{inspect(err)}"
×
1880
        )
1881

1882
        {:error, {:internal_server_error, "Please try again later."}}
1883
    end
1884
  end
1885

1886
  @doc """
1887
    Publishes grading for all graded submissions for an assessment and sends notifications to students.
1888
    Requires admin.
1889

1890
    Returns `{:ok, nil}` on success, otherwise `{:error, {status, message}}`.
1891
  """
1892
  def publish_all_graded(publisher = %CourseRegistration{}, assessment_id) do
1893
    Logger.info("Attempting to publish all graded submissions for assessment #{assessment_id}")
2✔
1894

1895
    if publisher.role == :admin do
2✔
1896
      Logger.info("User #{publisher.id} is an admin, proceeding with publishing")
2✔
1897

1898
      answers_query =
2✔
1899
        Answer
1900
        |> group_by([ans], ans.submission_id)
1901
        |> select([ans], %{
2✔
1902
          submission_id: ans.submission_id,
1903
          graded_count: filter(count(ans.id), not is_nil(ans.grader_id)),
1904
          autograded_count: filter(count(ans.id), ans.autograding_status == :success)
1905
        })
1906

1907
      question_query =
2✔
1908
        Question
1909
        |> group_by([q], q.assessment_id)
2✔
1910
        |> join(:inner, [q], a in Assessment, on: q.assessment_id == a.id)
1911
        |> select([q, a], %{
2✔
1912
          assessment_id: q.assessment_id,
1913
          question_count: count(q.id)
1914
        })
1915

1916
      submission_query =
2✔
1917
        Submission
1918
        |> join(:inner, [s], ans in subquery(answers_query), on: ans.submission_id == s.id)
2✔
1919
        |> join(:inner, [s, ans], asst in subquery(question_query),
2✔
1920
          on: s.assessment_id == asst.assessment_id
1921
        )
1922
        |> join(:inner, [s, ans, asst], cr in CourseRegistration, on: s.student_id == cr.id)
1923
        |> where([s, ans, asst, cr], cr.course_id == ^publisher.course_id)
2✔
1924
        |> where(
1925
          [s, ans, asst, cr],
1926
          asst.question_count == ans.graded_count or asst.question_count == ans.autograded_count
1927
        )
1928
        |> where([s, ans, asst, cr], s.is_grading_published == false)
1929
        |> where([s, ans, asst, cr], s.assessment_id == ^assessment_id)
1930
        |> select([s, ans, asst, cr], %{
2✔
1931
          id: s.id
1932
        })
1933

1934
      Logger.info("Fetching submissions eligible for publishing")
2✔
1935
      submissions = Repo.all(submission_query)
2✔
1936

1937
      Logger.info("Updating submissions to set grading as published")
2✔
1938
      Repo.update_all(submission_query, set: [is_grading_published: true])
2✔
1939

1940
      Logger.info("Sending notifications for published submissions")
2✔
1941

1942
      Enum.each(submissions, fn submission ->
2✔
1943
        Notifications.write_notification_when_published(
2✔
1944
          submission.id,
2✔
1945
          :published_grading
1946
        )
1947

1948
        Logger.info("Notification sent for submission #{submission.id}")
2✔
1949
      end)
1950

1951
      Logger.info("Successfully published all graded submissions for assessment #{assessment_id}")
2✔
1952
      {:ok, nil}
1953
    else
1954
      Logger.error("User #{publisher.id} is not an admin, cannot publish all grades")
×
1955
      {:error, {:forbidden, "Only Admin is permitted to publish all grades"}}
1956
    end
1957
  end
1958

1959
  @doc """
1960
     Unpublishes grading for all submissions with grades published for an assessment and sends notifications to students.
1961
     Requires admin role.
1962

1963
     Returns `{:ok, nil}` on success, otherwise `{:error, {status, message}}`.
1964
  """
1965

1966
  def unpublish_all(publisher = %CourseRegistration{}, assessment_id) do
1967
    Logger.info("Attempting to unpublish all submissions for assessment #{assessment_id}")
2✔
1968

1969
    if publisher.role == :admin do
2✔
1970
      Logger.info("User #{publisher.id} is an admin, proceeding with unpublishing")
2✔
1971

1972
      submission_query =
2✔
1973
        Submission
1974
        |> join(:inner, [s], cr in CourseRegistration, on: s.student_id == cr.id)
1975
        |> where([s, cr], cr.course_id == ^publisher.course_id)
2✔
1976
        |> where([s, cr], s.is_grading_published == true)
1977
        |> where([s, cr], s.assessment_id == ^assessment_id)
1978
        |> select([s, cr], %{
2✔
1979
          id: s.id,
1980
          student_id: cr.id
1981
        })
1982

1983
      Logger.info("Fetching submissions eligible for unpublishing")
2✔
1984
      submissions = Repo.all(submission_query)
2✔
1985

1986
      Logger.info("Unpublishing submissions for assessment #{assessment_id}")
2✔
1987
      Repo.update_all(submission_query, set: [is_grading_published: false])
2✔
1988

1989
      Logger.info("Sending notifications for unpublished submissions")
2✔
1990

1991
      Enum.each(submissions, fn submission ->
2✔
1992
        Notifications.handle_unpublish_grades_notifications(
2✔
1993
          assessment_id,
1994
          Repo.get(CourseRegistration, submission.student_id)
2✔
1995
        )
1996
      end)
1997

1998
      {:ok, nil}
1999
    else
2000
      Logger.error("User #{publisher.id} is not an admin, cannot unpublish all grades")
×
2001
      {:error, {:forbidden, "Only Admin is permitted to unpublish all grades"}}
2002
    end
2003
  end
2004

2005
  @spec update_submission_status(Submission.t()) ::
2006
          {:ok, Submission.t()} | {:error, Ecto.Changeset.t()}
2007
  defp update_submission_status(submission = %Submission{}) do
2008
    Logger.info("Updating submission status for submission #{submission.id}")
358✔
2009

2010
    submission
2011
    |> Submission.changeset(%{status: :submitted, submitted_at: Timex.now()})
2012
    |> Repo.update()
358✔
2013
  end
2014

2015
  @spec update_xp_bonus(Submission.t()) ::
2016
          {:ok, Submission.t()} | {:error, Ecto.Changeset.t()}
2017
  # TODO: Should destructure and pattern match on the function
2018
  defp update_xp_bonus(submission = %Submission{id: submission_id}) do
2019
    Logger.info("Updating XP bonus for submission #{submission_id}")
366✔
2020
    # to ensure backwards compatibility
2021
    if submission.xp_bonus == 0 do
366✔
2022
      assessment = submission.assessment
364✔
2023
      assessment_conifg = Repo.get_by(AssessmentConfig, id: assessment.config_id)
364✔
2024

2025
      max_bonus_xp = assessment_conifg.early_submission_xp
364✔
2026
      early_hours = assessment_conifg.hours_before_early_xp_decay
364✔
2027

2028
      ans_xp =
364✔
2029
        Answer
2030
        |> where(submission_id: ^submission_id)
2031
        |> order_by(:question_id)
2032
        |> select([a], %{
364✔
2033
          total_xp: a.xp + a.xp_adjustment
2034
        })
2035

2036
      total =
364✔
2037
        ans_xp
2038
        |> subquery
2039
        |> select([a], %{
364✔
2040
          total_xp: coalesce(sum(a.total_xp), 0)
2041
        })
2042
        |> Repo.one()
2043

2044
      cur_time =
364✔
2045
        if submission.submitted_at == nil do
364✔
2046
          Timex.now()
4✔
2047
        else
2048
          submission.submitted_at
360✔
2049
        end
2050

2051
      xp_bonus =
364✔
2052
        if total.total_xp <= 0 do
364✔
2053
          0
2054
        else
2055
          if Timex.before?(cur_time, Timex.shift(assessment.open_at, hours: early_hours)) do
204✔
2056
            max_bonus_xp
2✔
2057
          else
2058
            # This logic interpolates from max bonus at early hour to 0 bonus at close time
2059
            decaying_hours =
202✔
2060
              Timex.diff(assessment.close_at, assessment.open_at, :hours) - early_hours
202✔
2061

2062
            remaining_hours = Enum.max([0, Timex.diff(assessment.close_at, cur_time, :hours)])
202✔
2063
            proportion = if(decaying_hours > 0, do: remaining_hours / decaying_hours, else: 1)
202✔
2064
            bonus_xp = round(max_bonus_xp * proportion)
202✔
2065
            Enum.max([0, bonus_xp])
202✔
2066
          end
2067
        end
2068

2069
      Logger.info("XP bonus updated for submission #{submission_id}")
364✔
2070

2071
      submission
2072
      |> Submission.changeset(%{xp_bonus: xp_bonus})
2073
      |> Repo.update()
364✔
2074
    end
2075
  end
2076

2077
  defp update_submission_status_router(submission = %Submission{}, question = %Question{}) do
2078
    Logger.info(
43✔
2079
      "Updating submission status for submission #{submission.id} and question #{question.id}"
×
2080
    )
2081

2082
    case question.type do
43✔
2083
      :voting -> update_contest_voting_submission_status(submission, question)
12✔
2084
      :mcq -> update_submission_status(submission, question.assessment)
17✔
2085
      :programming -> update_submission_status(submission, question.assessment)
14✔
2086
    end
2087
  end
2088

2089
  defp update_submission_status(submission = %Submission{}, assessment = %Assessment{}) do
2090
    Logger.info(
31✔
2091
      "Updating submission status for submission #{submission.id} and assessment #{assessment.id}"
×
2092
    )
2093

2094
    model_assoc_count = fn model, assoc, id ->
31✔
2095
      model
2096
      |> where(id: ^id)
62✔
2097
      |> join(:inner, [m], a in assoc(m, ^assoc))
2098
      |> select([_, a], count(a.id))
62✔
2099
      |> Repo.one()
62✔
2100
    end
2101

2102
    Multi.new()
2103
    |> Multi.run(:assessment, fn _repo, _ ->
31✔
2104
      {:ok, model_assoc_count.(Assessment, :questions, assessment.id)}
31✔
2105
    end)
2106
    |> Multi.run(:submission, fn _repo, _ ->
31✔
2107
      {:ok, model_assoc_count.(Submission, :answers, submission.id)}
31✔
2108
    end)
2109
    |> Multi.run(:update, fn _repo, %{submission: s_count, assessment: a_count} ->
2110
      if s_count == a_count do
31✔
2111
        submission |> Submission.changeset(%{status: :attempted}) |> Repo.update()
5✔
2112
      else
2113
        {:ok, nil}
2114
      end
2115
    end)
2116
    |> Repo.transaction()
31✔
2117
  end
2118

2119
  defp update_contest_voting_submission_status(submission = %Submission{}, question = %Question{}) do
2120
    Logger.info(
12✔
2121
      "Updating contest voting submission status for submission #{submission.id} and question #{question.id}"
×
2122
    )
2123

2124
    has_nil_entries =
12✔
2125
      SubmissionVotes
2126
      |> where(question_id: ^question.id)
12✔
2127
      |> where(voter_id: ^submission.student_id)
12✔
2128
      |> where([sv], is_nil(sv.score))
12✔
2129
      |> Repo.exists?()
2130

2131
    unless has_nil_entries do
12✔
2132
      submission |> Submission.changeset(%{status: :attempted}) |> Repo.update()
12✔
2133
    end
2134
  end
2135

2136
  defp load_contest_voting_entries(
2137
         questions,
2138
         %CourseRegistration{role: role, course_id: course_id, id: voter_id},
2139
         assessment,
2140
         visible_entries
2141
       ) do
2142
    Logger.info("Loading contest voting entries for assessment #{assessment.id}")
98✔
2143

2144
    Enum.map(
98✔
2145
      questions,
2146
      fn q ->
2147
        if q.type == :voting do
803✔
2148
          submission_votes = all_submission_votes_by_question_id_and_voter_id(q.id, voter_id)
267✔
2149
          # fetch top 10 contest voting entries with the contest question id
2150
          question_id = fetch_associated_contest_question_id(course_id, q)
267✔
2151

2152
          # fetch top 10 contest coting entries with contest question id based on popular score
2153
          popular_results =
267✔
2154
            if is_nil(question_id) do
249✔
2155
              []
2156
            else
2157
              if leaderboard_open?(assessment, q) or role in @open_all_assessment_roles do
18✔
2158
                fetch_top_popular_score_answers(question_id, visible_entries)
15✔
2159
              else
2160
                []
2161
              end
2162
            end
2163

2164
          leaderboard_results =
267✔
2165
            if is_nil(question_id) do
249✔
2166
              []
2167
            else
2168
              if leaderboard_open?(assessment, q) or role in @open_all_assessment_roles do
18✔
2169
                fetch_top_relative_score_answers(question_id, visible_entries)
15✔
2170
              else
2171
                []
2172
              end
2173
            end
2174

2175
          # populate entries to vote for and leaderboard data into the question
2176
          voting_question =
267✔
2177
            q.question
267✔
2178
            |> Map.put(:contest_entries, submission_votes)
2179
            |> Map.put(
2180
              :contest_leaderboard,
2181
              leaderboard_results
2182
            )
2183
            |> Map.put(
2184
              :popular_leaderboard,
2185
              popular_results
2186
            )
2187

2188
          Map.put(q, :question, voting_question)
267✔
2189
        else
2190
          q
536✔
2191
        end
2192
      end
2193
    )
2194
  end
2195

2196
  defp all_submission_votes_by_question_id_and_voter_id(question_id, voter_id) do
2197
    Logger.info("Fetching all submission votes for question #{question_id} and voter #{voter_id}")
267✔
2198

2199
    SubmissionVotes
2200
    |> where([v], v.voter_id == ^voter_id and v.question_id == ^question_id)
267✔
2201
    |> join(:inner, [v], s in assoc(v, :submission))
267✔
2202
    |> join(:inner, [v, s], a in assoc(s, :answers))
2203
    |> select([v, s, a], %{submission_id: v.submission_id, answer: a.answer, score: v.score})
267✔
2204
    |> Repo.all()
267✔
2205
  end
2206

2207
  # Finds the contest_question_id associated with the given voting_question id
2208
  def fetch_associated_contest_question_id(course_id, voting_question) do
2209
    contest_number = voting_question.question["contest_number"]
272✔
2210

2211
    if is_nil(contest_number) do
272✔
2212
      nil
2213
    else
2214
      Assessment
2215
      |> where(number: ^contest_number, course_id: ^course_id)
272✔
2216
      |> join(:inner, [a], q in assoc(a, :questions))
2217
      |> order_by([a, q], q.display_order)
2218
      |> select([a, q], q.id)
272✔
2219
      |> Repo.one()
272✔
2220
    end
2221
  end
2222

2223
  defp leaderboard_open?(assessment, voting_question) do
2224
    Timex.before?(
36✔
2225
      Timex.shift(assessment.close_at, hours: voting_question.question["reveal_hours"]),
36✔
2226
      Timex.now()
2227
    )
2228
  end
2229

2230
  def fetch_contest_voting_assesment_id(assessment_id) do
2231
    contest_number =
×
2232
      Assessment
2233
      |> where(id: ^assessment_id)
2234
      |> select([a], a.number)
×
2235
      |> Repo.one()
2236

2237
    if is_nil(contest_number) do
×
2238
      nil
2239
    else
2240
      Assessment
2241
      |> join(:inner, [a], q in assoc(a, :questions))
2242
      |> where([a, q], q.question["contest_number"] == ^contest_number)
2243
      |> select([a], a.id)
×
2244
      |> Repo.one()
×
2245
    end
2246
  end
2247

2248
  @doc """
2249
  Fetches all contests for the course id where the voting assessment has been published
2250

2251
  Used for contest leaderboard dropdown fetching
2252
  """
2253
  def fetch_all_contests(course_id) do
2254
    Logger.info("Fetching all contests for course #{course_id}")
×
2255

2256
    contest_numbers =
×
2257
      Question
2258
      |> where(type: :voting)
2259
      |> select([q], q.question["contest_number"])
×
2260
      |> Repo.all()
2261
      |> Enum.reject(&is_nil/1)
×
2262

2263
    if contest_numbers == [] do
×
2264
      []
2265
    else
2266
      Assessment
2267
      |> where([a], a.number in ^contest_numbers and a.course_id == ^course_id)
×
2268
      |> join(:inner, [a], ac in AssessmentConfig, on: a.config_id == ac.id)
2269
      |> where([a, ac], ac.type == "Contests")
2270
      |> select([a], %{contest_id: a.id, title: a.title, published: a.is_published})
×
2271
      |> Repo.all()
×
2272
    end
2273
  end
2274

2275
  @doc """
2276
  Fetches top answers for the given question, based on the contest relative_score
2277

2278
  Used for contest leaderboard fetching
2279
  """
2280
  def fetch_top_relative_score_answers(question_id, number_of_answers) do
2281
    Logger.info("Fetching top relative score answers for question #{question_id}")
24✔
2282

2283
    subquery =
24✔
2284
      Answer
2285
      |> where(question_id: ^question_id)
2286
      |> where(
2287
        [a],
2288
        fragment(
2289
          "?->>'code' like ?",
2290
          a.answer,
2291
          "%return%"
2292
        )
2293
      )
2294
      |> order_by(desc: :relative_score)
24✔
2295
      |> join(:left, [a], s in assoc(a, :submission))
24✔
2296
      |> join(:left, [a, s], student in assoc(s, :student))
24✔
2297
      |> join(:inner, [a, s, student], student_user in assoc(student, :user))
2298
      |> where([a, s, student], student.role == "student")
2299
      |> select([a, s, student, student_user], %{
24✔
2300
        submission_id: a.submission_id,
2301
        answer: a.answer,
2302
        relative_score: a.relative_score,
2303
        student_name: student_user.name,
2304
        student_username: student_user.username,
2305
        rank: fragment("RANK() OVER (ORDER BY ? DESC)", a.relative_score)
2306
      })
2307

2308
    final_query =
24✔
2309
      from(r in subquery(subquery),
24✔
2310
        where: r.rank <= ^number_of_answers
2311
      )
2312

2313
    Repo.all(final_query)
24✔
2314
  end
2315

2316
  @doc """
2317
  Fetches top answers for the given question, based on the contest popular_score
2318

2319
  Used for contest leaderboard fetching
2320
  """
2321
  def fetch_top_popular_score_answers(question_id, number_of_answers) do
2322
    Logger.info("Fetching top popular score answers for question #{question_id}")
16✔
2323

2324
    subquery =
16✔
2325
      Answer
2326
      |> where(question_id: ^question_id)
2327
      |> where(
2328
        [a],
2329
        fragment(
2330
          "?->>'code' like ?",
2331
          a.answer,
2332
          "%return%"
2333
        )
2334
      )
2335
      |> order_by(desc: :popular_score)
16✔
2336
      |> join(:left, [a], s in assoc(a, :submission))
16✔
2337
      |> join(:left, [a, s], student in assoc(s, :student))
16✔
2338
      |> join(:inner, [a, s, student], student_user in assoc(student, :user))
2339
      |> where([a, s, student], student.role == "student")
2340
      |> select([a, s, student, student_user], %{
16✔
2341
        submission_id: a.submission_id,
2342
        answer: a.answer,
2343
        popular_score: a.popular_score,
2344
        student_name: student_user.name,
2345
        student_username: student_user.username,
2346
        rank: fragment("RANK() OVER (ORDER BY ? DESC)", a.popular_score)
2347
      })
2348

2349
    final_query =
16✔
2350
      from(r in subquery(subquery),
16✔
2351
        where: r.rank <= ^number_of_answers
2352
      )
2353

2354
    Repo.all(final_query)
16✔
2355
  end
2356

2357
  @doc """
2358
  Computes rolling leaderboard for contest votes that are still open.
2359
  """
2360
  def update_rolling_contest_leaderboards do
2361
    Logger.info("Updating rolling contest leaderboards")
1✔
2362
    # 115 = 2 hours - 5 minutes is default.
2363
    if Log.log_execution("update_rolling_contest_leaderboards", Duration.from_minutes(115)) do
1✔
2364
      Logger.info("Started update_rolling_contest_leaderboards")
1✔
2365

2366
      voting_questions_to_update = fetch_active_voting_questions()
1✔
2367

2368
      _ =
1✔
2369
        voting_questions_to_update
2370
        |> Enum.map(fn qn -> compute_relative_score(qn.id) end)
1✔
2371

2372
      Logger.info("Successfully update_rolling_contest_leaderboards")
1✔
2373
    end
2374
  end
2375

2376
  def fetch_active_voting_questions do
2377
    Question
2378
    |> join(:left, [q], a in assoc(q, :assessment))
2379
    |> where([q, a], q.type == "voting")
2380
    |> where([q, a], a.is_published)
2381
    |> where([q, a], a.open_at <= ^Timex.now() and a.close_at >= ^Timex.now())
2✔
2382
    |> Repo.all()
2✔
2383
  end
2384

2385
  @doc """
2386
  Computes final leaderboard for contest votes that have closed.
2387
  """
2388
  def update_final_contest_leaderboards do
2389
    Logger.info("Updating final contest leaderboards")
1✔
2390
    # 1435 = 24 hours - 5 minutes
2391
    if Log.log_execution("update_final_contest_leaderboards", Duration.from_minutes(1435)) do
1✔
2392
      Logger.info("Started update_final_contest_leaderboards")
1✔
2393

2394
      voting_questions_to_update = fetch_voting_questions_due_yesterday() || []
1✔
2395

2396
      voting_questions_to_update =
1✔
2397
        if is_nil(voting_questions_to_update), do: [], else: voting_questions_to_update
1✔
2398

2399
      scores =
1✔
2400
        Enum.map(voting_questions_to_update, fn qn ->
2401
          compute_relative_score(qn.id)
1✔
2402
        end)
2403

2404
      if Enum.empty?(voting_questions_to_update) do
1✔
2405
        Logger.warn("No voting questions to update.")
×
2406
      else
2407
        # Process each voting question
2408
        Enum.each(voting_questions_to_update, fn qn ->
1✔
2409
          assign_winning_contest_entries_xp(qn.id)
1✔
2410
        end)
2411

2412
        Logger.info("Successfully update_final_contest_leaderboards")
1✔
2413
      end
2414

2415
      scores
1✔
2416
    end
2417
  end
2418

2419
  def fetch_voting_questions_due_yesterday do
2420
    Question
2421
    |> join(:left, [q], a in assoc(q, :assessment))
2422
    |> where([q, a], q.type == "voting")
2423
    |> where([q, a], a.is_published)
2424
    |> where([q, a], a.open_at <= ^Timex.now())
2425
    |> where(
2✔
2426
      [q, a],
2427
      a.close_at < ^Timex.now() and a.close_at >= ^Timex.shift(Timex.now(), days: -1)
2428
    )
2429
    |> Repo.all()
2✔
2430
  end
2431

2432
  @doc """
2433
  Automatically assigns XP to the winning contest entries
2434
  """
2435
  def assign_winning_contest_entries_xp(contest_voting_question_id) do
2436
    Logger.info(
6✔
2437
      "Assigning XP to winning contest entries for question #{contest_voting_question_id}"
×
2438
    )
2439

2440
    voting_questions =
6✔
2441
      Question
2442
      |> where(type: :voting)
2443
      |> where(id: ^contest_voting_question_id)
6✔
2444
      |> Repo.one()
2445

2446
    contest_question_id =
6✔
2447
      SubmissionVotes
2448
      |> where(question_id: ^contest_voting_question_id)
6✔
2449
      |> join(:inner, [sv], ans in Answer, on: sv.submission_id == ans.submission_id)
2450
      |> select([sv, ans], ans.question_id)
2451
      |> limit(1)
6✔
2452
      |> Repo.one()
2453

2454
    if is_nil(contest_question_id) do
6✔
2455
      Logger.warn("Contest question ID is missing. Terminating.")
×
2456
      :ok
2457
    else
2458
      default_xp_values = %Cadet.Assessments.QuestionTypes.VotingQuestion{} |> Map.get(:xp_values)
6✔
2459
      scores = voting_questions.question["xp_values"] || default_xp_values
6✔
2460

2461
      if scores == [] do
6✔
2462
        Logger.warn("No XP values provided. Terminating.")
×
2463
        :ok
2464
      else
2465
        Repo.transaction(fn ->
6✔
2466
          submission_ids =
6✔
2467
            Answer
2468
            |> where(question_id: ^contest_question_id)
2469
            |> select([a], a.submission_id)
6✔
2470
            |> Repo.all()
2471

2472
          Submission
2473
          |> where([s], s.id in ^submission_ids)
6✔
2474
          |> Repo.update_all(set: [is_grading_published: true])
6✔
2475

2476
          winning_popular_entries =
6✔
2477
            Answer
2478
            |> where(question_id: ^contest_question_id)
2479
            |> select([a], %{
6✔
2480
              id: a.id,
2481
              rank: fragment("rank() OVER (ORDER BY ? DESC)", a.popular_score)
2482
            })
2483
            |> Repo.all()
2484

2485
          winning_popular_entries
2486
          |> Enum.each(fn %{id: answer_id, rank: rank} ->
6✔
2487
            increment = Enum.at(scores, rank - 1, 0)
30✔
2488
            answer = Repo.get!(Answer, answer_id)
30✔
2489
            Repo.update!(Changeset.change(answer, %{xp_adjustment: increment}))
30✔
2490
          end)
2491

2492
          winning_score_entries =
6✔
2493
            Answer
2494
            |> where(question_id: ^contest_question_id)
2495
            |> select([a], %{
6✔
2496
              id: a.id,
2497
              rank: fragment("rank() OVER (ORDER BY ? DESC)", a.relative_score)
2498
            })
2499
            |> Repo.all()
2500

2501
          winning_score_entries
2502
          |> Enum.each(fn %{id: answer_id, rank: rank} ->
6✔
2503
            increment = Enum.at(scores, rank - 1, 0)
30✔
2504
            answer = Repo.get!(Answer, answer_id)
30✔
2505
            new_value = answer.xp_adjustment + increment
30✔
2506
            Repo.update!(Changeset.change(answer, %{xp_adjustment: new_value}))
30✔
2507
          end)
2508
        end)
2509

2510
        Logger.info("XP assigned to winning contest entries")
6✔
2511
      end
2512
    end
2513
  end
2514

2515
  @doc """
2516
  Computes the current relative_score of each voting submission answer
2517
  based on current submitted votes.
2518
  """
2519
  def compute_relative_score(contest_voting_question_id) do
2520
    Logger.info(
3✔
2521
      "Computing relative score for contest voting question #{contest_voting_question_id}"
×
2522
    )
2523

2524
    # reset all scores to 0 first
2525
    voting_questions =
3✔
2526
      Question
2527
      |> where(type: :voting)
2528
      |> where(id: ^contest_voting_question_id)
3✔
2529
      |> Repo.one()
2530

2531
    if is_nil(voting_questions) do
3✔
2532
      IO.puts("Voting question not found, skipping score computation.")
×
2533
      :ok
2534
    else
2535
      course_id =
3✔
2536
        Assessment
2537
        |> where(id: ^voting_questions.assessment_id)
3✔
2538
        |> select([a], a.course_id)
3✔
2539
        |> Repo.one()
2540

2541
      if is_nil(course_id) do
3✔
2542
        IO.puts("Course ID not found, skipping score computation.")
×
2543
        :ok
2544
      else
2545
        contest_question_id = fetch_associated_contest_question_id(course_id, voting_questions)
3✔
2546

2547
        if !is_nil(contest_question_id) do
3✔
2548
          # reset all scores to 0 first
2549
          Answer
2550
          |> where([ans], ans.question_id == ^contest_question_id)
2551
          |> update([ans], set: [popular_score: 0.0, relative_score: 0.0])
×
2552
          |> Repo.update_all([])
×
2553
        end
2554
      end
2555
    end
2556

2557
    # query all records from submission votes tied to the question id ->
2558
    # map score to user id ->
2559
    # store as grade ->
2560
    # query grade for contest question id.
2561
    eligible_votes =
3✔
2562
      SubmissionVotes
2563
      |> where(question_id: ^contest_voting_question_id)
2564
      |> where([sv], not is_nil(sv.score))
3✔
2565
      |> join(:inner, [sv], ans in Answer, on: sv.submission_id == ans.submission_id)
2566
      |> select(
3✔
2567
        [sv, ans],
2568
        %{ans_id: ans.id, score: sv.score, ans: ans.answer["code"]}
2569
      )
2570
      |> Repo.all()
2571

2572
    token_divider =
3✔
2573
      Question
2574
      |> select([q], q.question["token_divider"])
3✔
2575
      |> Repo.get_by(id: contest_voting_question_id)
2576

2577
    entry_scores = map_eligible_votes_to_entry_score(eligible_votes, token_divider)
3✔
2578
    normalized_scores = map_eligible_votes_to_popular_score(eligible_votes, token_divider)
3✔
2579

2580
    entry_scores
2581
    |> Enum.map(fn {ans_id, relative_score} ->
2582
      %Answer{id: ans_id}
2583
      |> Answer.contest_score_update_changeset(%{
15✔
2584
        relative_score: relative_score
2585
      })
2586
    end)
2587
    |> Enum.map(fn changeset ->
2588
      op_key = "answer_#{changeset.data.id}"
15✔
2589
      Multi.update(Multi.new(), op_key, changeset)
15✔
2590
    end)
2591
    |> Enum.reduce(Multi.new(), &Multi.append/2)
2592
    |> Repo.transaction()
3✔
2593

2594
    normalized_scores
2595
    |> Enum.map(fn {ans_id, popular_score} ->
2596
      %Answer{id: ans_id}
2597
      |> Answer.popular_score_update_changeset(%{
15✔
2598
        popular_score: popular_score
2599
      })
2600
    end)
2601
    |> Enum.map(fn changeset ->
2602
      op_key = "answer_#{changeset.data.id}"
15✔
2603
      Multi.update(Multi.new(), op_key, changeset)
15✔
2604
    end)
2605
    |> Enum.reduce(Multi.new(), &Multi.append/2)
2606
    |> Repo.transaction()
3✔
2607
  end
2608

2609
  defp map_eligible_votes_to_entry_score(eligible_votes, token_divider) do
2610
    Logger.info("Mapping eligible votes to entry scores")
3✔
2611
    # converts eligible votes to the {total cumulative score, number of votes, tokens}
2612
    entry_vote_data =
3✔
2613
      Enum.reduce(eligible_votes, %{}, fn %{ans_id: ans_id, score: score, ans: ans}, tracker ->
2614
        {prev_score, prev_count, _ans_tokens} = Map.get(tracker, ans_id, {0, 0, 0})
75✔
2615

2616
        Map.put(
75✔
2617
          tracker,
2618
          ans_id,
2619
          # assume each voter is assigned 10 entries which will make it fair.
2620
          {prev_score + score, prev_count + 1, Lexer.count_tokens(ans)}
2621
        )
2622
      end)
2623

2624
    # calculate the score based on formula {ans_id, score}
2625
    Enum.map(
3✔
2626
      entry_vote_data,
2627
      fn {ans_id, {sum_of_scores, number_of_voters, tokens}} ->
15✔
2628
        {ans_id, calculate_formula_score(sum_of_scores, number_of_voters, tokens, token_divider)}
2629
      end
2630
    )
2631
  end
2632

2633
  defp map_eligible_votes_to_popular_score(eligible_votes, token_divider) do
2634
    Logger.info("Mapping eligible votes to popular scores")
3✔
2635
    # converts eligible votes to the {total cumulative score, number of votes, tokens}
2636
    entry_vote_data =
3✔
2637
      Enum.reduce(eligible_votes, %{}, fn %{ans_id: ans_id, score: score, ans: ans}, tracker ->
2638
        {prev_score, prev_count, _ans_tokens} = Map.get(tracker, ans_id, {0, 0, 0})
75✔
2639

2640
        Map.put(
75✔
2641
          tracker,
2642
          ans_id,
2643
          # assume each voter is assigned 10 entries which will make it fair.
2644
          {prev_score + score, prev_count + 1, Lexer.count_tokens(ans)}
2645
        )
2646
      end)
2647

2648
    # calculate the score based on formula {ans_id, score}
2649
    Enum.map(
3✔
2650
      entry_vote_data,
2651
      fn {ans_id, {sum_of_scores, number_of_voters, tokens}} ->
15✔
2652
        {ans_id,
2653
         calculate_normalized_score(sum_of_scores, number_of_voters, tokens, token_divider)}
2654
      end
2655
    )
2656
  end
2657

2658
  # Calculate the score based on formula
2659
  # score(v,t) = v - 2^(t/token_divider) where v is the normalized_voting_score
2660
  # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100
2661
  defp calculate_formula_score(sum_of_scores, number_of_voters, tokens, token_divider) do
2662
    Logger.info("Calculating formula score")
15✔
2663

2664
    normalized_voting_score =
15✔
2665
      calculate_normalized_score(sum_of_scores, number_of_voters, tokens, token_divider)
2666

2667
    normalized_voting_score - :math.pow(2, min(1023.5, tokens / token_divider))
15✔
2668
  end
2669

2670
  # Calculate the normalized score based on formula
2671
  # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100
2672
  defp calculate_normalized_score(sum_of_scores, number_of_voters, _tokens, _token_divider) do
2673
    Logger.info("Calculating normalized score")
30✔
2674
    sum_of_scores / number_of_voters / 10 * 100
30✔
2675
  end
2676

2677
  @doc """
2678
  Function returning submissions under a grader. This function returns only the
2679
  fields that are exposed in the /grading endpoint.
2680

2681
  The input parameters are the user and query parameters. Query parameters are
2682
  used to filter the submissions.
2683

2684
  The return value is `{:ok, %{"count": count, "data": submissions}}`
2685

2686
  # Params
2687
  Refer to admin_grading_controller.ex/index for the list of query parameters.
2688

2689
  # Implementation
2690
  Uses helper functions to build the filter query. Helper functions are separated by tables in the database.
2691
  """
2692

2693
  @spec submissions_by_grader_for_index(CourseRegistration.t(), map()) ::
2694
          {:ok,
2695
           %{
2696
             :count => integer,
2697
             :data => %{
2698
               :assessments => [any()],
2699
               :submissions => [any()],
2700
               :users => [any()],
2701
               :teams => [any()],
2702
               :team_members => [any()]
2703
             }
2704
           }}
2705
  def submissions_by_grader_for_index(
2706
        grader = %CourseRegistration{course_id: course_id},
2707
        params
2708
      ) do
2709
    submission_answers_query =
41✔
2710
      from(ans in Answer,
2711
        group_by: ans.submission_id,
2712
        select: %{
2713
          submission_id: ans.submission_id,
2714
          xp: sum(ans.xp),
2715
          xp_adjustment: sum(ans.xp_adjustment),
2716
          graded_count: filter(count(ans.id), not is_nil(ans.grader_id))
2717
        }
2718
      )
2719

2720
    question_answers_query =
41✔
2721
      from(q in Question,
41✔
2722
        group_by: q.assessment_id,
2723
        join: a in Assessment,
2724
        on: q.assessment_id == a.id,
2725
        select: %{
2726
          assessment_id: q.assessment_id,
2727
          question_count: count(q.id),
2728
          title: max(a.title),
2729
          config_id: max(a.config_id)
2730
        }
2731
      )
2732

2733
    query =
41✔
2734
      from(s in Submission,
41✔
2735
        left_join: ans in subquery(submission_answers_query),
2736
        on: ans.submission_id == s.id,
2737
        as: :ans,
2738
        left_join: asst in subquery(question_answers_query),
2739
        on: asst.assessment_id == s.assessment_id,
2740
        as: :asst,
2741
        left_join: cr in CourseRegistration,
2742
        on: s.student_id == cr.id,
2743
        as: :cr,
2744
        left_join: user in User,
2745
        on: user.id == cr.user_id,
2746
        as: :user,
2747
        left_join: group in Group,
2748
        on: cr.group_id == group.id,
2749
        as: :group,
2750
        inner_join: config in AssessmentConfig,
2751
        on: asst.config_id == config.id,
2752
        as: :config,
2753
        where: ^build_user_filter(params),
2754
        where: s.assessment_id in subquery(build_assessment_filter(params, course_id)),
2755
        where: s.assessment_id in subquery(build_assessment_config_filter(params)),
2756
        where: ^build_submission_filter(params),
2757
        where: ^build_course_registration_filter(params, grader),
2758
        limit: ^params[:page_size],
2759
        offset: ^params[:offset],
2760
        select: %{
2761
          id: s.id,
2762
          status: s.status,
2763
          xp_bonus: s.xp_bonus,
2764
          unsubmitted_at: s.unsubmitted_at,
2765
          unsubmitted_by_id: s.unsubmitted_by_id,
2766
          student_id: s.student_id,
2767
          team_id: s.team_id,
2768
          assessment_id: s.assessment_id,
2769
          is_grading_published: s.is_grading_published,
2770
          xp: ans.xp,
2771
          xp_adjustment: ans.xp_adjustment,
2772
          graded_count: ans.graded_count,
2773
          question_count: asst.question_count
2774
        }
2775
      )
2776

2777
    query = sort_submission(query, params[:sort_by], params[:sort_direction])
41✔
2778

2779
    query =
41✔
2780
      from([s, ans, asst, cr, user, group] in query, order_by: [desc: s.inserted_at, asc: s.id])
41✔
2781

2782
    submissions = Repo.all(query)
41✔
2783

2784
    count_query =
41✔
2785
      from(s in Submission,
41✔
2786
        left_join: ans in subquery(submission_answers_query),
2787
        on: ans.submission_id == s.id,
2788
        as: :ans,
2789
        left_join: asst in subquery(question_answers_query),
2790
        on: asst.assessment_id == s.assessment_id,
2791
        as: :asst,
2792
        where: s.assessment_id in subquery(build_assessment_filter(params, course_id)),
2793
        where: s.assessment_id in subquery(build_assessment_config_filter(params)),
2794
        where: ^build_user_filter(params),
2795
        where: ^build_submission_filter(params),
2796
        where: ^build_course_registration_filter(params, grader),
2797
        select: count(s.id)
2798
      )
2799

2800
    count = Repo.one(count_query)
41✔
2801

2802
    {:ok, %{count: count, data: generate_grading_summary_view_model(submissions, course_id)}}
2803
  end
2804

2805
  # Given a query from submissions_by_grader_for_index,
2806
  # sorts it by the relevant field and direction.
2807
  defp sort_submission(query, sort_by, sort_direction)
2808
       when sort_direction in [:asc, :desc] do
2809
    case sort_by do
6✔
2810
      :assessment_name ->
2811
        from([s, ans, asst, cr, user, group, config] in query,
2✔
2812
          order_by: [{^sort_direction, fragment("upper(?)", asst.title)}]
2813
        )
2814

2815
      :assessment_type ->
2816
        from([s, ans, asst, cr, user, group, config] in query,
2✔
2817
          order_by: [{^sort_direction, asst.config_id}]
2818
        )
2819

2820
      :student_name ->
2821
        from([s, ans, asst, cr, user, group, config] in query,
×
2822
          order_by: [{^sort_direction, fragment("upper(?)", user.name)}]
2823
        )
2824

2825
      :student_username ->
2826
        from([s, ans, asst, cr, user, group, config] in query,
×
2827
          order_by: [{^sort_direction, fragment("upper(?)", user.username)}]
2828
        )
2829

2830
      :group_name ->
2831
        from([s, ans, asst, cr, user, group, config] in query,
×
2832
          order_by: [{^sort_direction, fragment("upper(?)", group.name)}]
2833
        )
2834

2835
      :progress_status ->
2836
        from([s, ans, asst, cr, user, group, config] in query,
×
2837
          order_by: [
2838
            {^sort_direction, config.is_manually_graded},
2839
            {^sort_direction, s.status},
2840
            {^sort_direction, ans.graded_count - asst.question_count},
2841
            {^sort_direction, s.is_grading_published}
2842
          ]
2843
        )
2844

2845
      :xp ->
2846
        from([s, ans, asst, cr, user, group, config] in query,
2✔
2847
          order_by: [{^sort_direction, ans.xp + ans.xp_adjustment}]
2848
        )
2849

2850
      _ ->
2851
        query
×
2852
    end
2853
  end
2854

2855
  defp sort_submission(query, _sort_by, _sort_direction), do: query
35✔
2856

2857
  def parse_sort_direction(params) do
2858
    case params[:sort_direction] do
5✔
2859
      "sort-asc" -> Map.put(params, :sort_direction, :asc)
×
2860
      "sort-desc" -> Map.put(params, :sort_direction, :desc)
×
2861
      _ -> Map.put(params, :sort_direction, nil)
5✔
2862
    end
2863
  end
2864

2865
  def parse_sort_by(params) do
2866
    case params[:sort_by] do
5✔
2867
      "assessmentName" -> Map.put(params, :sort_by, :assessment_name)
×
2868
      "assessmentType" -> Map.put(params, :sort_by, :assessment_type)
×
2869
      "studentName" -> Map.put(params, :sort_by, :student_name)
×
2870
      "studentUsername" -> Map.put(params, :sort_by, :student_username)
×
2871
      "groupName" -> Map.put(params, :sort_by, :group_name)
×
2872
      "progressStatus" -> Map.put(params, :sort_by, :progress_status)
×
2873
      "xp" -> Map.put(params, :sort_by, :xp)
×
2874
      _ -> Map.put(params, :sort_by, nil)
5✔
2875
    end
2876
  end
2877

2878
  defp build_assessment_filter(params, course_id) do
2879
    assessments_filters =
82✔
2880
      Enum.reduce(params, dynamic(true), fn
82✔
2881
        {:title, value}, dynamic ->
2882
          dynamic([assessment], ^dynamic and ilike(assessment.title, ^"%#{value}%"))
2✔
2883

2884
        {_, _}, dynamic ->
2885
          dynamic
206✔
2886
      end)
2887

2888
    from(a in Assessment,
82✔
2889
      where: a.course_id == ^course_id,
2890
      where: ^assessments_filters,
2891
      select: a.id
2892
    )
2893
  end
2894

2895
  defp build_submission_filter(params) do
2896
    Enum.reduce(params, dynamic(true), fn
82✔
2897
      {:status, value}, dynamic ->
2898
        dynamic([submission], ^dynamic and submission.status == ^value)
6✔
2899

2900
      {:is_fully_graded, value}, dynamic ->
2901
        dynamic(
4✔
2902
          [ans: ans, asst: asst],
2903
          ^dynamic and asst.question_count == ans.graded_count == ^value
2904
        )
2905

2906
      {:is_grading_published, value}, dynamic ->
2907
        dynamic([submission], ^dynamic and submission.is_grading_published == ^value)
4✔
2908

2909
      {_, _}, dynamic ->
2910
        dynamic
194✔
2911
    end)
2912
  end
2913

2914
  defp build_course_registration_filter(params, grader) do
2915
    Enum.reduce(params, dynamic(true), fn
82✔
2916
      {:group, true}, dynamic ->
2917
        dynamic(
10✔
2918
          [submission],
2919
          (^dynamic and
2920
             submission.student_id in subquery(
2921
               from(cr in CourseRegistration,
10✔
2922
                 join: g in Group,
2923
                 on: cr.group_id == g.id,
2924
                 where: g.leader_id == ^grader.id,
10✔
2925
                 select: cr.id
2926
               )
2927
             )) or submission.student_id == ^grader.id
10✔
2928
        )
2929

2930
      {:group_name, value}, dynamic ->
2931
        dynamic(
4✔
2932
          [submission],
2933
          ^dynamic and
2934
            submission.student_id in subquery(
2935
              from(cr in CourseRegistration,
4✔
2936
                join: g in Group,
2937
                on: cr.group_id == g.id,
2938
                where: g.name == ^value,
2939
                select: cr.id
2940
              )
2941
            )
2942
        )
2943

2944
      {_, _}, dynamic ->
2945
        dynamic
194✔
2946
    end)
2947
  end
2948

2949
  defp build_user_filter(params) do
2950
    Enum.reduce(params, dynamic(true), fn
82✔
2951
      {:name, value}, dynamic ->
2952
        dynamic(
6✔
2953
          [submission],
2954
          ^dynamic and
2955
            submission.student_id in subquery(
2956
              from(user in User,
6✔
2957
                where: ilike(user.name, ^"%#{value}%"),
6✔
2958
                inner_join: cr in CourseRegistration,
2959
                on: user.id == cr.user_id,
2960
                select: cr.id
2961
              )
2962
            )
2963
        )
2964

2965
      {:username, value}, dynamic ->
2966
        dynamic(
6✔
2967
          [submission],
2968
          ^dynamic and
2969
            submission.student_id in subquery(
2970
              from(user in User,
6✔
2971
                where: ilike(user.username, ^"%#{value}%"),
6✔
2972
                inner_join: cr in CourseRegistration,
2973
                on: user.id == cr.user_id,
2974
                select: cr.id
2975
              )
2976
            )
2977
        )
2978

2979
      {_, _}, dynamic ->
2980
        dynamic
196✔
2981
    end)
2982
  end
2983

2984
  defp build_assessment_config_filter(params) do
2985
    assessment_config_filters =
82✔
2986
      Enum.reduce(params, dynamic(true), fn
82✔
2987
        {:type, value}, dynamic ->
2988
          dynamic([assessment_config: config], ^dynamic and config.type == ^value)
6✔
2989

2990
        {:is_manually_graded, value}, dynamic ->
2991
          dynamic([assessment_config: config], ^dynamic and config.is_manually_graded == ^value)
4✔
2992

2993
        {_, _}, dynamic ->
2994
          dynamic
198✔
2995
      end)
2996

2997
    from(a in Assessment,
82✔
2998
      inner_join: config in AssessmentConfig,
2999
      on: a.config_id == config.id,
3000
      as: :assessment_config,
3001
      where: ^assessment_config_filters,
3002
      select: a.id
3003
    )
3004
  end
3005

3006
  defp generate_grading_summary_view_model(submissions, course_id) do
3007
    users =
41✔
3008
      CourseRegistration
3009
      |> where([cr], cr.course_id == ^course_id)
41✔
3010
      |> join(:inner, [cr], u in assoc(cr, :user))
41✔
3011
      |> join(:left, [cr, u], g in assoc(cr, :group))
3012
      |> preload([cr, u, g], user: u, group: g)
41✔
3013
      |> Repo.all()
3014

3015
    assessment_ids = submissions |> Enum.map(& &1.assessment_id) |> Enum.uniq()
41✔
3016

3017
    assessments =
41✔
3018
      Assessment
3019
      |> where([a], a.id in ^assessment_ids)
41✔
3020
      |> join(:left, [a], q in assoc(a, :questions))
41✔
3021
      |> join(:inner, [a], ac in assoc(a, :config))
3022
      |> preload([a, q, ac], questions: q, config: ac)
41✔
3023
      |> Repo.all()
3024

3025
    team_ids = submissions |> Enum.map(& &1.team_id) |> Enum.uniq()
41✔
3026

3027
    teams =
41✔
3028
      Team
3029
      |> where([t], t.id in ^team_ids)
41✔
3030
      |> Repo.all()
3031

3032
    team_members =
41✔
3033
      TeamMember
3034
      |> where([tm], tm.team_id in ^team_ids)
41✔
3035
      |> Repo.all()
3036

3037
    %{
41✔
3038
      users: users,
3039
      assessments: assessments,
3040
      submissions: submissions,
3041
      teams: teams,
3042
      team_members: team_members
3043
    }
3044
  end
3045

3046
  @spec get_answer(integer() | String.t()) ::
3047
          {:ok, Answer.t()} | {:error, {:bad_request, String.t()}}
3048
  def get_answer(id) when is_ecto_id(id) do
3049
    answer =
4✔
3050
      Answer
3051
      |> where(id: ^id)
4✔
3052
      # [a] are bindings (in SQL it is similar to FROM answers "AS a"),
3053
      # this line's alias is INNER JOIN ... "AS q"
3054
      |> join(:inner, [a], q in assoc(a, :question))
4✔
3055
      |> join(:inner, [_, q], ast in assoc(q, :assessment))
4✔
3056
      |> join(:inner, [..., ast], ac in assoc(ast, :config))
4✔
3057
      |> join(:left, [a, ...], g in assoc(a, :grader))
4✔
3058
      |> join(:left, [_, ..., g], gu in assoc(g, :user))
4✔
3059
      |> join(:inner, [a, ...], s in assoc(a, :submission))
4✔
3060
      |> join(:left, [_, ..., s], st in assoc(s, :student))
4✔
3061
      |> join(:left, [..., st], u in assoc(st, :user))
4✔
3062
      |> join(:left, [..., s, _, _], t in assoc(s, :team))
4✔
3063
      |> join(:left, [..., t], tm in assoc(t, :team_members))
4✔
3064
      |> join(:left, [..., tm], tms in assoc(tm, :student))
4✔
3065
      |> join(:left, [..., tms], tmu in assoc(tms, :user))
4✔
3066
      |> join(:left, [a, ...], ai in assoc(a, :ai_comments))
3067
      |> preload([_, q, ast, ac, g, gu, s, st, u, t, tm, tms, tmu, ai],
4✔
3068
        ai_comments: ai,
3069
        question: {q, assessment: {ast, config: ac}},
3070
        grader: {g, user: gu},
3071
        submission:
3072
          {s, student: {st, user: u}, team: {t, team_members: {tm, student: {tms, user: tmu}}}}
3073
      )
3074
      |> Repo.one()
3075

3076
    if is_nil(answer) do
4✔
3077
      {:error, {:bad_request, "Answer not found."}}
3078
    else
3079
      if answer.question.type == :voting do
3✔
NEW
3080
        empty_contest_entries = Map.put(answer.question.question, :contest_entries, [])
×
NEW
3081
        empty_popular_leaderboard = Map.put(empty_contest_entries, :popular_leaderboard, [])
×
NEW
3082
        empty_contest_leaderboard = Map.put(empty_popular_leaderboard, :contest_leaderboard, [])
×
NEW
3083
        question = Map.put(answer.question, :question, empty_contest_leaderboard)
×
NEW
3084
        Map.put(answer, :question, question)
×
3085
      end
3086

3087
      {:ok, answer}
3088
    end
3089
  end
3090

3091
  @spec get_answers_in_submission(integer() | String.t()) ::
3092
          {:ok, {[Answer.t()], Assessment.t()}}
3093
          | {:error, {:bad_request, String.t()}}
3094
  def get_answers_in_submission(id) when is_ecto_id(id) do
3095
    base_query =
4✔
3096
      Answer
3097
      |> where(submission_id: ^id)
4✔
3098
      # [a] are bindings (in SQL it is similar to FROM answers "AS a"),
3099
      # this line's alias is INNER JOIN ... "AS q"
3100
      |> join(:inner, [a], q in assoc(a, :question))
4✔
3101
      |> join(:inner, [_, q], ast in assoc(q, :assessment))
4✔
3102
      |> join(:inner, [..., ast], ac in assoc(ast, :config))
4✔
3103
      |> join(:left, [a, ...], g in assoc(a, :grader))
4✔
3104
      |> join(:left, [_, ..., g], gu in assoc(g, :user))
4✔
3105
      |> join(:inner, [a, ...], s in assoc(a, :submission))
4✔
3106
      |> join(:left, [_, ..., s], st in assoc(s, :student))
4✔
3107
      |> join(:left, [..., st], u in assoc(st, :user))
4✔
3108
      |> join(:left, [..., s, _, _], t in assoc(s, :team))
4✔
3109
      |> join(:left, [..., t], tm in assoc(t, :team_members))
4✔
3110
      |> join(:left, [..., tm], tms in assoc(tm, :student))
4✔
3111
      |> join(:left, [..., tms], tmu in assoc(tms, :user))
4✔
3112
      |> join(:left, [a, ...], ai in assoc(a, :ai_comments))
3113
      |> preload([_, q, ast, ac, g, gu, s, st, u, t, tm, tms, tmu, ai],
4✔
3114
        question: {q, assessment: {ast, config: ac}},
3115
        grader: {g, user: gu},
3116
        submission:
3117
          {s, student: {st, user: u}, team: {t, team_members: {tm, student: {tms, user: tmu}}}},
3118
        ai_comments: ai
3119
      )
3120

3121
    answers =
4✔
3122
      base_query
3123
      |> Repo.all()
3124
      |> Enum.sort_by(& &1.question.display_order)
10✔
3125
      |> Enum.map(fn ans ->
3126
        if ans.question.type == :voting do
10✔
3127
          empty_contest_entries = Map.put(ans.question.question, :contest_entries, [])
2✔
3128
          empty_popular_leaderboard = Map.put(empty_contest_entries, :popular_leaderboard, [])
2✔
3129
          empty_contest_leaderboard = Map.put(empty_popular_leaderboard, :contest_leaderboard, [])
2✔
3130
          question = Map.put(ans.question, :question, empty_contest_leaderboard)
2✔
3131
          Map.put(ans, :question, question)
2✔
3132
        else
3133
          ans
8✔
3134
        end
3135
      end)
3136

3137
    if answers == [] do
4✔
3138
      {:error, {:bad_request, "Submission is not found."}}
3139
    else
3140
      assessment_id = Submission |> where(id: ^id) |> select([s], s.assessment_id) |> Repo.one()
2✔
3141
      assessment = Assessment |> where(id: ^assessment_id) |> Repo.one()
2✔
3142
      {:ok, {answers, assessment}}
3143
    end
3144
  end
3145

3146
  defp is_fully_graded?(submission_id) do
3147
    submission =
6✔
3148
      Submission
3149
      |> Repo.get_by(id: submission_id)
3150

3151
    question_count =
6✔
3152
      Question
3153
      |> where(assessment_id: ^submission.assessment_id)
6✔
3154
      |> select([q], count(q.id))
6✔
3155
      |> Repo.one()
3156

3157
    graded_count =
6✔
3158
      Answer
3159
      |> where([a], submission_id: ^submission_id)
3160
      |> where([a], not is_nil(a.grader_id))
3161
      |> select([a], count(a.id))
6✔
3162
      |> Repo.one()
3163

3164
    question_count == graded_count
6✔
3165
  end
3166

3167
  def is_fully_autograded?(submission_id) do
3168
    submission =
59✔
3169
      Submission
3170
      |> Repo.get_by(id: submission_id)
3171

3172
    question_count =
59✔
3173
      Question
3174
      |> where(assessment_id: ^submission.assessment_id)
59✔
3175
      |> select([q], count(q.id))
59✔
3176
      |> Repo.one()
3177

3178
    graded_count =
59✔
3179
      Answer
3180
      |> where([a], submission_id: ^submission_id)
3181
      |> where([a], a.autograding_status == :success)
3182
      |> select([a], count(a.id))
59✔
3183
      |> Repo.one()
3184

3185
    question_count == graded_count
59✔
3186
  end
3187

3188
  @spec update_grading_info(
3189
          %{submission_id: integer() | String.t(), question_id: integer() | String.t()},
3190
          %{},
3191
          CourseRegistration.t()
3192
        ) ::
3193
          {:ok, nil}
3194
          | {:error, {:forbidden | :bad_request | :internal_server_error, String.t()}}
3195
  def update_grading_info(
3196
        %{submission_id: submission_id, question_id: question_id},
3197
        attrs,
3198
        cr = %CourseRegistration{id: grader_id}
3199
      )
3200
      when is_ecto_id(submission_id) and is_ecto_id(question_id) do
3201
    attrs = Map.put(attrs, "grader_id", grader_id)
8✔
3202

3203
    answer_query =
8✔
3204
      Answer
3205
      |> where(submission_id: ^submission_id)
3206
      |> where(question_id: ^question_id)
8✔
3207

3208
    answer_query =
8✔
3209
      answer_query
3210
      |> join(:inner, [a], s in assoc(a, :submission))
3211
      |> preload([_, s], submission: s)
8✔
3212

3213
    answer = Repo.one(answer_query)
8✔
3214

3215
    is_own_submission = grader_id == answer.submission.student_id
8✔
3216

3217
    submission =
8✔
3218
      Submission
3219
      |> join(:inner, [s], a in assoc(s, :assessment))
3220
      |> preload([_, a], assessment: {a, :config})
8✔
3221
      |> Repo.get(submission_id)
3222

3223
    is_grading_auto_published = submission.assessment.config.is_grading_auto_published
8✔
3224

3225
    with {:answer_found?, true} <- {:answer_found?, is_map(answer)},
8✔
3226
         {:status, true} <-
8✔
3227
           {:status, answer.submission.status == :submitted or is_own_submission},
8✔
3228
         {:valid, changeset = %Ecto.Changeset{valid?: true}} <-
7✔
3229
           {:valid, Answer.grading_changeset(answer, attrs)},
3230
         {:ok, _} <- Repo.update(changeset) do
6✔
3231
      update_xp_bonus(submission)
6✔
3232

3233
      if is_grading_auto_published and is_fully_graded?(submission_id) do
6✔
3234
        publish_grading(submission_id, cr)
×
3235
      end
3236

3237
      {:ok, nil}
3238
    else
3239
      {:answer_found?, false} ->
×
3240
        {:error, {:bad_request, "Answer not found or user not permitted to grade."}}
3241

3242
      {:valid, changeset} ->
1✔
3243
        {:error, {:bad_request, full_error_messages(changeset)}}
3244

3245
      {:status, _} ->
1✔
3246
        {:error, {:method_not_allowed, "Submission is not submitted yet."}}
3247

3248
      {:error, _} ->
×
3249
        {:error, {:internal_server_error, "Please try again later."}}
3250
    end
3251
  end
3252

3253
  def update_grading_info(
1✔
3254
        _,
3255
        _,
3256
        _
3257
      ) do
3258
    {:error, {:forbidden, "User is not permitted to grade."}}
3259
  end
3260

3261
  @spec force_regrade_submission(integer() | String.t(), CourseRegistration.t()) ::
3262
          {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}}
3263
  def force_regrade_submission(
3264
        submission_id,
3265
        _requesting_user = %CourseRegistration{id: grader_id}
3266
      )
3267
      when is_ecto_id(submission_id) do
3268
    with {:get, sub} when not is_nil(sub) <- {:get, Repo.get(Submission, submission_id)},
2✔
3269
         {:status, true} <- {:status, sub.student_id == grader_id or sub.status == :submitted} do
1✔
3270
      GradingJob.force_grade_individual_submission(sub, true)
1✔
3271
      {:ok, nil}
3272
    else
3273
      {:get, nil} ->
1✔
3274
        {:error, {:not_found, "Submission not found"}}
3275

3276
      {:status, false} ->
×
3277
        {:error, {:bad_request, "Submission not submitted yet"}}
3278
    end
3279
  end
3280

3281
  def force_regrade_submission(_, _) do
×
3282
    {:error, {:forbidden, "User is not permitted to grade."}}
3283
  end
3284

3285
  @spec force_regrade_answer(
3286
          integer() | String.t(),
3287
          integer() | String.t(),
3288
          CourseRegistration.t()
3289
        ) ::
3290
          {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}}
3291
  def force_regrade_answer(
3292
        submission_id,
3293
        question_id,
3294
        _requesting_user = %CourseRegistration{id: grader_id}
3295
      )
3296
      when is_ecto_id(submission_id) and is_ecto_id(question_id) do
3297
    answer =
2✔
3298
      Answer
3299
      |> where(submission_id: ^submission_id, question_id: ^question_id)
3300
      |> preload([:question, :submission])
2✔
3301
      |> Repo.one()
3302

3303
    with {:get, answer} when not is_nil(answer) <- {:get, answer},
2✔
3304
         {:status, true} <-
1✔
3305
           {:status,
3306
            answer.submission.student_id == grader_id or answer.submission.status == :submitted} do
1✔
3307
      GradingJob.grade_answer(answer, answer.question, true)
1✔
3308
      {:ok, nil}
3309
    else
3310
      {:get, nil} ->
1✔
3311
        {:error, {:not_found, "Answer not found"}}
3312

3313
      {:status, false} ->
×
3314
        {:error, {:bad_request, "Submission not submitted yet"}}
3315
    end
3316
  end
3317

3318
  def force_regrade_answer(_, _, _) do
×
3319
    {:error, {:forbidden, "User is not permitted to grade."}}
3320
  end
3321

3322
  defp find_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do
3323
    {:ok, team} = find_team(assessment.id, cr.id)
68✔
3324

3325
    submission =
68✔
3326
      case team do
3327
        %Team{} ->
3328
          Submission
3329
          |> where(team_id: ^team.id)
2✔
3330
          |> where(assessment_id: ^assessment.id)
2✔
3331
          |> Repo.one()
2✔
3332

3333
        nil ->
3334
          Submission
3335
          |> where(student_id: ^cr.id)
66✔
3336
          |> where(assessment_id: ^assessment.id)
66✔
3337
          |> Repo.one()
66✔
3338
      end
3339

3340
    if submission do
68✔
3341
      {:ok, submission}
3342
    else
3343
      {:error, nil}
3344
    end
3345
  end
3346

3347
  # Checks if an assessment is open and published.
3348
  @spec is_open?(Assessment.t()) :: boolean()
3349
  def is_open?(%Assessment{open_at: open_at, close_at: close_at, is_published: is_published}) do
3350
    Timex.between?(Timex.now(), open_at, close_at, inclusive: :start) and is_published
390✔
3351
  end
3352

3353
  @spec get_group_grading_summary(integer()) ::
3354
          {:ok, [String.t(), ...], []}
3355
  def get_group_grading_summary(course_id) do
3356
    subs =
1✔
3357
      Answer
3358
      |> join(:left, [ans], s in Submission, on: s.id == ans.submission_id)
1✔
3359
      |> join(:left, [ans, s], st in CourseRegistration, on: s.student_id == st.id)
1✔
3360
      |> join(:left, [ans, s, st], a in Assessment, on: a.id == s.assessment_id)
1✔
3361
      |> join(:inner, [ans, s, st, a], ac in AssessmentConfig, on: ac.id == a.config_id)
3362
      |> where(
3363
        [ans, s, st, a, ac],
3364
        not is_nil(st.group_id) and s.status == ^:submitted and
3365
          ac.show_grading_summary and a.course_id == ^course_id
3366
      )
3367
      |> group_by([ans, s, st, a, ac], s.id)
3368
      |> select([ans, s, st, a, ac], %{
1✔
3369
        group_id: max(st.group_id),
3370
        config_id: max(ac.id),
3371
        config_type: max(ac.type),
3372
        num_submitted: count(),
3373
        num_ungraded: filter(count(), is_nil(ans.grader_id))
3374
      })
3375

3376
    raw_data =
1✔
3377
      subs
3378
      |> subquery()
3379
      |> join(:left, [t], g in Group, on: t.group_id == g.id)
1✔
3380
      |> join(:left, [t, g], l in CourseRegistration, on: l.id == g.leader_id)
1✔
3381
      |> join(:left, [t, g, l], lu in User, on: lu.id == l.user_id)
3382
      |> group_by([t, g, l, lu], [t.group_id, t.config_id, t.config_type, g.name, lu.name])
3383
      |> select([t, g, l, lu], %{
1✔
3384
        group_name: g.name,
3385
        leader_name: lu.name,
3386
        config_id: t.config_id,
3387
        config_type: t.config_type,
3388
        ungraded: filter(count(), t.num_ungraded > 0),
3389
        submitted: count()
3390
      })
3391
      |> Repo.all()
3392

3393
    showing_configs =
1✔
3394
      AssessmentConfig
3395
      |> where([ac], ac.course_id == ^course_id and ac.show_grading_summary)
3396
      |> order_by(:order)
3397
      |> group_by([ac], ac.id)
3398
      |> select([ac], %{
1✔
3399
        id: ac.id,
3400
        type: ac.type
3401
      })
3402
      |> Repo.all()
3403

3404
    data_by_groups =
1✔
3405
      raw_data
3406
      |> Enum.reduce(%{}, fn raw, acc ->
3407
        if Map.has_key?(acc, raw.group_name) do
2✔
3408
          acc
3409
          |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded)
×
3410
          |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted)
×
3411
        else
3412
          acc
3413
          |> put_in([raw.group_name], %{})
2✔
3414
          |> put_in([raw.group_name, "groupName"], raw.group_name)
2✔
3415
          |> put_in([raw.group_name, "leaderName"], raw.leader_name)
2✔
3416
          |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded)
2✔
3417
          |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted)
2✔
3418
        end
3419
      end)
3420

3421
    headings =
1✔
3422
      showing_configs
3423
      |> Enum.reduce([], fn config, acc ->
3424
        acc ++ ["submitted" <> config.type, "ungraded" <> config.type]
2✔
3425
      end)
3426

3427
    default_row_data =
1✔
3428
      headings
3429
      |> Enum.reduce(%{}, fn heading, acc ->
3430
        put_in(acc, [heading], 0)
4✔
3431
      end)
3432

3433
    rows = data_by_groups |> Enum.map(fn {_k, row} -> Map.merge(default_row_data, row) end)
1✔
3434
    cols = ["groupName", "leaderName"] ++ headings
1✔
3435

3436
    {:ok, cols, rows}
1✔
3437
  end
3438

3439
  defp create_empty_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do
3440
    {:ok, team} = find_team(assessment.id, cr.id)
17✔
3441

3442
    case team do
17✔
3443
      %Team{} ->
3444
        %Submission{}
3445
        |> Submission.changeset(%{team: team, assessment: assessment})
3446
        |> Repo.insert()
3447
        |> case do
1✔
3448
          {:ok, submission} -> {:ok, submission}
1✔
3449
        end
3450

3451
      nil ->
3452
        %Submission{}
3453
        |> Submission.changeset(%{student: cr, assessment: assessment})
3454
        |> Repo.insert()
3455
        |> case do
16✔
3456
          {:ok, submission} -> {:ok, submission}
16✔
3457
        end
3458
    end
3459
  end
3460

3461
  defp find_or_create_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do
3462
    case find_submission(cr, assessment) do
58✔
3463
      {:ok, submission} -> {:ok, submission}
41✔
3464
      {:error, _} -> create_empty_submission(cr, assessment)
17✔
3465
    end
3466
  end
3467

3468
  defp insert_or_update_answer(
3469
         submission = %Submission{},
3470
         question = %Question{},
3471
         raw_answer,
3472
         course_reg_id
3473
       ) do
3474
    answer_content = build_answer_content(raw_answer, question.type)
52✔
3475

3476
    if question.type == :voting do
52✔
3477
      insert_or_update_voting_answer(submission.id, course_reg_id, question.id, answer_content)
15✔
3478
    else
3479
      answer_changeset =
37✔
3480
        %Answer{}
3481
        |> Answer.changeset(%{
3482
          answer: answer_content,
3483
          question_id: question.id,
37✔
3484
          submission_id: submission.id,
37✔
3485
          type: question.type,
37✔
3486
          last_modified_at: Timex.now()
3487
        })
3488

3489
      Repo.insert(
37✔
3490
        answer_changeset,
3491
        on_conflict: [
3492
          set: [answer: get_change(answer_changeset, :answer), last_modified_at: Timex.now()]
3493
        ],
3494
        conflict_target: [:submission_id, :question_id]
3495
      )
3496
    end
3497
  end
3498

3499
  def has_last_modified_answer?(
3500
        question = %Question{},
3501
        cr = %CourseRegistration{id: _cr_id},
3502
        last_modified_at,
3503
        force_submit
3504
      ) do
3505
    with {:ok, submission} <- find_or_create_submission(cr, question.assessment),
2✔
3506
         {:status, true} <- {:status, force_submit or submission.status != :submitted},
2✔
3507
         {:ok, is_modified} <- answer_last_modified?(submission, question, last_modified_at) do
2✔
3508
      {:ok, is_modified}
3509
    else
3510
      {:status, _} ->
×
3511
        {:error, {:forbidden, "Assessment submission already finalised"}}
3512
    end
3513
  end
3514

3515
  defp answer_last_modified?(
3516
         submission = %Submission{},
3517
         question = %Question{},
3518
         last_modified_at
3519
       ) do
3520
    case Repo.get_by(Answer, submission_id: submission.id, question_id: question.id) do
2✔
3521
      %Answer{last_modified_at: existing_last_modified_at} ->
3522
        existing_iso8601 = DateTime.to_iso8601(existing_last_modified_at)
1✔
3523

3524
        if existing_iso8601 == last_modified_at do
1✔
3525
          {:ok, false}
3526
        else
3527
          {:ok, true}
3528
        end
3529

3530
      nil ->
1✔
3531
        {:ok, false}
3532
    end
3533
  end
3534

3535
  def insert_or_update_voting_answer(submission_id, course_reg_id, question_id, answer_content) do
3536
    set_score_to_nil =
15✔
3537
      SubmissionVotes
3538
      |> where(voter_id: ^course_reg_id, question_id: ^question_id)
15✔
3539

3540
    voting_multi =
15✔
3541
      Multi.new()
3542
      |> Multi.update_all(:set_score_to_nil, set_score_to_nil, set: [score: nil])
3543

3544
    answer_content
3545
    |> Enum.with_index(1)
3546
    |> Enum.reduce(voting_multi, fn {entry, index}, multi ->
3547
      multi
3548
      |> Multi.run("update#{index}", fn _repo, _ ->
15✔
3549
        SubmissionVotes
3550
        |> Repo.get_by(
3551
          voter_id: course_reg_id,
3552
          submission_id: entry.submission_id
15✔
3553
        )
3554
        |> SubmissionVotes.changeset(%{score: entry.score})
15✔
3555
        |> Repo.insert_or_update()
15✔
3556
      end)
3557
    end)
3558
    |> Multi.run("insert into answer table", fn _repo, _ ->
3559
      Answer
3560
      |> Repo.get_by(submission_id: submission_id, question_id: question_id)
3561
      |> case do
12✔
3562
        nil ->
3563
          Repo.insert(%Answer{
9✔
3564
            answer: %{completed: true},
3565
            submission_id: submission_id,
3566
            question_id: question_id,
3567
            type: :voting
3568
          })
3569

3570
        _ ->
3✔
3571
          {:ok, nil}
3572
      end
3573
    end)
3574
    |> Repo.transaction()
3575
    |> case do
15✔
3576
      {:ok, _result} -> {:ok, nil}
12✔
3577
      {:error, _name, _changeset, _error} -> {:error, :invalid_vote}
3✔
3578
    end
3579
  end
3580

3581
  defp build_answer_content(raw_answer, question_type) do
3582
    case question_type do
52✔
3583
      :mcq ->
3584
        %{choice_id: raw_answer}
20✔
3585

3586
      :programming ->
3587
        %{code: raw_answer}
17✔
3588

3589
      :voting ->
3590
        raw_answer
3591
        |> Enum.map(fn ans ->
15✔
3592
          for {key, value} <- ans, into: %{}, do: {String.to_existing_atom(key), value}
15✔
3593
        end)
3594
    end
3595
  end
3596

3597
  def get_llm_assessment_prompt(question_id) do
3598
    query =
3✔
3599
      from(q in Question,
3✔
3600
        where: q.id == ^question_id,
3601
        join: a in assoc(q, :assessment),
3602
        select: a.llm_assessment_prompt
3603
      )
3604

3605
    Repo.one(query)
3✔
3606
  end
3607
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