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

source-academy / backend / 413a3582a0de2e124977611723bfc08357e776be

19 Aug 2025 09:18AM UTC coverage: 90.796% (-0.04%) from 90.836%
413a3582a0de2e124977611723bfc08357e776be

push

github

web-flow
Leaderboard fixes and improvements (#1288)

* Use map for options instead

* Remove repeating fields selected from total xp subquery

* Retrieve only the userids in the course

* select_merge operator is not needed

* Convert course userid query to pipeline form

* Add filter option for including non-students

* Fix submissions xp query and use pipeline format

* Fix code formatting

* Move anonymous function out of inline

* Update testcases for new format

* Fix test formatting

10 of 12 new or added lines in 2 files covered. (83.33%)

2 existing lines in 2 files now uncovered.

3285 of 3618 relevant lines covered (90.8%)

7487.23 hits per line

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

91.99
/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
    assessment = Repo.get(Assessment, id)
6✔
41

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

53
    if is_voted_on do
6✔
54
      {:error, {:bad_request, "Contest voting for this contest is still up"}}
55
    else
56
      Submission
57
      |> where(assessment_id: ^id)
5✔
58
      |> delete_submission_association(id)
5✔
59

60
      Question
61
      |> where(assessment_id: ^id)
5✔
62
      |> Repo.all()
63
      |> Enum.each(fn q ->
5✔
64
        delete_submission_votes_association(q)
2✔
65
      end)
66

67
      Repo.delete(assessment)
5✔
68
    end
69
  end
70

71
  defp delete_submission_votes_association(question) do
72
    SubmissionVotes
73
    |> where(question_id: ^question.id)
3✔
74
    |> Repo.delete_all()
3✔
75
  end
76

77
  defp delete_submission_association(submissions, assessment_id) do
78
    submissions
79
    |> Repo.all()
80
    |> Enum.each(fn submission ->
6✔
81
      Answer
82
      |> where(submission_id: ^submission.id)
1✔
83
      |> Repo.delete_all()
1✔
84
    end)
85

86
    Notification
87
    |> where(assessment_id: ^assessment_id)
6✔
88
    |> Repo.delete_all()
6✔
89

90
    Repo.delete_all(submissions)
6✔
91
  end
92

93
  @spec user_max_xp(CourseRegistration.t()) :: integer()
94
  def user_max_xp(%CourseRegistration{id: cr_id}) do
95
    Submission
96
    |> where(status: ^:submitted)
97
    |> where(student_id: ^cr_id)
2✔
98
    |> join(
99
      :inner,
100
      [s],
101
      a in subquery(Query.all_assessments_with_max_xp()),
102
      on: s.assessment_id == a.id
103
    )
104
    |> select([_, a], sum(a.max_xp))
2✔
105
    |> Repo.one()
106
    |> decimal_to_integer()
2✔
107
  end
108

109
  def assessments_total_xp(%CourseRegistration{id: cr_id}) do
110
    teams = find_teams(cr_id)
17✔
111
    submission_ids = get_submission_ids(cr_id, teams)
17✔
112

113
    submission_xp =
17✔
114
      Submission
115
      |> where(
116
        [s],
117
        s.id in subquery(submission_ids)
118
      )
119
      |> where(is_grading_published: true)
17✔
120
      |> join(:inner, [s], a in Answer, on: s.id == a.submission_id)
121
      |> group_by([s], s.id)
122
      |> select([s, a], %{
17✔
123
        # grouping by submission, so s.xp_bonus will be the same, but we need an
124
        # aggregate function
125
        total_xp: sum(a.xp) + sum(a.xp_adjustment) + max(s.xp_bonus)
126
      })
127

128
    total =
17✔
129
      submission_xp
130
      |> subquery
131
      |> select([s], %{
17✔
132
        total_xp: sum(s.total_xp)
133
      })
134
      |> Repo.one()
135

136
    # for {key, val} <- total, into: %{}, do: {key, decimal_to_integer(val)}
137
    decimal_to_integer(total.total_xp)
17✔
138
  end
139

140
  def user_total_xp(course_id, user_id, course_reg_id) do
141
    user_course = CourseRegistrations.get_user_course(user_id, course_id)
14✔
142

143
    total_achievement_xp = Achievements.achievements_total_xp(course_id, course_reg_id)
14✔
144
    total_assessment_xp = assessments_total_xp(user_course)
14✔
145

146
    total_achievement_xp + total_assessment_xp
14✔
147
  end
148

149
  def all_user_total_xp(course_id, options \\ %{}) do
150
    include_admin_staff_users = fn q ->
56✔
151
      if options[:include_admin_staff],
56✔
NEW
152
        do: q,
×
153
        else: where(q, [_, cr], cr.role == "student")
56✔
154
    end
155

156
    # get all users even if they have 0 xp
157
    course_userid_query =
56✔
158
      User
159
      |> join(:inner, [u], cr in CourseRegistration, on: cr.user_id == u.id)
160
      |> where([_, cr], cr.course_id == ^course_id)
56✔
161
      |> include_admin_staff_users.()
162
      |> select([u, cr], %{
56✔
163
        id: u.id,
164
        cr_id: cr.id
165
      })
166

167
    achievements_xp_query =
56✔
168
      from(u in User,
56✔
169
        join: cr in CourseRegistration,
170
        on: cr.user_id == u.id and cr.course_id == ^course_id,
171
        left_join: a in Achievement,
172
        on: a.course_id == cr.course_id,
173
        left_join: j in assoc(a, :goals),
174
        left_join: g in assoc(j, :goal),
175
        left_join: p in GoalProgress,
176
        on: p.goal_uuid == g.uuid and p.course_reg_id == cr.id,
177
        where:
178
          a.course_id == ^course_id and p.completed and
179
            p.count == g.target_count,
180
        group_by: [u.id, u.name, u.username, cr.id],
181
        select: %{
182
          user_id: u.id,
183
          achievements_xp:
184
            fragment(
185
              "CASE WHEN bool_and(?) THEN ? ELSE ? END",
186
              a.is_variable_xp,
187
              sum(p.count),
188
              max(a.xp)
189
            )
190
        }
191
      )
192

193
    submissions_xp_query =
56✔
194
      course_userid_query
195
      |> subquery()
196
      |> join(:left, [u], tm in TeamMember, on: tm.student_id == u.cr_id)
56✔
197
      |> join(:left, [u, tm], s in Submission, on: s.student_id == u.cr_id or s.team_id == tm.id)
56✔
198
      |> join(:left, [u, tm, s], a in Answer, on: s.id == a.submission_id)
199
      |> where([_, _, s, _], s.is_grading_published == true)
200
      |> group_by([u, _, s, _], [u.id, s.id])
201
      |> select([u, _, s, a], %{
56✔
202
        user_id: u.id,
203
        submission_xp: sum(a.xp) + sum(a.xp_adjustment) + max(s.xp_bonus)
204
      })
205

206
    total_xp_query =
56✔
207
      from(cu in subquery(course_userid_query),
56✔
208
        inner_join: u in User,
209
        on: u.id == cu.id,
210
        left_join: ax in subquery(achievements_xp_query),
211
        on: u.id == ax.user_id,
212
        left_join: sx in subquery(submissions_xp_query),
213
        on: u.id == sx.user_id,
214
        select: %{
215
          user_id: u.id,
216
          name: u.name,
217
          username: u.username,
218
          total_xp:
219
            fragment(
220
              "COALESCE(?, 0) + COALESCE(?, 0)",
221
              ax.achievements_xp,
222
              sx.submission_xp
223
            )
224
        },
225
        order_by: [desc: fragment("total_xp")]
226
      )
227

228
    # add rank index
229
    ranked_xp_query =
56✔
230
      from(t in subquery(total_xp_query),
56✔
231
        select_merge: %{
232
          rank: fragment("RANK() OVER (ORDER BY total_xp DESC)")
233
        },
234
        limit: ^options[:limit],
235
        offset: ^options[:offset]
236
      )
237

238
    count_query =
56✔
239
      from(t in subquery(total_xp_query),
56✔
240
        select: count(t.user_id)
241
      )
242

243
    {status, {rows, total_count}} =
56✔
244
      Repo.transaction(fn ->
245
        users = Repo.all(ranked_xp_query)
56✔
246
        count = Repo.one(count_query)
56✔
247
        {users, count}
248
      end)
249

250
    %{
56✔
251
      users: rows,
252
      total_count: total_count
253
    }
254
  end
255

256
  defp decimal_to_integer(decimal) do
257
    if Decimal.is_decimal(decimal) do
19✔
258
      Decimal.to_integer(decimal)
17✔
259
    else
260
      0
261
    end
262
  end
263

264
  def user_current_story(cr = %CourseRegistration{}) do
265
    {:ok, %{result: story}} =
2✔
266
      Multi.new()
267
      |> Multi.run(:unattempted, fn _repo, _ ->
2✔
268
        {:ok, get_user_story_by_type(cr, :unattempted)}
269
      end)
270
      |> Multi.run(:result, fn _repo, %{unattempted: unattempted_story} ->
271
        if unattempted_story do
2✔
272
          {:ok, %{play_story?: true, story: unattempted_story}}
273
        else
274
          {:ok, %{play_story?: false, story: get_user_story_by_type(cr, :attempted)}}
275
        end
276
      end)
277
      |> Repo.transaction()
278

279
    story
2✔
280
  end
281

282
  @spec get_user_story_by_type(CourseRegistration.t(), :unattempted | :attempted) ::
283
          String.t() | nil
284
  def get_user_story_by_type(%CourseRegistration{id: cr_id}, type)
285
      when is_atom(type) do
286
    filter_and_sort = fn query ->
2✔
287
      case type do
2✔
288
        :unattempted ->
289
          query
290
          |> where([_, s], is_nil(s.id))
291
          |> order_by([a], asc: a.open_at)
2✔
292

293
        :attempted ->
294
          query |> order_by([a], desc: a.close_at)
×
295
      end
296
    end
297

298
    Assessment
299
    |> where(is_published: true)
300
    |> where([a], not is_nil(a.story))
301
    |> where([a], a.open_at <= from_now(0, "second") and a.close_at >= from_now(0, "second"))
2✔
302
    |> join(:left, [a], s in Submission, on: s.assessment_id == a.id and s.student_id == ^cr_id)
2✔
303
    |> filter_and_sort.()
304
    |> order_by([a], a.config_id)
305
    |> select([a], a.story)
2✔
306
    |> first()
307
    |> Repo.one()
2✔
308
  end
309

310
  def assessment_with_questions_and_answers(
311
        assessment = %Assessment{password: nil},
312
        cr = %CourseRegistration{},
313
        nil
314
      ) do
315
    assessment_with_questions_and_answers(assessment, cr)
87✔
316
  end
317

318
  def assessment_with_questions_and_answers(
319
        assessment = %Assessment{password: nil},
320
        cr = %CourseRegistration{},
321
        _
322
      ) do
323
    assessment_with_questions_and_answers(assessment, cr)
3✔
324
  end
325

326
  def assessment_with_questions_and_answers(
327
        assessment = %Assessment{password: password},
328
        cr = %CourseRegistration{},
329
        given_password
330
      ) do
331
    cond do
11✔
332
      Timex.compare(Timex.now(), assessment.close_at) >= 0 ->
11✔
333
        assessment_with_questions_and_answers(assessment, cr)
1✔
334

335
      match?({:ok, _}, find_submission(cr, assessment)) ->
10✔
336
        assessment_with_questions_and_answers(assessment, cr)
1✔
337

338
      given_password == nil ->
9✔
339
        {:error, {:forbidden, "Missing Password."}}
340

341
      password == given_password ->
6✔
342
        find_or_create_submission(cr, assessment)
3✔
343
        assessment_with_questions_and_answers(assessment, cr)
3✔
344

345
      true ->
3✔
346
        {:error, {:forbidden, "Invalid Password."}}
347
    end
348
  end
349

350
  def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}, password)
351
      when is_ecto_id(id) do
352
    role = cr.role
101✔
353

354
    assessment =
101✔
355
      if role in @open_all_assessment_roles do
356
        Assessment
357
        |> where(id: ^id)
358
        |> preload(:config)
66✔
359
        |> Repo.one()
66✔
360
      else
361
        Assessment
362
        |> where(id: ^id)
363
        |> where(is_published: true)
364
        |> preload(:config)
35✔
365
        |> Repo.one()
35✔
366
      end
367

368
    if assessment do
101✔
369
      assessment_with_questions_and_answers(assessment, cr, password)
100✔
370
    else
371
      {:error, {:bad_request, "Assessment not found"}}
372
    end
373
  end
374

375
  def assessment_with_questions_and_answers(
376
        assessment = %Assessment{id: id},
377
        course_reg = %CourseRegistration{role: role}
378
      ) do
379
    team_id =
99✔
380
      case find_team(id, course_reg.id) do
99✔
381
        {:ok, nil} ->
96✔
382
          -1
383

384
        {:ok, team} ->
385
          team.id
1✔
386

387
        {:error, :team_not_found} ->
2✔
388
          -1
389
      end
390

391
    if Timex.compare(Timex.now(), assessment.open_at) >= 0 or role in @open_all_assessment_roles do
99✔
392
      answer_query =
98✔
393
        Answer
394
        |> join(:inner, [a], s in assoc(a, :submission))
395
        |> where([_, s], s.student_id == ^course_reg.id or s.team_id == ^team_id)
98✔
396

397
      visible_entries =
98✔
398
        Assessment
399
        |> join(:inner, [a], c in assoc(a, :course))
400
        |> where([a, c], a.id == ^id)
401
        |> select([a, c], c.top_contest_leaderboard_display)
98✔
402
        |> Repo.one()
403

404
      questions =
98✔
405
        Question
406
        |> where(assessment_id: ^id)
98✔
407
        |> join(:left, [q], a in subquery(answer_query), on: q.id == a.question_id)
98✔
408
        |> join(:left, [_, a], g in assoc(a, :grader))
98✔
409
        |> join(:left, [_, _, g], u in assoc(g, :user))
410
        |> select([q, a, g, u], {q, a, g, u})
411
        |> order_by(:display_order)
98✔
412
        |> Repo.all()
413
        |> Enum.map(fn
414
          {q, nil, _, _} -> %{q | answer: %Answer{grader: nil}}
540✔
415
          {q, a, nil, _} -> %{q | answer: %Answer{a | grader: nil}}
216✔
416
          {q, a, g, u} -> %{q | answer: %Answer{a | grader: %CourseRegistration{g | user: u}}}
47✔
417
        end)
418
        |> load_contest_voting_entries(course_reg, assessment, visible_entries)
419

420
      is_grading_published =
98✔
421
        Submission
422
        |> where(assessment_id: ^id)
423
        |> where([s], s.student_id == ^course_reg.id or s.team_id == ^team_id)
98✔
424
        |> select([s], s.is_grading_published)
98✔
425
        |> Repo.one()
426

427
      assessment =
98✔
428
        assessment
429
        |> Map.put(:questions, questions)
430
        |> Map.put(:is_grading_published, is_grading_published)
431

432
      {:ok, assessment}
433
    else
434
      {:error, {:forbidden, "Assessment not open"}}
435
    end
436
  end
437

438
  def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}) do
439
    assessment_with_questions_and_answers(id, cr, nil)
92✔
440
  end
441

442
  @doc """
443
  Returns a list of assessments with all fields and an indicator showing whether it has been attempted
444
  by the supplied user
445
  """
446
  def all_assessments(cr = %CourseRegistration{}) do
447
    teams = find_teams(cr.id)
18✔
448
    submission_ids = get_submission_ids(cr.id, teams)
18✔
449

450
    submission_aggregates =
18✔
451
      Submission
452
      |> join(:left, [s], ans in Answer, on: ans.submission_id == s.id)
453
      |> where(
454
        [s],
455
        s.id in subquery(submission_ids)
456
      )
457
      |> group_by([s], s.assessment_id)
458
      |> select([s, ans], %{
18✔
459
        assessment_id: s.assessment_id,
460
        # s.xp_bonus should be the same across the group, but we need an aggregate function here
461
        xp: fragment("? + ? + ?", sum(ans.xp), sum(ans.xp_adjustment), max(s.xp_bonus)),
462
        graded_count: ans.id |> count() |> filter(not is_nil(ans.grader_id))
463
      })
464

465
    submission_status =
18✔
466
      Submission
467
      |> where(
468
        [s],
469
        s.id in subquery(submission_ids)
470
      )
471
      |> select([s], [:assessment_id, :status, :is_grading_published])
18✔
472

473
    assessments =
18✔
474
      cr.course_id
18✔
475
      |> Query.all_assessments_with_aggregates()
476
      |> subquery()
477
      |> join(
18✔
478
        :left,
479
        [a],
480
        sa in subquery(submission_aggregates),
481
        on: a.id == sa.assessment_id
482
      )
483
      |> join(:left, [a, _], s in subquery(submission_status), on: a.id == s.assessment_id)
484
      |> select([a, sa, s], %{
18✔
485
        a
486
        | xp: sa.xp,
487
          graded_count: sa.graded_count,
488
          user_status: s.status,
489
          is_grading_published: s.is_grading_published
490
      })
491
      |> filter_published_assessments(cr)
492
      |> order_by(:open_at)
493
      |> preload(:config)
18✔
494
      |> Repo.all()
495

496
    {:ok, assessments}
497
  end
498

499
  defp get_submission_ids(cr_id, teams) do
500
    from(s in Submission,
35✔
501
      where: s.student_id == ^cr_id or s.team_id in ^Enum.map(teams, & &1.id),
1✔
502
      select: s.id
503
    )
504
  end
505

506
  defp is_voting_assigned(assessment_ids) do
507
    voting_assigned_question_ids =
18✔
508
      SubmissionVotes
509
      |> select([v], v.question_id)
18✔
510
      |> Repo.all()
511

512
    # Map of assessment_id to boolean
513
    voting_assigned_assessment_ids =
18✔
514
      Question
515
      |> where(type: :voting)
516
      |> where([q], q.id in ^voting_assigned_question_ids)
517
      |> where([q], q.assessment_id in ^assessment_ids)
518
      |> select([q], q.assessment_id)
519
      |> distinct(true)
18✔
520
      |> Repo.all()
521

522
    Enum.reduce(assessment_ids, %{}, fn id, acc ->
18✔
523
      Map.put(acc, id, Enum.member?(voting_assigned_assessment_ids, id))
92✔
524
    end)
525
  end
526

527
  @doc """
528
  A helper function which removes grading information from all assessments
529
  if it's grading is not published.
530
  """
531
  def format_all_assessments(assessments) do
532
    is_voting_assigned_map =
18✔
533
      assessments
534
      |> Enum.map(& &1.id)
92✔
535
      |> is_voting_assigned()
536

537
    Enum.map(assessments, fn a ->
18✔
538
      a = Map.put(a, :is_voting_published, Map.get(is_voting_assigned_map, a.id, false))
92✔
539

540
      if a.is_grading_published do
92✔
541
        a
8✔
542
      else
543
        a
544
        |> Map.put(:xp, 0)
545
        |> Map.put(:graded_count, 0)
84✔
546
      end
547
    end)
548
  end
549

550
  @doc """
551
  A helper function which removes grading information from the assessment
552
  if it's grading is not published.
553
  """
554
  def format_assessment_with_questions_and_answers(assessment) do
555
    if assessment.is_grading_published do
89✔
556
      assessment
6✔
557
    else
558
      %{
559
        assessment
560
        | questions:
83✔
561
            Enum.map(assessment.questions, fn q ->
83✔
562
              %{
563
                q
564
                | answer: %{
730✔
565
                    q.answer
730✔
566
                    | xp: 0,
567
                      xp_adjustment: 0,
568
                      autograding_status: :none,
569
                      autograding_results: [],
570
                      grader: nil,
571
                      grader_id: nil,
572
                      comments: nil
573
                  }
574
              }
575
            end)
576
      }
577
    end
578
  end
579

580
  def filter_published_assessments(assessments, cr) do
581
    role = cr.role
18✔
582

583
    case role do
18✔
584
      :student -> where(assessments, is_published: true)
11✔
585
      _ -> assessments
7✔
586
    end
587
  end
588

589
  def create_assessment(params) do
590
    %Assessment{}
591
    |> Assessment.changeset(params)
592
    |> Repo.insert()
1✔
593
  end
594

595
  @doc """
596
  The main function that inserts or updates assessments from the XML Parser
597
  """
598
  @spec insert_or_update_assessments_and_questions(map(), [map()], boolean()) ::
599
          {:ok, any()}
600
          | {:error, Ecto.Multi.name(), any(), %{optional(Ecto.Multi.name()) => any()}}
601
  def insert_or_update_assessments_and_questions(
602
        assessment_params,
603
        questions_params,
604
        force_update
605
      ) do
606
    assessment_multi =
27✔
607
      Multi.insert_or_update(
608
        Multi.new(),
609
        :assessment,
610
        insert_or_update_assessment_changeset(assessment_params, force_update)
611
      )
612

613
    if force_update and invalid_force_update(assessment_multi, questions_params) do
27✔
614
      {:error, "Question count is different"}
615
    else
616
      questions_params
617
      |> Enum.with_index(1)
618
      |> Enum.reduce(assessment_multi, fn {question_params, index}, multi ->
619
        Multi.run(multi, "question#{index}", fn _repo, %{assessment: %Assessment{id: id}} ->
78✔
620
          question =
53✔
621
            Question
622
            |> where([q], q.display_order == ^index and q.assessment_id == ^id)
53✔
623
            |> Repo.one()
624

625
          # the is_nil(question) check allows for force updating of brand new assessments
626
          if !force_update or is_nil(question) do
53✔
627
            {status, new_question} =
53✔
628
              question_params
629
              |> Map.put(:display_order, index)
630
              |> build_question_changeset_for_assessment_id(id)
631
              |> Repo.insert()
632

633
            if status == :ok and new_question.type == :voting do
53✔
634
              insert_voting(
16✔
635
                assessment_params.course_id,
16✔
636
                question_params.question.contest_number,
16✔
637
                new_question.id
16✔
638
              )
639
            else
640
              {status, new_question}
641
            end
642
          else
643
            params =
×
644
              question_params
645
              |> Map.put_new(:max_xp, 0)
646
              |> Map.put(:display_order, index)
647

648
            if question_params.type != Atom.to_string(question.type) do
×
649
              {:error,
650
               create_invalid_changeset_with_error(
651
                 :question,
652
                 "Question types should remain the same"
653
               )}
654
            else
655
              question
656
              |> Question.changeset(params)
657
              |> Repo.update()
×
658
            end
659
          end
660
        end)
661
      end)
662
      |> Repo.transaction()
26✔
663
    end
664
  end
665

666
  # Function that checks if the force update is invalid. The force update is only invalid
667
  # if the new question count is different from the old question count.
668
  defp invalid_force_update(assessment_multi, questions_params) do
669
    assessment_id =
1✔
670
      (assessment_multi.operations
1✔
671
       |> List.first()
672
       |> elem(1)
673
       |> elem(1)).data.id
1✔
674

675
    if assessment_id do
1✔
676
      open_date = Repo.get(Assessment, assessment_id).open_at
1✔
677
      # check if assessment is already opened
678
      if Timex.compare(open_date, Timex.now()) >= 0 do
1✔
679
        false
680
      else
681
        existing_questions_count =
1✔
682
          Question
683
          |> where([q], q.assessment_id == ^assessment_id)
1✔
684
          |> Repo.all()
685
          |> Enum.count()
686

687
        new_questions_count = Enum.count(questions_params)
1✔
688
        existing_questions_count != new_questions_count
1✔
689
      end
690
    else
691
      false
692
    end
693
  end
694

695
  @spec insert_or_update_assessment_changeset(map(), boolean()) :: Ecto.Changeset.t()
696
  defp insert_or_update_assessment_changeset(
697
         params = %{number: number, course_id: course_id},
698
         force_update
699
       ) do
700
    Assessment
701
    |> where(number: ^number)
702
    |> where(course_id: ^course_id)
27✔
703
    |> Repo.one()
704
    |> case do
27✔
705
      nil ->
706
        Assessment.changeset(%Assessment{}, params)
16✔
707

708
      %{id: assessment_id} = assessment ->
709
        answers_exist =
11✔
710
          Answer
711
          |> join(:inner, [a], q in assoc(a, :question))
11✔
712
          |> join(:inner, [a, q], asst in assoc(q, :assessment))
713
          |> where([a, q, asst], asst.id == ^assessment_id)
11✔
714
          |> Repo.exists?()
715

716
        # Maintain the same open/close date when updating an assessment
717
        params =
11✔
718
          params
719
          |> Map.delete(:open_at)
720
          |> Map.delete(:close_at)
721
          |> Map.delete(:is_published)
722

723
        cond do
11✔
724
          not answers_exist ->
725
            # Delete all realted submission_votes
726
            SubmissionVotes
727
            |> join(:inner, [sv, q], q in assoc(sv, :question))
728
            |> where([sv, q], q.assessment_id == ^assessment_id)
6✔
729
            |> Repo.delete_all()
6✔
730

731
            # Delete all existing questions
732
            Question
733
            |> where(assessment_id: ^assessment_id)
6✔
734
            |> Repo.delete_all()
6✔
735

736
            Assessment.changeset(assessment, params)
6✔
737

738
          force_update ->
5✔
739
            Assessment.changeset(assessment, params)
×
740

741
          true ->
5✔
742
            # if the assessment has submissions, don't edit
743
            create_invalid_changeset_with_error(:assessment, "has submissions")
5✔
744
        end
745
    end
746
  end
747

748
  @spec build_question_changeset_for_assessment_id(map(), number() | String.t()) ::
749
          Ecto.Changeset.t()
750
  defp build_question_changeset_for_assessment_id(params, assessment_id)
751
       when is_ecto_id(assessment_id) do
752
    params_with_assessment_id = Map.put_new(params, :assessment_id, assessment_id)
53✔
753

754
    Question.changeset(%Question{}, params_with_assessment_id)
53✔
755
  end
756

757
  def reassign_voting(assessment_id, is_reassigning_voting) do
758
    if is_reassigning_voting do
9✔
759
      if is_voting_published(assessment_id) do
1✔
760
        Submission
761
        |> where(assessment_id: ^assessment_id)
1✔
762
        |> delete_submission_association(assessment_id)
1✔
763

764
        Question
765
        |> where(assessment_id: ^assessment_id)
1✔
766
        |> Repo.all()
767
        |> Enum.each(fn q ->
1✔
768
          delete_submission_votes_association(q)
1✔
769
        end)
770
      end
771

772
      voting_assigned_question_ids =
1✔
773
        SubmissionVotes
774
        |> select([v], v.question_id)
1✔
775
        |> Repo.all()
776

777
      unpublished_voting_questions =
1✔
778
        Question
779
        |> where(type: :voting)
780
        |> where([q], q.id not in ^voting_assigned_question_ids)
781
        |> where(assessment_id: ^assessment_id)
1✔
782
        |> join(:inner, [q], asst in assoc(q, :assessment))
783
        |> select([q, asst], %{course_id: asst.course_id, question: q.question, id: q.id})
1✔
784
        |> Repo.all()
785

786
      for q <- unpublished_voting_questions do
1✔
787
        insert_voting(q.course_id, q.question["contest_number"], q.id)
1✔
788
      end
789

790
      {:ok, "voting assigned"}
791
    else
792
      {:ok, "no change to voting"}
793
    end
794
  end
795

796
  defp is_voting_published(assessment_id) do
797
    voting_assigned_question_ids =
1✔
798
      SubmissionVotes
799
      |> select([v], v.question_id)
1✔
800

801
    Question
802
    |> where(type: :voting)
803
    |> where(assessment_id: ^assessment_id)
804
    |> where([q], q.id in subquery(voting_assigned_question_ids))
1✔
805
    |> Repo.exists?() || false
1✔
806
  end
807

808
  def update_final_contest_entries do
809
    # 1435 = 1 day - 5 minutes
810
    if Log.log_execution("update_final_contest_entries", Duration.from_minutes(1435)) do
1✔
811
      Logger.info("Started update of contest entry pools")
1✔
812
      questions = fetch_unassigned_voting_questions()
1✔
813

814
      for q <- questions do
1✔
815
        insert_voting(q.course_id, q.question["contest_number"], q.question_id)
2✔
816
      end
817

818
      Logger.info("Successfully update contest entry pools")
1✔
819
    end
820
  end
821

822
  # fetch voting questions where entries have not been assigned
823
  def fetch_unassigned_voting_questions do
824
    voting_assigned_question_ids =
2✔
825
      SubmissionVotes
826
      |> select([v], v.question_id)
2✔
827
      |> Repo.all()
828

829
    valid_assessments =
2✔
830
      Assessment
831
      |> select([a], %{number: a.number, course_id: a.course_id})
2✔
832
      |> Repo.all()
833

834
    valid_questions =
2✔
835
      Question
836
      |> where(type: :voting)
837
      |> where([q], q.id not in ^voting_assigned_question_ids)
2✔
838
      |> join(:inner, [q], asst in assoc(q, :assessment))
839
      |> select([q, asst], %{course_id: asst.course_id, question: q.question, question_id: q.id})
2✔
840
      |> Repo.all()
841

842
    # fetch only voting where there is a corresponding contest
843
    Enum.filter(valid_questions, fn q ->
2✔
844
      Enum.any?(
6✔
845
        valid_assessments,
846
        fn a -> a.number == q.question["contest_number"] and a.course_id == q.course_id end
36✔
847
      )
848
    end)
849
  end
850

851
  @doc """
852
  Generates and assigns contest entries for users with given usernames.
853
  """
854
  def insert_voting(
855
        course_id,
856
        contest_number,
857
        question_id
858
      ) do
859
    contest_assessment = Repo.get_by(Assessment, number: contest_number, course_id: course_id)
26✔
860

861
    if is_nil(contest_assessment) do
26✔
862
      changeset = change(%Assessment{}, %{number: ""})
2✔
863

864
      error_changeset =
2✔
865
        Ecto.Changeset.add_error(
866
          changeset,
867
          :number,
868
          "invalid contest number"
869
        )
870

871
      {:error, error_changeset}
872
    else
873
      if Timex.compare(contest_assessment.close_at, Timex.now()) < 0 do
24✔
874
        compile_entries(course_id, contest_assessment, question_id)
6✔
875
      else
876
        # contest has not closed, do nothing
877
        {:ok, nil}
878
      end
879
    end
880
  end
881

882
  def compile_entries(
883
        course_id,
884
        contest_assessment,
885
        question_id
886
      ) do
887
    # Returns contest submission ids with answers that contain "return"
888
    contest_submission_ids =
6✔
889
      Submission
890
      |> join(:inner, [s], ans in assoc(s, :answers))
6✔
891
      |> join(:inner, [s, ans], cr in assoc(s, :student))
892
      |> where([s, ans, cr], cr.role == "student")
893
      |> where([s, _], s.assessment_id == ^contest_assessment.id and s.status == "submitted")
6✔
894
      |> where(
895
        [_, ans, cr],
896
        fragment(
897
          "?->>'code' like ?",
898
          ans.answer,
899
          "%return%"
900
        )
901
      )
902
      |> select([s, _ans], {s.student_id, s.id})
6✔
903
      |> Repo.all()
904
      |> Enum.into(%{})
905

906
    contest_submission_ids_length = Enum.count(contest_submission_ids)
6✔
907

908
    voter_ids =
6✔
909
      CourseRegistration
910
      |> where(role: "student", course_id: ^course_id)
911
      |> select([cr], cr.id)
6✔
912
      |> Repo.all()
913

914
    votes_per_user = min(contest_submission_ids_length, 10)
6✔
915

916
    votes_per_submission =
6✔
917
      if Enum.empty?(contest_submission_ids) do
×
918
        0
919
      else
920
        trunc(Float.ceil(votes_per_user * length(voter_ids) / contest_submission_ids_length))
6✔
921
      end
922

923
    submission_id_list =
6✔
924
      contest_submission_ids
925
      |> Enum.map(fn {_, s_id} -> s_id end)
37✔
926
      |> Enum.shuffle()
927
      |> List.duplicate(votes_per_submission)
928
      |> List.flatten()
929

930
    {_submission_map, submission_votes_changesets} =
6✔
931
      voter_ids
932
      |> Enum.reduce({submission_id_list, []}, fn voter_id, acc ->
254✔
933
        {submission_list, submission_votes} = acc
46✔
934

935
        user_contest_submission_id = Map.get(contest_submission_ids, voter_id)
46✔
936

937
        {votes, rest} =
46✔
938
          submission_list
939
          |> Enum.reduce_while({MapSet.new(), submission_list}, fn s_id, acc ->
940
            {user_votes, submissions} = acc
389✔
941

942
            max_votes =
389✔
943
              if votes_per_user == contest_submission_ids_length and
×
944
                   not is_nil(user_contest_submission_id) do
389✔
945
                # no. of submssions is less than 10. Unable to find
946
                votes_per_user - 1
338✔
947
              else
948
                votes_per_user
51✔
949
              end
950

951
            if MapSet.size(user_votes) < max_votes do
389✔
952
              if s_id != user_contest_submission_id and not MapSet.member?(user_votes, s_id) do
343✔
953
                new_user_votes = MapSet.put(user_votes, s_id)
254✔
954
                new_submissions = List.delete(submissions, s_id)
254✔
955
                {:cont, {new_user_votes, new_submissions}}
956
              else
957
                {:cont, {user_votes, submissions}}
958
              end
959
            else
960
              {:halt, acc}
961
            end
962
          end)
963

964
        votes = MapSet.to_list(votes)
46✔
965

966
        new_submission_votes =
46✔
967
          votes
968
          |> Enum.map(fn s_id ->
969
            %SubmissionVotes{
254✔
970
              voter_id: voter_id,
971
              submission_id: s_id,
972
              question_id: question_id
973
            }
974
          end)
975
          |> Enum.concat(submission_votes)
976

977
        {rest, new_submission_votes}
978
      end)
979

980
    submission_votes_changesets
981
    |> Enum.with_index()
982
    |> Enum.reduce(Multi.new(), fn {changeset, index}, multi ->
983
      Multi.insert(multi, Integer.to_string(index), changeset)
254✔
984
    end)
985
    |> Repo.transaction()
6✔
986
  end
987

988
  def update_assessment(id, params) when is_ecto_id(id) do
989
    simple_update(
10✔
990
      Assessment,
991
      id,
992
      using: &Assessment.changeset/2,
993
      params: params
994
    )
995
  end
996

997
  def update_question(id, params) when is_ecto_id(id) do
998
    simple_update(
1✔
999
      Question,
1000
      id,
1001
      using: &Question.changeset/2,
1002
      params: params
1003
    )
1004
  end
1005

1006
  def publish_assessment(id) when is_ecto_id(id) do
1007
    update_assessment(id, %{is_published: true})
1✔
1008
  end
1009

1010
  def create_question_for_assessment(params, assessment_id) when is_ecto_id(assessment_id) do
1011
    assessment =
5✔
1012
      Assessment
1013
      |> where(id: ^assessment_id)
5✔
1014
      |> join(:left, [a], q in assoc(a, :questions))
1015
      |> preload([_, q], questions: q)
5✔
1016
      |> Repo.one()
1017

1018
    if assessment do
5✔
1019
      params_with_assessment_id = Map.put_new(params, :assessment_id, assessment.id)
5✔
1020

1021
      %Question{}
1022
      |> Question.changeset(params_with_assessment_id)
1023
      |> put_display_order(assessment.questions)
5✔
1024
      |> Repo.insert()
5✔
1025
    else
1026
      {:error, "Assessment not found"}
1027
    end
1028
  end
1029

1030
  def get_question(id) when is_ecto_id(id) do
1031
    Question
1032
    |> where(id: ^id)
60✔
1033
    |> join(:inner, [q], assessment in assoc(q, :assessment))
1034
    |> preload([_, a], assessment: a)
60✔
1035
    |> Repo.one()
60✔
1036
  end
1037

1038
  def delete_question(id) when is_ecto_id(id) do
1039
    question = Repo.get(Question, id)
1✔
1040
    Repo.delete(question)
1✔
1041
  end
1042

1043
  def get_contest_voting_question(assessment_id) do
1044
    Question
1045
    |> where(type: :voting)
1046
    |> where(assessment_id: ^assessment_id)
2✔
1047
    |> Repo.one()
2✔
1048
  end
1049

1050
  @doc """
1051
  Public internal api to submit new answers for a question. Possible return values are:
1052
  `{:ok, nil}` -> success
1053
  `{:error, error}` -> failed. `error` is in the format of `{http_response_code, error message}`
1054

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

1058
  """
1059
  def answer_question(
1060
        question = %Question{},
1061
        cr = %CourseRegistration{id: cr_id},
1062
        raw_answer,
1063
        force_submit
1064
      ) do
1065
    with {:ok, _team} <- find_team(question.assessment.id, cr_id),
54✔
1066
         {:ok, submission} <- find_or_create_submission(cr, question.assessment),
53✔
1067
         {:status, true} <- {:status, force_submit or submission.status != :submitted},
53✔
1068
         {:ok, _answer} <- insert_or_update_answer(submission, question, raw_answer, cr_id) do
52✔
1069
      update_submission_status_router(submission, question)
43✔
1070

1071
      {:ok, nil}
1072
    else
1073
      {:status, _} ->
1✔
1074
        {:error, {:forbidden, "Assessment submission already finalised"}}
1075

1076
      {:error, :race_condition} ->
×
1077
        {:error, {:internal_server_error, "Please try again later."}}
1078

1079
      {:error, :team_not_found} ->
1✔
1080
        {:error, {:bad_request, "Your existing Team has been deleted!"}}
1081

1082
      {:error, :invalid_vote} ->
3✔
1083
        {:error, {:bad_request, "Invalid vote! Vote is not saved."}}
1084

1085
      _ ->
6✔
1086
        {:error, {:bad_request, "Missing or invalid parameter(s)"}}
1087
    end
1088
  end
1089

1090
  def is_team_assessment?(assessment_id) when is_ecto_id(assessment_id) do
1091
    max_team_size =
653✔
1092
      Assessment
1093
      |> where(id: ^assessment_id)
1094
      |> select([a], a.max_team_size)
653✔
1095
      |> Repo.one()
1096

1097
    max_team_size > 1
653✔
1098
  end
1099

1100
  defp find_teams(cr_id) when is_ecto_id(cr_id) do
1101
    query =
35✔
1102
      from(t in Team,
35✔
1103
        join: tm in assoc(t, :team_members),
1104
        where: tm.student_id == ^cr_id
1105
      )
1106

1107
    Repo.all(query)
35✔
1108
  end
1109

1110
  defp find_team(assessment_id, cr_id)
1111
       when is_ecto_id(assessment_id) and is_ecto_id(cr_id) do
1112
    query =
602✔
1113
      from(t in Team,
602✔
1114
        where: t.assessment_id == ^assessment_id,
1115
        join: tm in assoc(t, :team_members),
1116
        where: tm.student_id == ^cr_id,
1117
        limit: 1
1118
      )
1119

1120
    if is_team_assessment?(assessment_id) do
602✔
1121
      case Repo.one(query) do
10✔
1122
        nil -> {:error, :team_not_found}
3✔
1123
        team -> {:ok, team}
7✔
1124
      end
1125
    else
1126
      # team is nil for individual assessments
1127
      {:ok, nil}
1128
    end
1129
  end
1130

1131
  def get_submission(assessment_id, %CourseRegistration{id: cr_id})
1132
      when is_ecto_id(assessment_id) do
1133
    {:ok, team} = find_team(assessment_id, cr_id)
364✔
1134

1135
    case team do
364✔
1136
      %Team{} ->
1137
        Submission
1138
        |> where(assessment_id: ^assessment_id)
1139
        |> where(team_id: ^team.id)
1✔
1140
        |> join(:inner, [s], a in assoc(s, :assessment))
1141
        |> preload([_, a], assessment: a)
1✔
1142
        |> Repo.one()
1✔
1143

1144
      nil ->
1145
        Submission
1146
        |> where(assessment_id: ^assessment_id)
1147
        |> where(student_id: ^cr_id)
363✔
1148
        |> join(:inner, [s], a in assoc(s, :assessment))
1149
        |> preload([_, a], assessment: a)
363✔
1150
        |> Repo.one()
363✔
1151
    end
1152
  end
1153

1154
  def get_submission_by_id(submission_id) when is_ecto_id(submission_id) do
1155
    Submission
1156
    |> where(id: ^submission_id)
1✔
1157
    |> join(:inner, [s], a in assoc(s, :assessment))
1158
    |> preload([_, a], assessment: a)
1✔
1159
    |> Repo.one()
1✔
1160
  end
1161

1162
  def finalise_submission(submission = %Submission{}) do
1163
    with {:status, :attempted} <- {:status, submission.status},
360✔
1164
         {:ok, updated_submission} <- update_submission_status(submission) do
358✔
1165
      # Couple with update_submission_status and update_xp_bonus to ensure notification is sent
1166
      submission = Repo.preload(submission, assessment: [:config])
358✔
1167

1168
      if submission.assessment.config.is_manually_graded do
358✔
1169
        Notifications.write_notification_when_student_submits(submission)
358✔
1170
      end
1171

1172
      # Send email notification to avenger
1173
      %{notification_type: "assessment_submission", submission_id: updated_submission.id}
358✔
1174
      |> Cadet.Workers.NotificationWorker.new()
1175
      |> Oban.insert()
358✔
1176

1177
      # Begin autograding job
1178
      GradingJob.force_grade_individual_submission(updated_submission)
358✔
1179
      update_xp_bonus(updated_submission)
358✔
1180

1181
      {:ok, nil}
1182
    else
1183
      {:status, :attempting} ->
1✔
1184
        {:error, {:bad_request, "Some questions have not been attempted"}}
1185

1186
      {:status, :submitted} ->
1✔
1187
        {:error, {:forbidden, "Assessment has already been submitted"}}
1188

1189
      _ ->
×
1190
        {:error, {:internal_server_error, "Please try again later."}}
1191
    end
1192
  end
1193

1194
  def unsubmit_submission(
1195
        submission_id,
1196
        cr = %CourseRegistration{id: course_reg_id, role: role}
1197
      )
1198
      when is_ecto_id(submission_id) do
1199
    submission =
9✔
1200
      Submission
1201
      |> join(:inner, [s], a in assoc(s, :assessment))
1202
      |> preload([_, a], assessment: a)
9✔
1203
      |> Repo.get(submission_id)
1204

1205
    # allows staff to unsubmit own assessment
1206
    bypass = role in @bypass_closed_roles and submission.student_id == course_reg_id
9✔
1207

1208
    with {:submission_found?, true} <- {:submission_found?, is_map(submission)},
9✔
1209
         {:is_open?, true} <- {:is_open?, bypass or is_open?(submission.assessment)},
9✔
1210
         {:status, :submitted} <- {:status, submission.status},
8✔
1211
         {:allowed_to_unsubmit?, true} <-
7✔
1212
           {:allowed_to_unsubmit?,
1213
            role == :admin or bypass or is_nil(submission.student_id) or
7✔
1214
              Cadet.Accounts.Query.avenger_of?(cr, submission.student_id)},
3✔
1215
         {:is_grading_published?, false} <-
6✔
1216
           {:is_grading_published?, submission.is_grading_published} do
6✔
1217
      Multi.new()
1218
      |> Multi.run(
1219
        :rollback_submission,
1220
        fn _repo, _ ->
1221
          submission
1222
          |> Submission.changeset(%{
1223
            status: :attempted,
1224
            xp_bonus: 0,
1225
            unsubmitted_by_id: course_reg_id,
1226
            unsubmitted_at: Timex.now()
1227
          })
1228
          |> Repo.update()
5✔
1229
        end
1230
      )
1231
      |> Multi.run(:rollback_answers, fn _repo, _ ->
1232
        Answer
1233
        |> join(:inner, [a], q in assoc(a, :question))
5✔
1234
        |> join(:inner, [a, _], s in assoc(a, :submission))
1235
        |> preload([_, q, s], question: q, submission: s)
1236
        |> where(submission_id: ^submission.id)
5✔
1237
        |> Repo.all()
1238
        |> Enum.reduce_while({:ok, nil}, fn answer, acc ->
5✔
1239
          case acc do
4✔
1240
            {:error, _} ->
×
1241
              {:halt, acc}
1242

1243
            {:ok, _} ->
4✔
1244
              {:cont,
1245
               answer
1246
               |> Answer.grading_changeset(%{
1247
                 xp: 0,
1248
                 xp_adjustment: 0,
1249
                 autograding_status: :none,
1250
                 autograding_results: []
1251
               })
1252
               |> Repo.update()}
1253
          end
1254
        end)
1255
      end)
1256
      |> Repo.transaction()
5✔
1257

1258
      case submission.student_id do
5✔
1259
        # Team submission, handle notifications for team members
1260
        nil ->
1261
          team = Repo.get(Team, submission.team_id)
1✔
1262

1263
          query =
1✔
1264
            from(t in Team,
1✔
1265
              join: tm in TeamMember,
1266
              on: t.id == tm.team_id,
1267
              join: cr in CourseRegistration,
1268
              on: tm.student_id == cr.id,
1269
              where: t.id == ^team.id,
1✔
1270
              select: cr.id
1271
            )
1272

1273
          team_members = Repo.all(query)
1✔
1274

1275
          Enum.each(team_members, fn tm_id ->
1✔
1276
            Notifications.handle_unsubmit_notifications(
2✔
1277
              submission.assessment.id,
2✔
1278
              Repo.get(CourseRegistration, tm_id)
1279
            )
1280
          end)
1281

1282
        student_id ->
1283
          Notifications.handle_unsubmit_notifications(
4✔
1284
            submission.assessment.id,
4✔
1285
            Repo.get(CourseRegistration, student_id)
1286
          )
1287
      end
1288

1289
      # Remove grading notifications for submissions
1290
      Notification
1291
      |> where(submission_id: ^submission_id, type: :submitted)
1292
      |> select([n], n.id)
5✔
1293
      |> Repo.all()
1294
      |> Notifications.acknowledge(cr)
5✔
1295

1296
      {:ok, nil}
1297
    else
1298
      {:submission_found?, false} ->
×
1299
        {:error, {:not_found, "Submission not found"}}
1300

1301
      {:is_open?, false} ->
1✔
1302
        {:error, {:forbidden, "Assessment not open"}}
1303

1304
      {:status, :attempting} ->
×
1305
        {:error, {:bad_request, "Some questions have not been attempted"}}
1306

1307
      {:status, :attempted} ->
1✔
1308
        {:error, {:bad_request, "Assessment has not been submitted"}}
1309

1310
      {:allowed_to_unsubmit?, false} ->
1✔
1311
        {:error, {:forbidden, "Only Avenger of student or Admin is permitted to unsubmit"}}
1312

1313
      {:is_grading_published?, true} ->
1✔
1314
        {:error, {:forbidden, "Grading has not been unpublished"}}
1315

1316
      _ ->
×
1317
        {:error, {:internal_server_error, "Please try again later."}}
1318
    end
1319
  end
1320

1321
  defp can_publish?(submission_id, cr = %CourseRegistration{id: course_reg_id, role: role}) do
1322
    submission =
7✔
1323
      Submission
1324
      |> join(:inner, [s], a in assoc(s, :assessment))
7✔
1325
      |> join(:inner, [_, a], c in assoc(a, :config))
1326
      |> preload([_, a, c], assessment: {a, config: c})
7✔
1327
      |> Repo.get(submission_id)
1328

1329
    # allows staff to unpublish own assessment
1330
    bypass = role in @bypass_closed_roles and submission.student_id == course_reg_id
7✔
1331

1332
    # assumption: if team assessment, all team members are under the same avenger
1333
    effective_student_id =
7✔
1334
      if is_nil(submission.student_id) do
7✔
1335
        Teams.get_first_member(submission.team_id).student_id
×
1336
      else
1337
        submission.student_id
7✔
1338
      end
1339

1340
    with {:submission_found?, true} <- {:submission_found?, is_map(submission)},
7✔
1341
         {:status, :submitted} <- {:status, submission.status},
7✔
1342
         {:is_manually_graded?, true} <-
6✔
1343
           {:is_manually_graded?, submission.assessment.config.is_manually_graded},
6✔
1344
         {:fully_graded?, true} <- {:fully_graded?, is_fully_graded?(submission_id)},
6✔
1345
         {:allowed_to_publish?, true} <-
6✔
1346
           {:allowed_to_publish?,
1347
            role == :admin or bypass or
6✔
1348
              Cadet.Accounts.Query.avenger_of?(cr, effective_student_id)} do
4✔
1349
      {:ok, submission}
1350
    end
1351
  end
1352

1353
  @doc """
1354
    Unpublishes grading for a submission and send notification to student.
1355
    Requires admin or staff who is group leader of student.
1356

1357
    Only manually graded assessments can be individually unpublished. We can only
1358
    unpublish all submissions for auto-graded assessments.
1359

1360
    Returns `{:ok, nil}` on success, otherwise `{:error, {status, message}}`.
1361
  """
1362
  def unpublish_grading(submission_id, cr = %CourseRegistration{})
1363
      when is_ecto_id(submission_id) do
1364
    case can_publish?(submission_id, cr) do
3✔
1365
      {:ok, submission} ->
1366
        submission
1367
        |> Submission.changeset(%{is_grading_published: false})
1368
        |> Repo.update()
2✔
1369

1370
        # assumption: if team assessment, all team members are under the same avenger
1371
        effective_student_id =
2✔
1372
          if is_nil(submission.student_id) do
2✔
1373
            Teams.get_first_member(submission.team_id).student_id
×
1374
          else
1375
            submission.student_id
2✔
1376
          end
1377

1378
        Notifications.handle_unpublish_grades_notifications(
2✔
1379
          submission.assessment.id,
2✔
1380
          Repo.get(CourseRegistration, effective_student_id)
1381
        )
1382

1383
        {:ok, nil}
1384

1385
      {:submission_found?, false} ->
×
1386
        {:error, {:not_found, "Submission not found"}}
1387

1388
      {:allowed_to_publish?, false} ->
1✔
1389
        {:error,
1390
         {:forbidden, "Only Avenger of student or Admin is permitted to unpublish grading"}}
1391

1392
      {:is_manually_graded?, false} ->
×
1393
        {:error,
1394
         {:bad_request, "Only manually graded assessments can be individually unpublished"}}
1395

1396
      _ ->
×
1397
        {:error, {:internal_server_error, "Please try again later."}}
1398
    end
1399
  end
1400

1401
  @doc """
1402
    Publishes grading for a submission and send notification to student.
1403
    Requires admin or staff who is group leader of student and all answers to be graded.
1404

1405
    Only manually graded assessments can be individually published. We can only
1406
    publish all submissions for auto-graded assessments.
1407

1408
    Returns `{:ok, nil}` on success, otherwise `{:error, {status, message}}`.
1409
  """
1410
  def publish_grading(submission_id, cr = %CourseRegistration{})
1411
      when is_ecto_id(submission_id) do
1412
    case can_publish?(submission_id, cr) do
4✔
1413
      {:ok, submission} ->
1414
        submission
1415
        |> Submission.changeset(%{is_grading_published: true})
1416
        |> Repo.update()
2✔
1417

1418
        update_xp_bonus(submission)
2✔
1419

1420
        Notifications.write_notification_when_published(
2✔
1421
          submission.id,
2✔
1422
          :published_grading
1423
        )
1424

1425
        Notification
1426
        |> where(submission_id: ^submission.id, type: :submitted)
2✔
1427
        |> select([n], n.id)
2✔
1428
        |> Repo.all()
1429
        |> Notifications.acknowledge(cr)
2✔
1430

1431
        {:ok, nil}
1432

1433
      {:submission_found?, false} ->
×
1434
        {:error, {:not_found, "Submission not found"}}
1435

1436
      {:status, :attempting} ->
×
1437
        {:error, {:bad_request, "Some questions have not been attempted"}}
1438

1439
      {:status, :attempted} ->
1✔
1440
        {:error, {:bad_request, "Assessment has not been submitted"}}
1441

1442
      {:allowed_to_publish?, false} ->
1✔
1443
        {:error, {:forbidden, "Only Avenger of student or Admin is permitted to publish grading"}}
1444

1445
      {:is_manually_graded?, false} ->
×
1446
        {:error, {:bad_request, "Only manually graded assessments can be individually published"}}
1447

1448
      {:fully_graded?, false} ->
×
1449
        {:error, {:bad_request, "Some answers are not graded"}}
1450

1451
      _ ->
×
1452
        {:error, {:internal_server_error, "Please try again later."}}
1453
    end
1454
  end
1455

1456
  @doc """
1457
    Publishes grading for a submission and send notification to student.
1458
    This function is used by the auto-grading system to publish grading. Bypasses Course Reg checks.
1459

1460
    Returns `{:ok, nil}` on success, otherwise `{:error, {status, message}}`.
1461
  """
1462
  def publish_grading(submission_id)
1463
      when is_ecto_id(submission_id) do
1464
    submission =
14✔
1465
      Submission
1466
      |> join(:inner, [s], a in assoc(s, :assessment))
1467
      |> preload([_, a], assessment: a)
14✔
1468
      |> Repo.get(submission_id)
1469

1470
    with {:submission_found?, true} <- {:submission_found?, is_map(submission)},
14✔
1471
         {:status, :submitted} <- {:status, submission.status} do
14✔
1472
      submission
1473
      |> Submission.changeset(%{is_grading_published: true})
1474
      |> Repo.update()
14✔
1475

1476
      Notifications.write_notification_when_published(
14✔
1477
        submission.id,
14✔
1478
        :published_grading
1479
      )
1480

1481
      {:ok, nil}
1482
    else
1483
      {:submission_found?, false} ->
×
1484
        {:error, {:not_found, "Submission not found"}}
1485

1486
      {:status, :attempting} ->
×
1487
        {:error, {:bad_request, "Some questions have not been attempted"}}
1488

1489
      {:status, :attempted} ->
×
1490
        {:error, {:bad_request, "Assessment has not been submitted"}}
1491

1492
      _ ->
×
1493
        {:error, {:internal_server_error, "Please try again later."}}
1494
    end
1495
  end
1496

1497
  @doc """
1498
    Publishes grading for all graded submissions for an assessment and sends notifications to students.
1499
    Requires admin.
1500

1501
    Returns `{:ok, nil}` on success, otherwise `{:error, {status, message}}`.
1502
  """
1503
  def publish_all_graded(publisher = %CourseRegistration{}, assessment_id) do
1504
    if publisher.role == :admin do
2✔
1505
      answers_query =
2✔
1506
        Answer
1507
        |> group_by([ans], ans.submission_id)
1508
        |> select([ans], %{
2✔
1509
          submission_id: ans.submission_id,
1510
          graded_count: filter(count(ans.id), not is_nil(ans.grader_id)),
1511
          autograded_count: filter(count(ans.id), ans.autograding_status == :success)
1512
        })
1513

1514
      question_query =
2✔
1515
        Question
1516
        |> group_by([q], q.assessment_id)
2✔
1517
        |> join(:inner, [q], a in Assessment, on: q.assessment_id == a.id)
1518
        |> select([q, a], %{
2✔
1519
          assessment_id: q.assessment_id,
1520
          question_count: count(q.id)
1521
        })
1522

1523
      submission_query =
2✔
1524
        Submission
1525
        |> join(:inner, [s], ans in subquery(answers_query), on: ans.submission_id == s.id)
2✔
1526
        |> join(:inner, [s, ans], asst in subquery(question_query),
2✔
1527
          on: s.assessment_id == asst.assessment_id
1528
        )
1529
        |> join(:inner, [s, ans, asst], cr in CourseRegistration, on: s.student_id == cr.id)
1530
        |> where([s, ans, asst, cr], cr.course_id == ^publisher.course_id)
2✔
1531
        |> where(
1532
          [s, ans, asst, cr],
1533
          asst.question_count == ans.graded_count or asst.question_count == ans.autograded_count
1534
        )
1535
        |> where([s, ans, asst, cr], s.is_grading_published == false)
1536
        |> where([s, ans, asst, cr], s.assessment_id == ^assessment_id)
1537
        |> select([s, ans, asst, cr], %{
2✔
1538
          id: s.id
1539
        })
1540

1541
      submissions = Repo.all(submission_query)
2✔
1542

1543
      Repo.update_all(submission_query, set: [is_grading_published: true])
2✔
1544

1545
      Enum.each(submissions, fn submission ->
2✔
1546
        Notifications.write_notification_when_published(
2✔
1547
          submission.id,
2✔
1548
          :published_grading
1549
        )
1550
      end)
1551

1552
      {:ok, nil}
1553
    else
1554
      {:error, {:forbidden, "Only Admin is permitted to publish all grades"}}
1555
    end
1556
  end
1557

1558
  @doc """
1559
     Unpublishes grading for all submissions with grades published for an assessment and sends notifications to students.
1560
     Requires admin role.
1561

1562
     Returns `{:ok, nil}` on success, otherwise `{:error, {status, message}}`.
1563
  """
1564

1565
  def unpublish_all(publisher = %CourseRegistration{}, assessment_id) do
1566
    if publisher.role == :admin do
2✔
1567
      submission_query =
2✔
1568
        Submission
1569
        |> join(:inner, [s], cr in CourseRegistration, on: s.student_id == cr.id)
1570
        |> where([s, cr], cr.course_id == ^publisher.course_id)
2✔
1571
        |> where([s, cr], s.is_grading_published == true)
1572
        |> where([s, cr], s.assessment_id == ^assessment_id)
1573
        |> select([s, cr], %{
2✔
1574
          id: s.id,
1575
          student_id: cr.id
1576
        })
1577

1578
      submissions = Repo.all(submission_query)
2✔
1579

1580
      Repo.update_all(submission_query, set: [is_grading_published: false])
2✔
1581

1582
      Enum.each(submissions, fn submission ->
2✔
1583
        Notifications.handle_unpublish_grades_notifications(
2✔
1584
          assessment_id,
1585
          Repo.get(CourseRegistration, submission.student_id)
2✔
1586
        )
1587
      end)
1588

1589
      {:ok, nil}
1590
    else
1591
      {:error, {:forbidden, "Only Admin is permitted to unpublish all grades"}}
1592
    end
1593
  end
1594

1595
  @spec update_submission_status(Submission.t()) ::
1596
          {:ok, Submission.t()} | {:error, Ecto.Changeset.t()}
1597
  defp update_submission_status(submission = %Submission{}) do
1598
    submission
1599
    |> Submission.changeset(%{status: :submitted, submitted_at: Timex.now()})
1600
    |> Repo.update()
358✔
1601
  end
1602

1603
  @spec update_xp_bonus(Submission.t()) ::
1604
          {:ok, Submission.t()} | {:error, Ecto.Changeset.t()}
1605
  # TODO: Should destructure and pattern match on the function
1606
  defp update_xp_bonus(submission = %Submission{id: submission_id}) do
1607
    # to ensure backwards compatibility
1608
    if submission.xp_bonus == 0 do
366✔
1609
      assessment = submission.assessment
364✔
1610
      assessment_conifg = Repo.get_by(AssessmentConfig, id: assessment.config_id)
364✔
1611

1612
      max_bonus_xp = assessment_conifg.early_submission_xp
364✔
1613
      early_hours = assessment_conifg.hours_before_early_xp_decay
364✔
1614

1615
      ans_xp =
364✔
1616
        Answer
1617
        |> where(submission_id: ^submission_id)
1618
        |> order_by(:question_id)
1619
        |> select([a], %{
364✔
1620
          total_xp: a.xp + a.xp_adjustment
1621
        })
1622

1623
      total =
364✔
1624
        ans_xp
1625
        |> subquery
1626
        |> select([a], %{
364✔
1627
          total_xp: coalesce(sum(a.total_xp), 0)
1628
        })
1629
        |> Repo.one()
1630

1631
      cur_time =
364✔
1632
        if submission.submitted_at == nil do
364✔
1633
          Timex.now()
4✔
1634
        else
1635
          submission.submitted_at
360✔
1636
        end
1637

1638
      xp_bonus =
364✔
1639
        if total.total_xp <= 0 do
364✔
1640
          0
1641
        else
1642
          if Timex.before?(cur_time, Timex.shift(assessment.open_at, hours: early_hours)) do
204✔
1643
            max_bonus_xp
2✔
1644
          else
1645
            # This logic interpolates from max bonus at early hour to 0 bonus at close time
1646
            decaying_hours =
202✔
1647
              Timex.diff(assessment.close_at, assessment.open_at, :hours) - early_hours
202✔
1648

1649
            remaining_hours = Enum.max([0, Timex.diff(assessment.close_at, cur_time, :hours)])
202✔
1650
            proportion = if(decaying_hours > 0, do: remaining_hours / decaying_hours, else: 1)
202✔
1651
            bonus_xp = round(max_bonus_xp * proportion)
202✔
1652
            Enum.max([0, bonus_xp])
202✔
1653
          end
1654
        end
1655

1656
      submission
1657
      |> Submission.changeset(%{xp_bonus: xp_bonus})
1658
      |> Repo.update()
364✔
1659
    end
1660
  end
1661

1662
  defp update_submission_status_router(submission = %Submission{}, question = %Question{}) do
1663
    case question.type do
43✔
1664
      :voting -> update_contest_voting_submission_status(submission, question)
12✔
1665
      :mcq -> update_submission_status(submission, question.assessment)
17✔
1666
      :programming -> update_submission_status(submission, question.assessment)
14✔
1667
    end
1668
  end
1669

1670
  defp update_submission_status(submission = %Submission{}, assessment = %Assessment{}) do
1671
    model_assoc_count = fn model, assoc, id ->
31✔
1672
      model
1673
      |> where(id: ^id)
62✔
1674
      |> join(:inner, [m], a in assoc(m, ^assoc))
1675
      |> select([_, a], count(a.id))
62✔
1676
      |> Repo.one()
62✔
1677
    end
1678

1679
    Multi.new()
1680
    |> Multi.run(:assessment, fn _repo, _ ->
31✔
1681
      {:ok, model_assoc_count.(Assessment, :questions, assessment.id)}
31✔
1682
    end)
1683
    |> Multi.run(:submission, fn _repo, _ ->
31✔
1684
      {:ok, model_assoc_count.(Submission, :answers, submission.id)}
31✔
1685
    end)
1686
    |> Multi.run(:update, fn _repo, %{submission: s_count, assessment: a_count} ->
1687
      if s_count == a_count do
31✔
1688
        submission |> Submission.changeset(%{status: :attempted}) |> Repo.update()
5✔
1689
      else
1690
        {:ok, nil}
1691
      end
1692
    end)
1693
    |> Repo.transaction()
31✔
1694
  end
1695

1696
  defp update_contest_voting_submission_status(submission = %Submission{}, question = %Question{}) do
1697
    has_nil_entries =
12✔
1698
      SubmissionVotes
1699
      |> where(question_id: ^question.id)
12✔
1700
      |> where(voter_id: ^submission.student_id)
12✔
1701
      |> where([sv], is_nil(sv.score))
12✔
1702
      |> Repo.exists?()
1703

1704
    unless has_nil_entries do
12✔
1705
      submission |> Submission.changeset(%{status: :attempted}) |> Repo.update()
12✔
1706
    end
1707
  end
1708

1709
  defp load_contest_voting_entries(
1710
         questions,
1711
         %CourseRegistration{role: role, course_id: course_id, id: voter_id},
1712
         assessment,
1713
         visible_entries
1714
       ) do
1715
    Enum.map(
98✔
1716
      questions,
1717
      fn q ->
1718
        if q.type == :voting do
803✔
1719
          submission_votes = all_submission_votes_by_question_id_and_voter_id(q.id, voter_id)
267✔
1720
          # fetch top 10 contest voting entries with the contest question id
1721
          question_id = fetch_associated_contest_question_id(course_id, q)
267✔
1722

1723
          # fetch top 10 contest coting entries with contest question id based on popular score
1724
          popular_results =
267✔
1725
            if is_nil(question_id) do
249✔
1726
              []
1727
            else
1728
              if leaderboard_open?(assessment, q) or role in @open_all_assessment_roles do
18✔
1729
                fetch_top_popular_score_answers(question_id, visible_entries)
15✔
1730
              else
1731
                []
1732
              end
1733
            end
1734

1735
          leaderboard_results =
267✔
1736
            if is_nil(question_id) do
249✔
1737
              []
1738
            else
1739
              if leaderboard_open?(assessment, q) or role in @open_all_assessment_roles do
18✔
1740
                fetch_top_relative_score_answers(question_id, visible_entries)
15✔
1741
              else
1742
                []
1743
              end
1744
            end
1745

1746
          # populate entries to vote for and leaderboard data into the question
1747
          voting_question =
267✔
1748
            q.question
267✔
1749
            |> Map.put(:contest_entries, submission_votes)
1750
            |> Map.put(
1751
              :contest_leaderboard,
1752
              leaderboard_results
1753
            )
1754
            |> Map.put(
1755
              :popular_leaderboard,
1756
              popular_results
1757
            )
1758

1759
          Map.put(q, :question, voting_question)
267✔
1760
        else
1761
          q
536✔
1762
        end
1763
      end
1764
    )
1765
  end
1766

1767
  defp all_submission_votes_by_question_id_and_voter_id(question_id, voter_id) do
1768
    SubmissionVotes
1769
    |> where([v], v.voter_id == ^voter_id and v.question_id == ^question_id)
267✔
1770
    |> join(:inner, [v], s in assoc(v, :submission))
267✔
1771
    |> join(:inner, [v, s], a in assoc(s, :answers))
1772
    |> select([v, s, a], %{submission_id: v.submission_id, answer: a.answer, score: v.score})
267✔
1773
    |> Repo.all()
267✔
1774
  end
1775

1776
  # Finds the contest_question_id associated with the given voting_question id
1777
  def fetch_associated_contest_question_id(course_id, voting_question) do
1778
    contest_number = voting_question.question["contest_number"]
272✔
1779

1780
    if is_nil(contest_number) do
272✔
1781
      nil
1782
    else
1783
      Assessment
1784
      |> where(number: ^contest_number, course_id: ^course_id)
272✔
1785
      |> join(:inner, [a], q in assoc(a, :questions))
1786
      |> order_by([a, q], q.display_order)
1787
      |> select([a, q], q.id)
272✔
1788
      |> Repo.one()
272✔
1789
    end
1790
  end
1791

1792
  defp leaderboard_open?(assessment, voting_question) do
1793
    Timex.before?(
36✔
1794
      Timex.shift(assessment.close_at, hours: voting_question.question["reveal_hours"]),
36✔
1795
      Timex.now()
1796
    )
1797
  end
1798

1799
  def fetch_contest_voting_assesment_id(assessment_id) do
1800
    contest_number =
×
1801
      Assessment
1802
      |> where(id: ^assessment_id)
1803
      |> select([a], a.number)
×
1804
      |> Repo.one()
1805

1806
    if is_nil(contest_number) do
×
1807
      nil
1808
    else
1809
      Assessment
1810
      |> join(:inner, [a], q in assoc(a, :questions))
1811
      |> where([a, q], q.question["contest_number"] == ^contest_number)
1812
      |> select([a], a.id)
×
1813
      |> Repo.one()
×
1814
    end
1815
  end
1816

1817
  @doc """
1818
  Fetches all contests for the course id where the voting assessment has been published
1819

1820
  Used for contest leaderboard dropdown fetching
1821
  """
1822
  def fetch_all_contests(course_id) do
1823
    contest_numbers =
×
1824
      Question
1825
      |> where(type: :voting)
1826
      |> select([q], q.question["contest_number"])
×
1827
      |> Repo.all()
1828
      |> Enum.reject(&is_nil/1)
×
1829

1830
    if contest_numbers == [] do
×
1831
      []
1832
    else
1833
      Assessment
1834
      |> where([a], a.number in ^contest_numbers and a.course_id == ^course_id)
×
1835
      |> join(:inner, [a], ac in AssessmentConfig, on: a.config_id == ac.id)
1836
      |> where([a, ac], ac.type == "Contests")
1837
      |> select([a], %{contest_id: a.id, title: a.title, published: a.is_published})
×
1838
      |> Repo.all()
×
1839
    end
1840
  end
1841

1842
  @doc """
1843
  Fetches top answers for the given question, based on the contest relative_score
1844

1845
  Used for contest leaderboard fetching
1846
  """
1847
  def fetch_top_relative_score_answers(question_id, number_of_answers) do
1848
    subquery =
24✔
1849
      Answer
1850
      |> where(question_id: ^question_id)
1851
      |> where(
1852
        [a],
1853
        fragment(
1854
          "?->>'code' like ?",
1855
          a.answer,
1856
          "%return%"
1857
        )
1858
      )
1859
      |> order_by(desc: :relative_score)
24✔
1860
      |> join(:left, [a], s in assoc(a, :submission))
24✔
1861
      |> join(:left, [a, s], student in assoc(s, :student))
24✔
1862
      |> join(:inner, [a, s, student], student_user in assoc(student, :user))
1863
      |> where([a, s, student], student.role == "student")
1864
      |> select([a, s, student, student_user], %{
24✔
1865
        submission_id: a.submission_id,
1866
        answer: a.answer,
1867
        relative_score: a.relative_score,
1868
        student_name: student_user.name,
1869
        student_username: student_user.username,
1870
        rank: fragment("RANK() OVER (ORDER BY ? DESC)", a.relative_score)
1871
      })
1872

1873
    final_query =
24✔
1874
      from(r in subquery(subquery),
24✔
1875
        where: r.rank <= ^number_of_answers
1876
      )
1877

1878
    Repo.all(final_query)
24✔
1879
  end
1880

1881
  @doc """
1882
  Fetches top answers for the given question, based on the contest popular_score
1883

1884
  Used for contest leaderboard fetching
1885
  """
1886
  def fetch_top_popular_score_answers(question_id, number_of_answers) do
1887
    subquery =
16✔
1888
      Answer
1889
      |> where(question_id: ^question_id)
1890
      |> where(
1891
        [a],
1892
        fragment(
1893
          "?->>'code' like ?",
1894
          a.answer,
1895
          "%return%"
1896
        )
1897
      )
1898
      |> order_by(desc: :popular_score)
16✔
1899
      |> join(:left, [a], s in assoc(a, :submission))
16✔
1900
      |> join(:left, [a, s], student in assoc(s, :student))
16✔
1901
      |> join(:inner, [a, s, student], student_user in assoc(student, :user))
1902
      |> where([a, s, student], student.role == "student")
1903
      |> select([a, s, student, student_user], %{
16✔
1904
        submission_id: a.submission_id,
1905
        answer: a.answer,
1906
        popular_score: a.popular_score,
1907
        student_name: student_user.name,
1908
        student_username: student_user.username,
1909
        rank: fragment("RANK() OVER (ORDER BY ? DESC)", a.popular_score)
1910
      })
1911

1912
    final_query =
16✔
1913
      from(r in subquery(subquery),
16✔
1914
        where: r.rank <= ^number_of_answers
1915
      )
1916

1917
    Repo.all(final_query)
16✔
1918
  end
1919

1920
  @doc """
1921
  Computes rolling leaderboard for contest votes that are still open.
1922
  """
1923
  def update_rolling_contest_leaderboards do
1924
    # 115 = 2 hours - 5 minutes is default.
1925
    if Log.log_execution("update_rolling_contest_leaderboards", Duration.from_minutes(115)) do
1✔
1926
      Logger.info("Started update_rolling_contest_leaderboards")
1✔
1927

1928
      voting_questions_to_update = fetch_active_voting_questions()
1✔
1929

1930
      _ =
1✔
1931
        voting_questions_to_update
1932
        |> Enum.map(fn qn -> compute_relative_score(qn.id) end)
1✔
1933

1934
      Logger.info("Successfully update_rolling_contest_leaderboards")
1✔
1935
    end
1936
  end
1937

1938
  def fetch_active_voting_questions do
1939
    Question
1940
    |> join(:left, [q], a in assoc(q, :assessment))
1941
    |> where([q, a], q.type == "voting")
1942
    |> where([q, a], a.is_published)
1943
    |> where([q, a], a.open_at <= ^Timex.now() and a.close_at >= ^Timex.now())
2✔
1944
    |> Repo.all()
2✔
1945
  end
1946

1947
  @doc """
1948
  Computes final leaderboard for contest votes that have closed.
1949
  """
1950
  def update_final_contest_leaderboards do
1951
    # 1435 = 24 hours - 5 minutes
1952
    if Log.log_execution("update_final_contest_leaderboards", Duration.from_minutes(1435)) do
1✔
1953
      Logger.info("Started update_final_contest_leaderboards")
1✔
1954

1955
      voting_questions_to_update = fetch_voting_questions_due_yesterday() || []
1✔
1956

1957
      voting_questions_to_update =
1✔
1958
        if is_nil(voting_questions_to_update), do: [], else: voting_questions_to_update
1✔
1959

1960
      scores =
1✔
1961
        Enum.map(voting_questions_to_update, fn qn ->
1962
          compute_relative_score(qn.id)
1✔
1963
        end)
1964

1965
      if Enum.empty?(voting_questions_to_update) do
1✔
1966
        Logger.warn("No voting questions to update.")
×
1967
      else
1968
        # Process each voting question
1969
        Enum.each(voting_questions_to_update, fn qn ->
1✔
1970
          assign_winning_contest_entries_xp(qn.id)
1✔
1971
        end)
1972

1973
        Logger.info("Successfully update_final_contest_leaderboards")
1✔
1974
      end
1975

1976
      scores
1✔
1977
    end
1978
  end
1979

1980
  def fetch_voting_questions_due_yesterday do
1981
    Question
1982
    |> join(:left, [q], a in assoc(q, :assessment))
1983
    |> where([q, a], q.type == "voting")
1984
    |> where([q, a], a.is_published)
1985
    |> where([q, a], a.open_at <= ^Timex.now())
1986
    |> where(
2✔
1987
      [q, a],
1988
      a.close_at < ^Timex.now() and a.close_at >= ^Timex.shift(Timex.now(), days: -1)
1989
    )
1990
    |> Repo.all()
2✔
1991
  end
1992

1993
  @doc """
1994
  Automatically assigns XP to the winning contest entries
1995
  """
1996
  def assign_winning_contest_entries_xp(contest_voting_question_id) do
1997
    voting_questions =
6✔
1998
      Question
1999
      |> where(type: :voting)
2000
      |> where(id: ^contest_voting_question_id)
6✔
2001
      |> Repo.one()
2002

2003
    contest_question_id =
6✔
2004
      SubmissionVotes
2005
      |> where(question_id: ^contest_voting_question_id)
6✔
2006
      |> join(:inner, [sv], ans in Answer, on: sv.submission_id == ans.submission_id)
2007
      |> select([sv, ans], ans.question_id)
2008
      |> limit(1)
6✔
2009
      |> Repo.one()
2010

2011
    if is_nil(contest_question_id) do
6✔
2012
      Logger.warn("Contest question ID is missing. Terminating.")
×
2013
      :ok
2014
    else
2015
      default_xp_values = %Cadet.Assessments.QuestionTypes.VotingQuestion{} |> Map.get(:xp_values)
6✔
2016
      scores = voting_questions.question["xp_values"] || default_xp_values
6✔
2017

2018
      if scores == [] do
6✔
2019
        Logger.warn("No XP values provided. Terminating.")
×
2020
        :ok
2021
      else
2022
        Repo.transaction(fn ->
6✔
2023
          submission_ids =
6✔
2024
            Answer
2025
            |> where(question_id: ^contest_question_id)
2026
            |> select([a], a.submission_id)
6✔
2027
            |> Repo.all()
2028

2029
          Submission
2030
          |> where([s], s.id in ^submission_ids)
6✔
2031
          |> Repo.update_all(set: [is_grading_published: true])
6✔
2032

2033
          winning_popular_entries =
6✔
2034
            Answer
2035
            |> where(question_id: ^contest_question_id)
2036
            |> select([a], %{
6✔
2037
              id: a.id,
2038
              rank: fragment("rank() OVER (ORDER BY ? DESC)", a.popular_score)
2039
            })
2040
            |> Repo.all()
2041

2042
          winning_popular_entries
2043
          |> Enum.each(fn %{id: answer_id, rank: rank} ->
6✔
2044
            increment = Enum.at(scores, rank - 1, 0)
30✔
2045
            answer = Repo.get!(Answer, answer_id)
30✔
2046
            Repo.update!(Changeset.change(answer, %{xp_adjustment: increment}))
30✔
2047
          end)
2048

2049
          winning_score_entries =
6✔
2050
            Answer
2051
            |> where(question_id: ^contest_question_id)
2052
            |> select([a], %{
6✔
2053
              id: a.id,
2054
              rank: fragment("rank() OVER (ORDER BY ? DESC)", a.relative_score)
2055
            })
2056
            |> Repo.all()
2057

2058
          winning_score_entries
2059
          |> Enum.each(fn %{id: answer_id, rank: rank} ->
6✔
2060
            increment = Enum.at(scores, rank - 1, 0)
30✔
2061
            answer = Repo.get!(Answer, answer_id)
30✔
2062
            new_value = answer.xp_adjustment + increment
30✔
2063
            Repo.update!(Changeset.change(answer, %{xp_adjustment: new_value}))
30✔
2064
          end)
2065
        end)
2066

2067
        Logger.info("XP assigned to winning contest entries")
6✔
2068
      end
2069
    end
2070
  end
2071

2072
  @doc """
2073
  Computes the current relative_score of each voting submission answer
2074
  based on current submitted votes.
2075
  """
2076
  def compute_relative_score(contest_voting_question_id) do
2077
    # reset all scores to 0 first
2078
    voting_questions =
3✔
2079
      Question
2080
      |> where(type: :voting)
2081
      |> where(id: ^contest_voting_question_id)
3✔
2082
      |> Repo.one()
2083

2084
    if is_nil(voting_questions) do
3✔
2085
      IO.puts("Voting question not found, skipping score computation.")
×
2086
      :ok
2087
    else
2088
      course_id =
3✔
2089
        Assessment
2090
        |> where(id: ^voting_questions.assessment_id)
3✔
2091
        |> select([a], a.course_id)
3✔
2092
        |> Repo.one()
2093

2094
      if is_nil(course_id) do
3✔
2095
        IO.puts("Course ID not found, skipping score computation.")
×
2096
        :ok
2097
      else
2098
        contest_question_id = fetch_associated_contest_question_id(course_id, voting_questions)
3✔
2099

2100
        if !is_nil(contest_question_id) do
3✔
2101
          # reset all scores to 0 first
2102
          Answer
2103
          |> where([ans], ans.question_id == ^contest_question_id)
2104
          |> update([ans], set: [popular_score: 0.0, relative_score: 0.0])
×
2105
          |> Repo.update_all([])
×
2106
        end
2107
      end
2108
    end
2109

2110
    # query all records from submission votes tied to the question id ->
2111
    # map score to user id ->
2112
    # store as grade ->
2113
    # query grade for contest question id.
2114
    eligible_votes =
3✔
2115
      SubmissionVotes
2116
      |> where(question_id: ^contest_voting_question_id)
2117
      |> where([sv], not is_nil(sv.score))
3✔
2118
      |> join(:inner, [sv], ans in Answer, on: sv.submission_id == ans.submission_id)
2119
      |> select(
3✔
2120
        [sv, ans],
2121
        %{ans_id: ans.id, score: sv.score, ans: ans.answer["code"]}
2122
      )
2123
      |> Repo.all()
2124

2125
    token_divider =
3✔
2126
      Question
2127
      |> select([q], q.question["token_divider"])
3✔
2128
      |> Repo.get_by(id: contest_voting_question_id)
2129

2130
    entry_scores = map_eligible_votes_to_entry_score(eligible_votes, token_divider)
3✔
2131
    normalized_scores = map_eligible_votes_to_popular_score(eligible_votes, token_divider)
3✔
2132

2133
    entry_scores
2134
    |> Enum.map(fn {ans_id, relative_score} ->
2135
      %Answer{id: ans_id}
2136
      |> Answer.contest_score_update_changeset(%{
15✔
2137
        relative_score: relative_score
2138
      })
2139
    end)
2140
    |> Enum.map(fn changeset ->
2141
      op_key = "answer_#{changeset.data.id}"
15✔
2142
      Multi.update(Multi.new(), op_key, changeset)
15✔
2143
    end)
2144
    |> Enum.reduce(Multi.new(), &Multi.append/2)
2145
    |> Repo.transaction()
3✔
2146

2147
    normalized_scores
2148
    |> Enum.map(fn {ans_id, popular_score} ->
2149
      %Answer{id: ans_id}
2150
      |> Answer.popular_score_update_changeset(%{
15✔
2151
        popular_score: popular_score
2152
      })
2153
    end)
2154
    |> Enum.map(fn changeset ->
2155
      op_key = "answer_#{changeset.data.id}"
15✔
2156
      Multi.update(Multi.new(), op_key, changeset)
15✔
2157
    end)
2158
    |> Enum.reduce(Multi.new(), &Multi.append/2)
2159
    |> Repo.transaction()
3✔
2160
  end
2161

2162
  defp map_eligible_votes_to_entry_score(eligible_votes, token_divider) do
2163
    # converts eligible votes to the {total cumulative score, number of votes, tokens}
2164
    entry_vote_data =
3✔
2165
      Enum.reduce(eligible_votes, %{}, fn %{ans_id: ans_id, score: score, ans: ans}, tracker ->
2166
        {prev_score, prev_count, _ans_tokens} = Map.get(tracker, ans_id, {0, 0, 0})
75✔
2167

2168
        Map.put(
75✔
2169
          tracker,
2170
          ans_id,
2171
          # assume each voter is assigned 10 entries which will make it fair.
2172
          {prev_score + score, prev_count + 1, Lexer.count_tokens(ans)}
2173
        )
2174
      end)
2175

2176
    # calculate the score based on formula {ans_id, score}
2177
    Enum.map(
3✔
2178
      entry_vote_data,
2179
      fn {ans_id, {sum_of_scores, number_of_voters, tokens}} ->
15✔
2180
        {ans_id, calculate_formula_score(sum_of_scores, number_of_voters, tokens, token_divider)}
2181
      end
2182
    )
2183
  end
2184

2185
  defp map_eligible_votes_to_popular_score(eligible_votes, token_divider) do
2186
    # converts eligible votes to the {total cumulative score, number of votes, tokens}
2187
    entry_vote_data =
3✔
2188
      Enum.reduce(eligible_votes, %{}, fn %{ans_id: ans_id, score: score, ans: ans}, tracker ->
2189
        {prev_score, prev_count, _ans_tokens} = Map.get(tracker, ans_id, {0, 0, 0})
75✔
2190

2191
        Map.put(
75✔
2192
          tracker,
2193
          ans_id,
2194
          # assume each voter is assigned 10 entries which will make it fair.
2195
          {prev_score + score, prev_count + 1, Lexer.count_tokens(ans)}
2196
        )
2197
      end)
2198

2199
    # calculate the score based on formula {ans_id, score}
2200
    Enum.map(
3✔
2201
      entry_vote_data,
2202
      fn {ans_id, {sum_of_scores, number_of_voters, tokens}} ->
15✔
2203
        {ans_id,
2204
         calculate_normalized_score(sum_of_scores, number_of_voters, tokens, token_divider)}
2205
      end
2206
    )
2207
  end
2208

2209
  # Calculate the score based on formula
2210
  # score(v,t) = v - 2^(t/token_divider) where v is the normalized_voting_score
2211
  # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100
2212
  defp calculate_formula_score(sum_of_scores, number_of_voters, tokens, token_divider) do
2213
    normalized_voting_score =
15✔
2214
      calculate_normalized_score(sum_of_scores, number_of_voters, tokens, token_divider)
2215

2216
    normalized_voting_score - :math.pow(2, min(1023.5, tokens / token_divider))
15✔
2217
  end
2218

2219
  # Calculate the normalized score based on formula
2220
  # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100
2221
  defp calculate_normalized_score(sum_of_scores, number_of_voters, _tokens, _token_divider) do
2222
    sum_of_scores / number_of_voters / 10 * 100
30✔
2223
  end
2224

2225
  @doc """
2226
  Function returning submissions under a grader. This function returns only the
2227
  fields that are exposed in the /grading endpoint.
2228

2229
  The input parameters are the user and query parameters. Query parameters are
2230
  used to filter the submissions.
2231

2232
  The return value is `{:ok, %{"count": count, "data": submissions}}`
2233

2234
  # Params
2235
  Refer to admin_grading_controller.ex/index for the list of query parameters.
2236

2237
  # Implementation
2238
  Uses helper functions to build the filter query. Helper functions are separated by tables in the database.
2239
  """
2240

2241
  @spec submissions_by_grader_for_index(CourseRegistration.t(), map()) ::
2242
          {:ok,
2243
           %{
2244
             :count => integer,
2245
             :data => %{
2246
               :assessments => [any()],
2247
               :submissions => [any()],
2248
               :users => [any()],
2249
               :teams => [any()],
2250
               :team_members => [any()]
2251
             }
2252
           }}
2253
  def submissions_by_grader_for_index(
2254
        grader = %CourseRegistration{course_id: course_id},
2255
        params
2256
      ) do
2257
    submission_answers_query =
41✔
2258
      from(ans in Answer,
2259
        group_by: ans.submission_id,
2260
        select: %{
2261
          submission_id: ans.submission_id,
2262
          xp: sum(ans.xp),
2263
          xp_adjustment: sum(ans.xp_adjustment),
2264
          graded_count: filter(count(ans.id), not is_nil(ans.grader_id))
2265
        }
2266
      )
2267

2268
    question_answers_query =
41✔
2269
      from(q in Question,
41✔
2270
        group_by: q.assessment_id,
2271
        join: a in Assessment,
2272
        on: q.assessment_id == a.id,
2273
        select: %{
2274
          assessment_id: q.assessment_id,
2275
          question_count: count(q.id),
2276
          title: max(a.title),
2277
          config_id: max(a.config_id)
2278
        }
2279
      )
2280

2281
    query =
41✔
2282
      from(s in Submission,
41✔
2283
        left_join: ans in subquery(submission_answers_query),
2284
        on: ans.submission_id == s.id,
2285
        as: :ans,
2286
        left_join: asst in subquery(question_answers_query),
2287
        on: asst.assessment_id == s.assessment_id,
2288
        as: :asst,
2289
        left_join: cr in CourseRegistration,
2290
        on: s.student_id == cr.id,
2291
        as: :cr,
2292
        left_join: user in User,
2293
        on: user.id == cr.user_id,
2294
        as: :user,
2295
        left_join: group in Group,
2296
        on: cr.group_id == group.id,
2297
        as: :group,
2298
        inner_join: config in AssessmentConfig,
2299
        on: asst.config_id == config.id,
2300
        as: :config,
2301
        where: ^build_user_filter(params),
2302
        where: s.assessment_id in subquery(build_assessment_filter(params, course_id)),
2303
        where: s.assessment_id in subquery(build_assessment_config_filter(params)),
2304
        where: ^build_submission_filter(params),
2305
        where: ^build_course_registration_filter(params, grader),
2306
        limit: ^params[:page_size],
2307
        offset: ^params[:offset],
2308
        select: %{
2309
          id: s.id,
2310
          status: s.status,
2311
          xp_bonus: s.xp_bonus,
2312
          unsubmitted_at: s.unsubmitted_at,
2313
          unsubmitted_by_id: s.unsubmitted_by_id,
2314
          student_id: s.student_id,
2315
          team_id: s.team_id,
2316
          assessment_id: s.assessment_id,
2317
          is_grading_published: s.is_grading_published,
2318
          xp: ans.xp,
2319
          xp_adjustment: ans.xp_adjustment,
2320
          graded_count: ans.graded_count,
2321
          question_count: asst.question_count
2322
        }
2323
      )
2324

2325
    query = sort_submission(query, params[:sort_by], params[:sort_direction])
41✔
2326

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

2330
    submissions = Repo.all(query)
41✔
2331

2332
    count_query =
41✔
2333
      from(s in Submission,
41✔
2334
        left_join: ans in subquery(submission_answers_query),
2335
        on: ans.submission_id == s.id,
2336
        as: :ans,
2337
        left_join: asst in subquery(question_answers_query),
2338
        on: asst.assessment_id == s.assessment_id,
2339
        as: :asst,
2340
        where: s.assessment_id in subquery(build_assessment_filter(params, course_id)),
2341
        where: s.assessment_id in subquery(build_assessment_config_filter(params)),
2342
        where: ^build_user_filter(params),
2343
        where: ^build_submission_filter(params),
2344
        where: ^build_course_registration_filter(params, grader),
2345
        select: count(s.id)
2346
      )
2347

2348
    count = Repo.one(count_query)
41✔
2349

2350
    {:ok, %{count: count, data: generate_grading_summary_view_model(submissions, course_id)}}
2351
  end
2352

2353
  # Given a query from submissions_by_grader_for_index,
2354
  # sorts it by the relevant field and direction.
2355
  defp sort_submission(query, sort_by, sort_direction)
2356
       when sort_direction in [:asc, :desc] do
2357
    case sort_by do
6✔
2358
      :assessment_name ->
2359
        from([s, ans, asst, cr, user, group, config] in query,
2✔
2360
          order_by: [{^sort_direction, fragment("upper(?)", asst.title)}]
2361
        )
2362

2363
      :assessment_type ->
2364
        from([s, ans, asst, cr, user, group, config] in query,
2✔
2365
          order_by: [{^sort_direction, asst.config_id}]
2366
        )
2367

2368
      :student_name ->
2369
        from([s, ans, asst, cr, user, group, config] in query,
×
2370
          order_by: [{^sort_direction, fragment("upper(?)", user.name)}]
2371
        )
2372

2373
      :student_username ->
2374
        from([s, ans, asst, cr, user, group, config] in query,
×
2375
          order_by: [{^sort_direction, fragment("upper(?)", user.username)}]
2376
        )
2377

2378
      :group_name ->
2379
        from([s, ans, asst, cr, user, group, config] in query,
×
2380
          order_by: [{^sort_direction, fragment("upper(?)", group.name)}]
2381
        )
2382

2383
      :progress_status ->
2384
        from([s, ans, asst, cr, user, group, config] in query,
×
2385
          order_by: [
2386
            {^sort_direction, config.is_manually_graded},
2387
            {^sort_direction, s.status},
2388
            {^sort_direction, ans.graded_count - asst.question_count},
2389
            {^sort_direction, s.is_grading_published}
2390
          ]
2391
        )
2392

2393
      :xp ->
2394
        from([s, ans, asst, cr, user, group, config] in query,
2✔
2395
          order_by: [{^sort_direction, ans.xp + ans.xp_adjustment}]
2396
        )
2397

2398
      _ ->
2399
        query
×
2400
    end
2401
  end
2402

2403
  defp sort_submission(query, _sort_by, _sort_direction), do: query
35✔
2404

2405
  def parse_sort_direction(params) do
2406
    case params[:sort_direction] do
5✔
2407
      "sort-asc" -> Map.put(params, :sort_direction, :asc)
×
2408
      "sort-desc" -> Map.put(params, :sort_direction, :desc)
×
2409
      _ -> Map.put(params, :sort_direction, nil)
5✔
2410
    end
2411
  end
2412

2413
  def parse_sort_by(params) do
2414
    case params[:sort_by] do
5✔
2415
      "assessmentName" -> Map.put(params, :sort_by, :assessment_name)
×
2416
      "assessmentType" -> Map.put(params, :sort_by, :assessment_type)
×
2417
      "studentName" -> Map.put(params, :sort_by, :student_name)
×
2418
      "studentUsername" -> Map.put(params, :sort_by, :student_username)
×
2419
      "groupName" -> Map.put(params, :sort_by, :group_name)
×
2420
      "progressStatus" -> Map.put(params, :sort_by, :progress_status)
×
2421
      "xp" -> Map.put(params, :sort_by, :xp)
×
2422
      _ -> Map.put(params, :sort_by, nil)
5✔
2423
    end
2424
  end
2425

2426
  defp build_assessment_filter(params, course_id) do
2427
    assessments_filters =
82✔
2428
      Enum.reduce(params, dynamic(true), fn
82✔
2429
        {:title, value}, dynamic ->
2430
          dynamic([assessment], ^dynamic and ilike(assessment.title, ^"%#{value}%"))
2✔
2431

2432
        {_, _}, dynamic ->
2433
          dynamic
206✔
2434
      end)
2435

2436
    from(a in Assessment,
82✔
2437
      where: a.course_id == ^course_id,
2438
      where: ^assessments_filters,
2439
      select: a.id
2440
    )
2441
  end
2442

2443
  defp build_submission_filter(params) do
2444
    Enum.reduce(params, dynamic(true), fn
82✔
2445
      {:status, value}, dynamic ->
2446
        dynamic([submission], ^dynamic and submission.status == ^value)
6✔
2447

2448
      {:is_fully_graded, value}, dynamic ->
2449
        dynamic(
4✔
2450
          [ans: ans, asst: asst],
2451
          ^dynamic and asst.question_count == ans.graded_count == ^value
2452
        )
2453

2454
      {:is_grading_published, value}, dynamic ->
2455
        dynamic([submission], ^dynamic and submission.is_grading_published == ^value)
4✔
2456

2457
      {_, _}, dynamic ->
2458
        dynamic
194✔
2459
    end)
2460
  end
2461

2462
  defp build_course_registration_filter(params, grader) do
2463
    Enum.reduce(params, dynamic(true), fn
82✔
2464
      {:group, true}, dynamic ->
2465
        dynamic(
10✔
2466
          [submission],
2467
          (^dynamic and
2468
             submission.student_id in subquery(
2469
               from(cr in CourseRegistration,
10✔
2470
                 join: g in Group,
2471
                 on: cr.group_id == g.id,
2472
                 where: g.leader_id == ^grader.id,
10✔
2473
                 select: cr.id
2474
               )
2475
             )) or submission.student_id == ^grader.id
10✔
2476
        )
2477

2478
      {:group_name, value}, dynamic ->
2479
        dynamic(
4✔
2480
          [submission],
2481
          ^dynamic and
2482
            submission.student_id in subquery(
2483
              from(cr in CourseRegistration,
4✔
2484
                join: g in Group,
2485
                on: cr.group_id == g.id,
2486
                where: g.name == ^value,
2487
                select: cr.id
2488
              )
2489
            )
2490
        )
2491

2492
      {_, _}, dynamic ->
2493
        dynamic
194✔
2494
    end)
2495
  end
2496

2497
  defp build_user_filter(params) do
2498
    Enum.reduce(params, dynamic(true), fn
82✔
2499
      {:name, value}, dynamic ->
2500
        dynamic(
6✔
2501
          [submission],
2502
          ^dynamic and
2503
            submission.student_id in subquery(
2504
              from(user in User,
6✔
2505
                where: ilike(user.name, ^"%#{value}%"),
6✔
2506
                inner_join: cr in CourseRegistration,
2507
                on: user.id == cr.user_id,
2508
                select: cr.id
2509
              )
2510
            )
2511
        )
2512

2513
      {:username, value}, dynamic ->
2514
        dynamic(
6✔
2515
          [submission],
2516
          ^dynamic and
2517
            submission.student_id in subquery(
2518
              from(user in User,
6✔
2519
                where: ilike(user.username, ^"%#{value}%"),
6✔
2520
                inner_join: cr in CourseRegistration,
2521
                on: user.id == cr.user_id,
2522
                select: cr.id
2523
              )
2524
            )
2525
        )
2526

2527
      {_, _}, dynamic ->
2528
        dynamic
196✔
2529
    end)
2530
  end
2531

2532
  defp build_assessment_config_filter(params) do
2533
    assessment_config_filters =
82✔
2534
      Enum.reduce(params, dynamic(true), fn
82✔
2535
        {:type, value}, dynamic ->
2536
          dynamic([assessment_config: config], ^dynamic and config.type == ^value)
6✔
2537

2538
        {:is_manually_graded, value}, dynamic ->
2539
          dynamic([assessment_config: config], ^dynamic and config.is_manually_graded == ^value)
4✔
2540

2541
        {_, _}, dynamic ->
2542
          dynamic
198✔
2543
      end)
2544

2545
    from(a in Assessment,
82✔
2546
      inner_join: config in AssessmentConfig,
2547
      on: a.config_id == config.id,
2548
      as: :assessment_config,
2549
      where: ^assessment_config_filters,
2550
      select: a.id
2551
    )
2552
  end
2553

2554
  defp generate_grading_summary_view_model(submissions, course_id) do
2555
    users =
41✔
2556
      CourseRegistration
2557
      |> where([cr], cr.course_id == ^course_id)
41✔
2558
      |> join(:inner, [cr], u in assoc(cr, :user))
41✔
2559
      |> join(:left, [cr, u], g in assoc(cr, :group))
2560
      |> preload([cr, u, g], user: u, group: g)
41✔
2561
      |> Repo.all()
2562

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

2565
    assessments =
41✔
2566
      Assessment
2567
      |> where([a], a.id in ^assessment_ids)
41✔
2568
      |> join(:left, [a], q in assoc(a, :questions))
41✔
2569
      |> join(:inner, [a], ac in assoc(a, :config))
2570
      |> preload([a, q, ac], questions: q, config: ac)
41✔
2571
      |> Repo.all()
2572

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

2575
    teams =
41✔
2576
      Team
2577
      |> where([t], t.id in ^team_ids)
41✔
2578
      |> Repo.all()
2579

2580
    team_members =
41✔
2581
      TeamMember
2582
      |> where([tm], tm.team_id in ^team_ids)
41✔
2583
      |> Repo.all()
2584

2585
    %{
41✔
2586
      users: users,
2587
      assessments: assessments,
2588
      submissions: submissions,
2589
      teams: teams,
2590
      team_members: team_members
2591
    }
2592
  end
2593

2594
  @spec get_answers_in_submission(integer() | String.t()) ::
2595
          {:ok, {[Answer.t()], Assessment.t()}}
2596
          | {:error, {:bad_request, String.t()}}
2597
  def get_answers_in_submission(id) when is_ecto_id(id) do
2598
    answer_query =
4✔
2599
      Answer
2600
      |> where(submission_id: ^id)
4✔
2601
      |> join(:inner, [a], q in assoc(a, :question))
4✔
2602
      |> join(:inner, [_, q], ast in assoc(q, :assessment))
4✔
2603
      |> join(:inner, [..., ast], ac in assoc(ast, :config))
4✔
2604
      |> join(:left, [a, ...], g in assoc(a, :grader))
4✔
2605
      |> join(:left, [_, ..., g], gu in assoc(g, :user))
4✔
2606
      |> join(:inner, [a, ...], s in assoc(a, :submission))
4✔
2607
      |> join(:left, [_, ..., s], st in assoc(s, :student))
4✔
2608
      |> join(:left, [..., st], u in assoc(st, :user))
4✔
2609
      |> join(:left, [..., s, _, _], t in assoc(s, :team))
4✔
2610
      |> join(:left, [..., t], tm in assoc(t, :team_members))
4✔
2611
      |> join(:left, [..., tm], tms in assoc(tm, :student))
4✔
2612
      |> join(:left, [..., tms], tmu in assoc(tms, :user))
2613
      |> preload([_, q, ast, ac, g, gu, s, st, u, t, tm, tms, tmu],
4✔
2614
        question: {q, assessment: {ast, config: ac}},
2615
        grader: {g, user: gu},
2616
        submission:
2617
          {s, student: {st, user: u}, team: {t, team_members: {tm, student: {tms, user: tmu}}}}
2618
      )
2619

2620
    answers =
4✔
2621
      answer_query
2622
      |> Repo.all()
2623
      |> Enum.sort_by(& &1.question.display_order)
10✔
2624
      |> Enum.map(fn ans ->
2625
        if ans.question.type == :voting do
10✔
2626
          empty_contest_entries = Map.put(ans.question.question, :contest_entries, [])
2✔
2627
          empty_popular_leaderboard = Map.put(empty_contest_entries, :popular_leaderboard, [])
2✔
2628
          empty_contest_leaderboard = Map.put(empty_popular_leaderboard, :contest_leaderboard, [])
2✔
2629
          question = Map.put(ans.question, :question, empty_contest_leaderboard)
2✔
2630
          Map.put(ans, :question, question)
2✔
2631
        else
2632
          ans
8✔
2633
        end
2634
      end)
2635

2636
    if answers == [] do
4✔
2637
      {:error, {:bad_request, "Submission is not found."}}
2638
    else
2639
      assessment_id = Submission |> where(id: ^id) |> select([s], s.assessment_id) |> Repo.one()
2✔
2640
      assessment = Assessment |> where(id: ^assessment_id) |> Repo.one()
2✔
2641
      {:ok, {answers, assessment}}
2642
    end
2643
  end
2644

2645
  defp is_fully_graded?(submission_id) do
2646
    submission =
6✔
2647
      Submission
2648
      |> Repo.get_by(id: submission_id)
2649

2650
    question_count =
6✔
2651
      Question
2652
      |> where(assessment_id: ^submission.assessment_id)
6✔
2653
      |> select([q], count(q.id))
6✔
2654
      |> Repo.one()
2655

2656
    graded_count =
6✔
2657
      Answer
2658
      |> where([a], submission_id: ^submission_id)
2659
      |> where([a], not is_nil(a.grader_id))
2660
      |> select([a], count(a.id))
6✔
2661
      |> Repo.one()
2662

2663
    question_count == graded_count
6✔
2664
  end
2665

2666
  def is_fully_autograded?(submission_id) do
2667
    submission =
59✔
2668
      Submission
2669
      |> Repo.get_by(id: submission_id)
2670

2671
    question_count =
59✔
2672
      Question
2673
      |> where(assessment_id: ^submission.assessment_id)
59✔
2674
      |> select([q], count(q.id))
59✔
2675
      |> Repo.one()
2676

2677
    graded_count =
59✔
2678
      Answer
2679
      |> where([a], submission_id: ^submission_id)
2680
      |> where([a], a.autograding_status == :success)
2681
      |> select([a], count(a.id))
59✔
2682
      |> Repo.one()
2683

2684
    question_count == graded_count
59✔
2685
  end
2686

2687
  @spec update_grading_info(
2688
          %{submission_id: integer() | String.t(), question_id: integer() | String.t()},
2689
          %{},
2690
          CourseRegistration.t()
2691
        ) ::
2692
          {:ok, nil}
2693
          | {:error, {:forbidden | :bad_request | :internal_server_error, String.t()}}
2694
  def update_grading_info(
2695
        %{submission_id: submission_id, question_id: question_id},
2696
        attrs,
2697
        cr = %CourseRegistration{id: grader_id}
2698
      )
2699
      when is_ecto_id(submission_id) and is_ecto_id(question_id) do
2700
    attrs = Map.put(attrs, "grader_id", grader_id)
8✔
2701

2702
    answer_query =
8✔
2703
      Answer
2704
      |> where(submission_id: ^submission_id)
2705
      |> where(question_id: ^question_id)
8✔
2706

2707
    answer_query =
8✔
2708
      answer_query
2709
      |> join(:inner, [a], s in assoc(a, :submission))
2710
      |> preload([_, s], submission: s)
8✔
2711

2712
    answer = Repo.one(answer_query)
8✔
2713

2714
    is_own_submission = grader_id == answer.submission.student_id
8✔
2715

2716
    submission =
8✔
2717
      Submission
2718
      |> join(:inner, [s], a in assoc(s, :assessment))
2719
      |> preload([_, a], assessment: {a, :config})
8✔
2720
      |> Repo.get(submission_id)
2721

2722
    is_grading_auto_published = submission.assessment.config.is_grading_auto_published
8✔
2723

2724
    with {:answer_found?, true} <- {:answer_found?, is_map(answer)},
8✔
2725
         {:status, true} <-
8✔
2726
           {:status, answer.submission.status == :submitted or is_own_submission},
8✔
2727
         {:valid, changeset = %Ecto.Changeset{valid?: true}} <-
7✔
2728
           {:valid, Answer.grading_changeset(answer, attrs)},
2729
         {:ok, _} <- Repo.update(changeset) do
6✔
2730
      update_xp_bonus(submission)
6✔
2731

2732
      if is_grading_auto_published and is_fully_graded?(submission_id) do
6✔
2733
        publish_grading(submission_id, cr)
×
2734
      end
2735

2736
      {:ok, nil}
2737
    else
2738
      {:answer_found?, false} ->
×
2739
        {:error, {:bad_request, "Answer not found or user not permitted to grade."}}
2740

2741
      {:valid, changeset} ->
1✔
2742
        {:error, {:bad_request, full_error_messages(changeset)}}
2743

2744
      {:status, _} ->
1✔
2745
        {:error, {:method_not_allowed, "Submission is not submitted yet."}}
2746

2747
      {:error, _} ->
×
2748
        {:error, {:internal_server_error, "Please try again later."}}
2749
    end
2750
  end
2751

2752
  def update_grading_info(
1✔
2753
        _,
2754
        _,
2755
        _
2756
      ) do
2757
    {:error, {:forbidden, "User is not permitted to grade."}}
2758
  end
2759

2760
  @spec force_regrade_submission(integer() | String.t(), CourseRegistration.t()) ::
2761
          {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}}
2762
  def force_regrade_submission(
2763
        submission_id,
2764
        _requesting_user = %CourseRegistration{id: grader_id}
2765
      )
2766
      when is_ecto_id(submission_id) do
2767
    with {:get, sub} when not is_nil(sub) <- {:get, Repo.get(Submission, submission_id)},
2✔
2768
         {:status, true} <- {:status, sub.student_id == grader_id or sub.status == :submitted} do
1✔
2769
      GradingJob.force_grade_individual_submission(sub, true)
1✔
2770
      {:ok, nil}
2771
    else
2772
      {:get, nil} ->
1✔
2773
        {:error, {:not_found, "Submission not found"}}
2774

2775
      {:status, false} ->
×
2776
        {:error, {:bad_request, "Submission not submitted yet"}}
2777
    end
2778
  end
2779

2780
  def force_regrade_submission(_, _) do
×
2781
    {:error, {:forbidden, "User is not permitted to grade."}}
2782
  end
2783

2784
  @spec force_regrade_answer(
2785
          integer() | String.t(),
2786
          integer() | String.t(),
2787
          CourseRegistration.t()
2788
        ) ::
2789
          {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}}
2790
  def force_regrade_answer(
2791
        submission_id,
2792
        question_id,
2793
        _requesting_user = %CourseRegistration{id: grader_id}
2794
      )
2795
      when is_ecto_id(submission_id) and is_ecto_id(question_id) do
2796
    answer =
2✔
2797
      Answer
2798
      |> where(submission_id: ^submission_id, question_id: ^question_id)
2799
      |> preload([:question, :submission])
2✔
2800
      |> Repo.one()
2801

2802
    with {:get, answer} when not is_nil(answer) <- {:get, answer},
2✔
2803
         {:status, true} <-
1✔
2804
           {:status,
2805
            answer.submission.student_id == grader_id or answer.submission.status == :submitted} do
1✔
2806
      GradingJob.grade_answer(answer, answer.question, true)
1✔
2807
      {:ok, nil}
2808
    else
2809
      {:get, nil} ->
1✔
2810
        {:error, {:not_found, "Answer not found"}}
2811

2812
      {:status, false} ->
×
2813
        {:error, {:bad_request, "Submission not submitted yet"}}
2814
    end
2815
  end
2816

2817
  def force_regrade_answer(_, _, _) do
×
2818
    {:error, {:forbidden, "User is not permitted to grade."}}
2819
  end
2820

2821
  defp find_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do
2822
    {:ok, team} = find_team(assessment.id, cr.id)
68✔
2823

2824
    submission =
68✔
2825
      case team do
2826
        %Team{} ->
2827
          Submission
2828
          |> where(team_id: ^team.id)
2✔
2829
          |> where(assessment_id: ^assessment.id)
2✔
2830
          |> Repo.one()
2✔
2831

2832
        nil ->
2833
          Submission
2834
          |> where(student_id: ^cr.id)
66✔
2835
          |> where(assessment_id: ^assessment.id)
66✔
2836
          |> Repo.one()
66✔
2837
      end
2838

2839
    if submission do
68✔
2840
      {:ok, submission}
2841
    else
2842
      {:error, nil}
2843
    end
2844
  end
2845

2846
  # Checks if an assessment is open and published.
2847
  @spec is_open?(Assessment.t()) :: boolean()
2848
  def is_open?(%Assessment{open_at: open_at, close_at: close_at, is_published: is_published}) do
2849
    Timex.between?(Timex.now(), open_at, close_at, inclusive: :start) and is_published
390✔
2850
  end
2851

2852
  @spec get_group_grading_summary(integer()) ::
2853
          {:ok, [String.t(), ...], []}
2854
  def get_group_grading_summary(course_id) do
2855
    subs =
1✔
2856
      Answer
2857
      |> join(:left, [ans], s in Submission, on: s.id == ans.submission_id)
1✔
2858
      |> join(:left, [ans, s], st in CourseRegistration, on: s.student_id == st.id)
1✔
2859
      |> join(:left, [ans, s, st], a in Assessment, on: a.id == s.assessment_id)
1✔
2860
      |> join(:inner, [ans, s, st, a], ac in AssessmentConfig, on: ac.id == a.config_id)
2861
      |> where(
2862
        [ans, s, st, a, ac],
2863
        not is_nil(st.group_id) and s.status == ^:submitted and
2864
          ac.show_grading_summary and a.course_id == ^course_id
2865
      )
2866
      |> group_by([ans, s, st, a, ac], s.id)
2867
      |> select([ans, s, st, a, ac], %{
1✔
2868
        group_id: max(st.group_id),
2869
        config_id: max(ac.id),
2870
        config_type: max(ac.type),
2871
        num_submitted: count(),
2872
        num_ungraded: filter(count(), is_nil(ans.grader_id))
2873
      })
2874

2875
    raw_data =
1✔
2876
      subs
2877
      |> subquery()
2878
      |> join(:left, [t], g in Group, on: t.group_id == g.id)
1✔
2879
      |> join(:left, [t, g], l in CourseRegistration, on: l.id == g.leader_id)
1✔
2880
      |> join(:left, [t, g, l], lu in User, on: lu.id == l.user_id)
2881
      |> group_by([t, g, l, lu], [t.group_id, t.config_id, t.config_type, g.name, lu.name])
2882
      |> select([t, g, l, lu], %{
1✔
2883
        group_name: g.name,
2884
        leader_name: lu.name,
2885
        config_id: t.config_id,
2886
        config_type: t.config_type,
2887
        ungraded: filter(count(), t.num_ungraded > 0),
2888
        submitted: count()
2889
      })
2890
      |> Repo.all()
2891

2892
    showing_configs =
1✔
2893
      AssessmentConfig
2894
      |> where([ac], ac.course_id == ^course_id and ac.show_grading_summary)
2895
      |> order_by(:order)
2896
      |> group_by([ac], ac.id)
2897
      |> select([ac], %{
1✔
2898
        id: ac.id,
2899
        type: ac.type
2900
      })
2901
      |> Repo.all()
2902

2903
    data_by_groups =
1✔
2904
      raw_data
2905
      |> Enum.reduce(%{}, fn raw, acc ->
2906
        if Map.has_key?(acc, raw.group_name) do
2✔
2907
          acc
2908
          |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded)
×
2909
          |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted)
×
2910
        else
2911
          acc
2912
          |> put_in([raw.group_name], %{})
2✔
2913
          |> put_in([raw.group_name, "groupName"], raw.group_name)
2✔
2914
          |> put_in([raw.group_name, "leaderName"], raw.leader_name)
2✔
2915
          |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded)
2✔
2916
          |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted)
2✔
2917
        end
2918
      end)
2919

2920
    headings =
1✔
2921
      showing_configs
2922
      |> Enum.reduce([], fn config, acc ->
2923
        acc ++ ["submitted" <> config.type, "ungraded" <> config.type]
2✔
2924
      end)
2925

2926
    default_row_data =
1✔
2927
      headings
2928
      |> Enum.reduce(%{}, fn heading, acc ->
2929
        put_in(acc, [heading], 0)
4✔
2930
      end)
2931

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

2935
    {:ok, cols, rows}
1✔
2936
  end
2937

2938
  defp create_empty_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do
2939
    {:ok, team} = find_team(assessment.id, cr.id)
17✔
2940

2941
    case team do
17✔
2942
      %Team{} ->
2943
        %Submission{}
2944
        |> Submission.changeset(%{team: team, assessment: assessment})
2945
        |> Repo.insert()
2946
        |> case do
1✔
2947
          {:ok, submission} -> {:ok, submission}
1✔
2948
        end
2949

2950
      nil ->
2951
        %Submission{}
2952
        |> Submission.changeset(%{student: cr, assessment: assessment})
2953
        |> Repo.insert()
2954
        |> case do
16✔
2955
          {:ok, submission} -> {:ok, submission}
16✔
2956
        end
2957
    end
2958
  end
2959

2960
  defp find_or_create_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do
2961
    case find_submission(cr, assessment) do
58✔
2962
      {:ok, submission} -> {:ok, submission}
41✔
2963
      {:error, _} -> create_empty_submission(cr, assessment)
17✔
2964
    end
2965
  end
2966

2967
  defp insert_or_update_answer(
2968
         submission = %Submission{},
2969
         question = %Question{},
2970
         raw_answer,
2971
         course_reg_id
2972
       ) do
2973
    answer_content = build_answer_content(raw_answer, question.type)
52✔
2974

2975
    if question.type == :voting do
52✔
2976
      insert_or_update_voting_answer(submission.id, course_reg_id, question.id, answer_content)
15✔
2977
    else
2978
      answer_changeset =
37✔
2979
        %Answer{}
2980
        |> Answer.changeset(%{
2981
          answer: answer_content,
2982
          question_id: question.id,
37✔
2983
          submission_id: submission.id,
37✔
2984
          type: question.type,
37✔
2985
          last_modified_at: Timex.now()
2986
        })
2987

2988
      Repo.insert(
37✔
2989
        answer_changeset,
2990
        on_conflict: [
2991
          set: [answer: get_change(answer_changeset, :answer), last_modified_at: Timex.now()]
2992
        ],
2993
        conflict_target: [:submission_id, :question_id]
2994
      )
2995
    end
2996
  end
2997

2998
  def has_last_modified_answer?(
2999
        question = %Question{},
3000
        cr = %CourseRegistration{id: _cr_id},
3001
        last_modified_at,
3002
        force_submit
3003
      ) do
3004
    with {:ok, submission} <- find_or_create_submission(cr, question.assessment),
2✔
3005
         {:status, true} <- {:status, force_submit or submission.status != :submitted},
2✔
3006
         {:ok, is_modified} <- answer_last_modified?(submission, question, last_modified_at) do
2✔
3007
      {:ok, is_modified}
3008
    else
3009
      {:status, _} ->
×
3010
        {:error, {:forbidden, "Assessment submission already finalised"}}
3011
    end
3012
  end
3013

3014
  defp answer_last_modified?(
3015
         submission = %Submission{},
3016
         question = %Question{},
3017
         last_modified_at
3018
       ) do
3019
    case Repo.get_by(Answer, submission_id: submission.id, question_id: question.id) do
2✔
3020
      %Answer{last_modified_at: existing_last_modified_at} ->
3021
        existing_iso8601 = DateTime.to_iso8601(existing_last_modified_at)
1✔
3022

3023
        if existing_iso8601 == last_modified_at do
1✔
3024
          {:ok, false}
3025
        else
3026
          {:ok, true}
3027
        end
3028

3029
      nil ->
1✔
3030
        {:ok, false}
3031
    end
3032
  end
3033

3034
  def insert_or_update_voting_answer(submission_id, course_reg_id, question_id, answer_content) do
3035
    set_score_to_nil =
15✔
3036
      SubmissionVotes
3037
      |> where(voter_id: ^course_reg_id, question_id: ^question_id)
15✔
3038

3039
    voting_multi =
15✔
3040
      Multi.new()
3041
      |> Multi.update_all(:set_score_to_nil, set_score_to_nil, set: [score: nil])
3042

3043
    answer_content
3044
    |> Enum.with_index(1)
3045
    |> Enum.reduce(voting_multi, fn {entry, index}, multi ->
3046
      multi
3047
      |> Multi.run("update#{index}", fn _repo, _ ->
15✔
3048
        SubmissionVotes
3049
        |> Repo.get_by(
3050
          voter_id: course_reg_id,
3051
          submission_id: entry.submission_id
15✔
3052
        )
3053
        |> SubmissionVotes.changeset(%{score: entry.score})
15✔
3054
        |> Repo.insert_or_update()
15✔
3055
      end)
3056
    end)
3057
    |> Multi.run("insert into answer table", fn _repo, _ ->
3058
      Answer
3059
      |> Repo.get_by(submission_id: submission_id, question_id: question_id)
3060
      |> case do
12✔
3061
        nil ->
3062
          Repo.insert(%Answer{
9✔
3063
            answer: %{completed: true},
3064
            submission_id: submission_id,
3065
            question_id: question_id,
3066
            type: :voting
3067
          })
3068

3069
        _ ->
3✔
3070
          {:ok, nil}
3071
      end
3072
    end)
3073
    |> Repo.transaction()
3074
    |> case do
15✔
3075
      {:ok, _result} -> {:ok, nil}
12✔
3076
      {:error, _name, _changeset, _error} -> {:error, :invalid_vote}
3✔
3077
    end
3078
  end
3079

3080
  defp build_answer_content(raw_answer, question_type) do
3081
    case question_type do
52✔
3082
      :mcq ->
3083
        %{choice_id: raw_answer}
20✔
3084

3085
      :programming ->
3086
        %{code: raw_answer}
17✔
3087

3088
      :voting ->
3089
        raw_answer
3090
        |> Enum.map(fn ans ->
15✔
3091
          for {key, value} <- ans, into: %{}, do: {String.to_existing_atom(key), value}
15✔
3092
        end)
3093
    end
3094
  end
3095
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