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

source-academy / backend / 415c28e8ee8b88e6acc822aca1500269b5f844ad

06 Aug 2025 03:59PM UTC coverage: 91.52% (-0.9%) from 92.467%
415c28e8ee8b88e6acc822aca1500269b5f844ad

push

github

web-flow
Leaderboard (#1238)

* added 'enable_leaderboard' columns in courses table

* Leaderboard create course config, leaderboard page routing, leaderboard sql query for all users in course

* added 'top_leaderboard_display' columns in courses table

* added 'all_user_total_xp' function for leaderboard display

* add top leaderboard display options to course settings (select how many to display in leaderboard)

* added contest scores fetching and contest score calculation

* Refactor query execution in assessments module for improved readability

* added functions to fetch contest scoring and voting

* changes to default values

* updated tests

* Fixed xp fetching for all users

* Add top contest leaderboard display configuration and update related tests.
Updated leaderboard fetching and exporting for assessment workspace leaderboard.
Added Leaderboard Dropdown contests fetching.

* Added automatic XP assignment for winning contest entries

* Implement XP assignment for winning contest entries based on contest voting XML and added dispatch endpoint for XP customisation

* Add default value for XP values and improve XP assignment logic for contest entries

* No tiebreak for contest scoring

* Refactor contest scoring endpoints for authentication errors

* Enhance leaderboard update logic and improve error handling for voting questions

* Refactor XP assignment logic for voting questions and set default XP values.
Refactor Score calculation logic to reset to 0 before calculating.

* Temporary Assessment Workspace leaderboard fix for testing

* Fixed tests for assessments (default XP to award for contests)

* Refactor contest fetching logic to filter by voting question contest numbers

* Refactor leaderboard query logic to use RANK() and improve code readability.
Uncommented leaderboard portions after finalising testing

* temporary fix for STePS

* Add ranking to assessment workspace leaderboard queries and update view helpers to include rank

* Post-STePS f... (continued)

117 of 156 new or added lines in 5 files covered. (75.0%)

7 existing lines in 2 files now uncovered.

3216 of 3514 relevant lines covered (91.52%)

7703.7 hits per line

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

92.71
/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, offset \\ nil, limit \\ nil) do
150
    # get all users even if they have 0 xp
151
    base_user_query =
56✔
152
      from(
56✔
153
        cr in CourseRegistration,
154
        join: u in User,
155
        on: cr.user_id == u.id,
156
        where: cr.course_id == ^course_id,
157
        select: %{
158
          user_id: u.id,
159
          name: u.name,
160
          username: u.username
161
        }
162
      )
163

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

190
    submissions_xp_query =
56✔
191
      from(
56✔
192
        sub_xp in subquery(
193
          from(cr in CourseRegistration,
56✔
194
            join: u in User,
195
            on: cr.user_id == u.id,
196
            full_join: tm in TeamMember,
197
            on: cr.id == tm.student_id,
198
            join: s in Submission,
199
            on: tm.team_id == s.team_id or s.student_id == cr.id,
200
            join: a in Answer,
201
            on: s.id == a.submission_id,
202
            where: s.is_grading_published == true and cr.course_id == ^course_id,
203
            group_by: [cr.id, u.id, u.name, u.username, s.id, a.xp, a.xp_adjustment],
204
            select: %{
205
              user_id: u.id,
206
              submission_xp: a.xp + a.xp_adjustment + max(s.xp_bonus)
207
            }
208
          )
209
        ),
210
        group_by: sub_xp.user_id,
211
        select: %{
212
          user_id: sub_xp.user_id,
213
          submission_xp: sum(sub_xp.submission_xp)
214
        }
215
      )
216

217
    total_xp_query =
56✔
218
      from(bu in subquery(base_user_query),
56✔
219
        left_join: ax in subquery(achievements_xp_query),
220
        on: bu.user_id == ax.user_id,
221
        left_join: sx in subquery(submissions_xp_query),
222
        on: bu.user_id == sx.user_id,
223
        select: %{
224
          user_id: bu.user_id,
225
          name: bu.name,
226
          username: bu.username,
227
          total_xp:
228
            fragment(
229
              "COALESCE(?, 0) + COALESCE(?, 0)",
230
              ax.achievements_xp,
231
              sx.submission_xp
232
            )
233
        },
234
        order_by: [desc: fragment("total_xp")]
235
      )
236

237
    # add rank index
238
    ranked_xp_query =
56✔
239
      from(t in subquery(total_xp_query),
56✔
240
        select: %{
241
          rank: fragment("RANK() OVER (ORDER BY total_xp DESC)"),
242
          user_id: t.user_id,
243
          name: t.name,
244
          username: t.username,
245
          total_xp: t.total_xp
246
        },
247
        limit: ^limit,
248
        offset: ^offset
249
      )
250

251
    count_query =
56✔
252
      from(t in subquery(total_xp_query),
56✔
253
        select: count(t.user_id)
254
      )
255

256
    {status, {rows, total_count}} =
56✔
257
      Repo.transaction(fn ->
258
        users = Repo.all(ranked_xp_query)
56✔
259
        count = Repo.one(count_query)
56✔
260
        {users, count}
261
      end)
262

263
    %{
56✔
264
      users: rows,
265
      total_count: total_count
266
    }
267
  end
268

269
  defp decimal_to_integer(decimal) do
270
    if Decimal.is_decimal(decimal) do
19✔
271
      Decimal.to_integer(decimal)
17✔
272
    else
273
      0
274
    end
275
  end
276

277
  def user_current_story(cr = %CourseRegistration{}) do
278
    {:ok, %{result: story}} =
2✔
279
      Multi.new()
280
      |> Multi.run(:unattempted, fn _repo, _ ->
2✔
281
        {:ok, get_user_story_by_type(cr, :unattempted)}
282
      end)
283
      |> Multi.run(:result, fn _repo, %{unattempted: unattempted_story} ->
284
        if unattempted_story do
2✔
285
          {:ok, %{play_story?: true, story: unattempted_story}}
286
        else
287
          {:ok, %{play_story?: false, story: get_user_story_by_type(cr, :attempted)}}
288
        end
289
      end)
290
      |> Repo.transaction()
291

292
    story
2✔
293
  end
294

295
  @spec get_user_story_by_type(CourseRegistration.t(), :unattempted | :attempted) ::
296
          String.t() | nil
297
  def get_user_story_by_type(%CourseRegistration{id: cr_id}, type)
298
      when is_atom(type) do
299
    filter_and_sort = fn query ->
2✔
300
      case type do
2✔
301
        :unattempted ->
302
          query
303
          |> where([_, s], is_nil(s.id))
304
          |> order_by([a], asc: a.open_at)
2✔
305

306
        :attempted ->
307
          query |> order_by([a], desc: a.close_at)
×
308
      end
309
    end
310

311
    Assessment
312
    |> where(is_published: true)
313
    |> where([a], not is_nil(a.story))
314
    |> where([a], a.open_at <= from_now(0, "second") and a.close_at >= from_now(0, "second"))
2✔
315
    |> join(:left, [a], s in Submission, on: s.assessment_id == a.id and s.student_id == ^cr_id)
2✔
316
    |> filter_and_sort.()
317
    |> order_by([a], a.config_id)
318
    |> select([a], a.story)
2✔
319
    |> first()
320
    |> Repo.one()
2✔
321
  end
322

323
  def assessment_with_questions_and_answers(
324
        assessment = %Assessment{password: nil},
325
        cr = %CourseRegistration{},
326
        nil
327
      ) do
328
    assessment_with_questions_and_answers(assessment, cr)
87✔
329
  end
330

331
  def assessment_with_questions_and_answers(
332
        assessment = %Assessment{password: nil},
333
        cr = %CourseRegistration{},
334
        _
335
      ) do
336
    assessment_with_questions_and_answers(assessment, cr)
3✔
337
  end
338

339
  def assessment_with_questions_and_answers(
340
        assessment = %Assessment{password: password},
341
        cr = %CourseRegistration{},
342
        given_password
343
      ) do
344
    cond do
11✔
345
      Timex.compare(Timex.now(), assessment.close_at) >= 0 ->
11✔
346
        assessment_with_questions_and_answers(assessment, cr)
1✔
347

348
      match?({:ok, _}, find_submission(cr, assessment)) ->
10✔
349
        assessment_with_questions_and_answers(assessment, cr)
1✔
350

351
      given_password == nil ->
9✔
352
        {:error, {:forbidden, "Missing Password."}}
353

354
      password == given_password ->
6✔
355
        find_or_create_submission(cr, assessment)
3✔
356
        assessment_with_questions_and_answers(assessment, cr)
3✔
357

358
      true ->
3✔
359
        {:error, {:forbidden, "Invalid Password."}}
360
    end
361
  end
362

363
  def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}, password)
364
      when is_ecto_id(id) do
365
    role = cr.role
101✔
366

367
    assessment =
101✔
368
      if role in @open_all_assessment_roles do
369
        Assessment
370
        |> where(id: ^id)
371
        |> preload(:config)
66✔
372
        |> Repo.one()
66✔
373
      else
374
        Assessment
375
        |> where(id: ^id)
376
        |> where(is_published: true)
377
        |> preload(:config)
35✔
378
        |> Repo.one()
35✔
379
      end
380

381
    if assessment do
101✔
382
      assessment_with_questions_and_answers(assessment, cr, password)
100✔
383
    else
384
      {:error, {:bad_request, "Assessment not found"}}
385
    end
386
  end
387

388
  def assessment_with_questions_and_answers(
389
        assessment = %Assessment{id: id},
390
        course_reg = %CourseRegistration{role: role}
391
      ) do
392
    team_id =
99✔
393
      case find_team(id, course_reg.id) do
99✔
394
        {:ok, nil} ->
96✔
395
          -1
396

397
        {:ok, team} ->
398
          team.id
1✔
399

400
        {:error, :team_not_found} ->
2✔
401
          -1
402
      end
403

404
    if Timex.compare(Timex.now(), assessment.open_at) >= 0 or role in @open_all_assessment_roles do
99✔
405
      answer_query =
98✔
406
        Answer
407
        |> join(:inner, [a], s in assoc(a, :submission))
408
        |> where([_, s], s.student_id == ^course_reg.id or s.team_id == ^team_id)
98✔
409

410
      visible_entries =
98✔
411
        Assessment
412
        |> join(:inner, [a], c in assoc(a, :course))
413
        |> where([a, c], a.id == ^id)
414
        |> select([a, c], c.top_contest_leaderboard_display)
98✔
415
        |> Repo.one()
416

417
      questions =
98✔
418
        Question
419
        |> where(assessment_id: ^id)
98✔
420
        |> join(:left, [q], a in subquery(answer_query), on: q.id == a.question_id)
98✔
421
        |> join(:left, [_, a], g in assoc(a, :grader))
98✔
422
        |> join(:left, [_, _, g], u in assoc(g, :user))
423
        |> select([q, a, g, u], {q, a, g, u})
424
        |> order_by(:display_order)
98✔
425
        |> Repo.all()
426
        |> Enum.map(fn
427
          {q, nil, _, _} -> %{q | answer: %Answer{grader: nil}}
540✔
428
          {q, a, nil, _} -> %{q | answer: %Answer{a | grader: nil}}
216✔
429
          {q, a, g, u} -> %{q | answer: %Answer{a | grader: %CourseRegistration{g | user: u}}}
47✔
430
        end)
431
        |> load_contest_voting_entries(course_reg, assessment, visible_entries)
432

433
      is_grading_published =
98✔
434
        Submission
435
        |> where(assessment_id: ^id)
436
        |> where([s], s.student_id == ^course_reg.id or s.team_id == ^team_id)
98✔
437
        |> select([s], s.is_grading_published)
98✔
438
        |> Repo.one()
439

440
      assessment =
98✔
441
        assessment
442
        |> Map.put(:questions, questions)
443
        |> Map.put(:is_grading_published, is_grading_published)
444

445
      {:ok, assessment}
446
    else
447
      {:error, {:forbidden, "Assessment not open"}}
448
    end
449
  end
450

451
  def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}) do
452
    assessment_with_questions_and_answers(id, cr, nil)
92✔
453
  end
454

455
  @doc """
456
  Returns a list of assessments with all fields and an indicator showing whether it has been attempted
457
  by the supplied user
458
  """
459
  def all_assessments(cr = %CourseRegistration{}) do
460
    teams = find_teams(cr.id)
18✔
461
    submission_ids = get_submission_ids(cr.id, teams)
18✔
462

463
    submission_aggregates =
18✔
464
      Submission
465
      |> join(:left, [s], ans in Answer, on: ans.submission_id == s.id)
466
      |> where(
467
        [s],
468
        s.id in subquery(submission_ids)
469
      )
470
      |> group_by([s], s.assessment_id)
471
      |> select([s, ans], %{
18✔
472
        assessment_id: s.assessment_id,
473
        # s.xp_bonus should be the same across the group, but we need an aggregate function here
474
        xp: fragment("? + ? + ?", sum(ans.xp), sum(ans.xp_adjustment), max(s.xp_bonus)),
475
        graded_count: ans.id |> count() |> filter(not is_nil(ans.grader_id))
476
      })
477

478
    submission_status =
18✔
479
      Submission
480
      |> where(
481
        [s],
482
        s.id in subquery(submission_ids)
483
      )
484
      |> select([s], [:assessment_id, :status, :is_grading_published])
18✔
485

486
    assessments =
18✔
487
      cr.course_id
18✔
488
      |> Query.all_assessments_with_aggregates()
489
      |> subquery()
490
      |> join(
18✔
491
        :left,
492
        [a],
493
        sa in subquery(submission_aggregates),
494
        on: a.id == sa.assessment_id
495
      )
496
      |> join(:left, [a, _], s in subquery(submission_status), on: a.id == s.assessment_id)
497
      |> select([a, sa, s], %{
18✔
498
        a
499
        | xp: sa.xp,
500
          graded_count: sa.graded_count,
501
          user_status: s.status,
502
          is_grading_published: s.is_grading_published
503
      })
504
      |> filter_published_assessments(cr)
505
      |> order_by(:open_at)
506
      |> preload(:config)
18✔
507
      |> Repo.all()
508

509
    {:ok, assessments}
510
  end
511

512
  defp get_submission_ids(cr_id, teams) do
513
    from(s in Submission,
35✔
514
      where: s.student_id == ^cr_id or s.team_id in ^Enum.map(teams, & &1.id),
1✔
515
      select: s.id
516
    )
517
  end
518

519
  defp is_voting_assigned(assessment_ids) do
520
    voting_assigned_question_ids =
18✔
521
      SubmissionVotes
522
      |> select([v], v.question_id)
18✔
523
      |> Repo.all()
524

525
    # Map of assessment_id to boolean
526
    voting_assigned_assessment_ids =
18✔
527
      Question
528
      |> where(type: :voting)
529
      |> where([q], q.id in ^voting_assigned_question_ids)
530
      |> where([q], q.assessment_id in ^assessment_ids)
531
      |> select([q], q.assessment_id)
532
      |> distinct(true)
18✔
533
      |> Repo.all()
534

535
    Enum.reduce(assessment_ids, %{}, fn id, acc ->
18✔
536
      Map.put(acc, id, Enum.member?(voting_assigned_assessment_ids, id))
92✔
537
    end)
538
  end
539

540
  @doc """
541
  A helper function which removes grading information from all assessments
542
  if it's grading is not published.
543
  """
544
  def format_all_assessments(assessments) do
545
    is_voting_assigned_map =
18✔
546
      assessments
547
      |> Enum.map(& &1.id)
92✔
548
      |> is_voting_assigned()
549

550
    Enum.map(assessments, fn a ->
18✔
551
      a = Map.put(a, :is_voting_published, Map.get(is_voting_assigned_map, a.id, false))
92✔
552

553
      if a.is_grading_published do
92✔
554
        a
8✔
555
      else
556
        a
557
        |> Map.put(:xp, 0)
558
        |> Map.put(:graded_count, 0)
84✔
559
      end
560
    end)
561
  end
562

563
  @doc """
564
  A helper function which removes grading information from the assessment
565
  if it's grading is not published.
566
  """
567
  def format_assessment_with_questions_and_answers(assessment) do
568
    if assessment.is_grading_published do
89✔
569
      assessment
6✔
570
    else
571
      %{
572
        assessment
573
        | questions:
83✔
574
            Enum.map(assessment.questions, fn q ->
83✔
575
              %{
576
                q
577
                | answer: %{
730✔
578
                    q.answer
730✔
579
                    | xp: 0,
580
                      xp_adjustment: 0,
581
                      autograding_status: :none,
582
                      autograding_results: [],
583
                      grader: nil,
584
                      grader_id: nil,
585
                      comments: nil
586
                  }
587
              }
588
            end)
589
      }
590
    end
591
  end
592

593
  def filter_published_assessments(assessments, cr) do
594
    role = cr.role
18✔
595

596
    case role do
18✔
597
      :student -> where(assessments, is_published: true)
11✔
598
      _ -> assessments
7✔
599
    end
600
  end
601

602
  def create_assessment(params) do
603
    %Assessment{}
604
    |> Assessment.changeset(params)
605
    |> Repo.insert()
1✔
606
  end
607

608
  @doc """
609
  The main function that inserts or updates assessments from the XML Parser
610
  """
611
  @spec insert_or_update_assessments_and_questions(map(), [map()], boolean()) ::
612
          {:ok, any()}
613
          | {:error, Ecto.Multi.name(), any(), %{optional(Ecto.Multi.name()) => any()}}
614
  def insert_or_update_assessments_and_questions(
615
        assessment_params,
616
        questions_params,
617
        force_update
618
      ) do
619
    assessment_multi =
27✔
620
      Multi.insert_or_update(
621
        Multi.new(),
622
        :assessment,
623
        insert_or_update_assessment_changeset(assessment_params, force_update)
624
      )
625

626
    if force_update and invalid_force_update(assessment_multi, questions_params) do
27✔
627
      {:error, "Question count is different"}
628
    else
629
      questions_params
630
      |> Enum.with_index(1)
631
      |> Enum.reduce(assessment_multi, fn {question_params, index}, multi ->
632
        Multi.run(multi, "question#{index}", fn _repo, %{assessment: %Assessment{id: id}} ->
78✔
633
          question =
53✔
634
            Question
635
            |> where([q], q.display_order == ^index and q.assessment_id == ^id)
53✔
636
            |> Repo.one()
637

638
          # the is_nil(question) check allows for force updating of brand new assessments
639
          if !force_update or is_nil(question) do
53✔
640
            {status, new_question} =
53✔
641
              question_params
642
              |> Map.put(:display_order, index)
643
              |> build_question_changeset_for_assessment_id(id)
644
              |> Repo.insert()
645

646
            if status == :ok and new_question.type == :voting do
53✔
647
              insert_voting(
16✔
648
                assessment_params.course_id,
16✔
649
                question_params.question.contest_number,
16✔
650
                new_question.id
16✔
651
              )
652
            else
653
              {status, new_question}
654
            end
655
          else
656
            params =
×
657
              question_params
658
              |> Map.put_new(:max_xp, 0)
659
              |> Map.put(:display_order, index)
660

661
            if question_params.type != Atom.to_string(question.type) do
×
662
              {:error,
663
               create_invalid_changeset_with_error(
664
                 :question,
665
                 "Question types should remain the same"
666
               )}
667
            else
668
              question
669
              |> Question.changeset(params)
670
              |> Repo.update()
×
671
            end
672
          end
673
        end)
674
      end)
675
      |> Repo.transaction()
26✔
676
    end
677
  end
678

679
  # Function that checks if the force update is invalid. The force update is only invalid
680
  # if the new question count is different from the old question count.
681
  defp invalid_force_update(assessment_multi, questions_params) do
682
    assessment_id =
1✔
683
      (assessment_multi.operations
1✔
684
       |> List.first()
685
       |> elem(1)
686
       |> elem(1)).data.id
1✔
687

688
    if assessment_id do
1✔
689
      open_date = Repo.get(Assessment, assessment_id).open_at
1✔
690
      # check if assessment is already opened
691
      if Timex.compare(open_date, Timex.now()) >= 0 do
1✔
692
        false
693
      else
694
        existing_questions_count =
1✔
695
          Question
696
          |> where([q], q.assessment_id == ^assessment_id)
1✔
697
          |> Repo.all()
698
          |> Enum.count()
699

700
        new_questions_count = Enum.count(questions_params)
1✔
701
        existing_questions_count != new_questions_count
1✔
702
      end
703
    else
704
      false
705
    end
706
  end
707

708
  @spec insert_or_update_assessment_changeset(map(), boolean()) :: Ecto.Changeset.t()
709
  defp insert_or_update_assessment_changeset(
710
         params = %{number: number, course_id: course_id},
711
         force_update
712
       ) do
713
    Assessment
714
    |> where(number: ^number)
715
    |> where(course_id: ^course_id)
27✔
716
    |> Repo.one()
717
    |> case do
27✔
718
      nil ->
719
        Assessment.changeset(%Assessment{}, params)
16✔
720

721
      %{id: assessment_id} = assessment ->
722
        answers_exist =
11✔
723
          Answer
724
          |> join(:inner, [a], q in assoc(a, :question))
11✔
725
          |> join(:inner, [a, q], asst in assoc(q, :assessment))
726
          |> where([a, q, asst], asst.id == ^assessment_id)
11✔
727
          |> Repo.exists?()
728

729
        # Maintain the same open/close date when updating an assessment
730
        params =
11✔
731
          params
732
          |> Map.delete(:open_at)
733
          |> Map.delete(:close_at)
734
          |> Map.delete(:is_published)
735

736
        cond do
11✔
737
          not answers_exist ->
738
            # Delete all realted submission_votes
739
            SubmissionVotes
740
            |> join(:inner, [sv, q], q in assoc(sv, :question))
741
            |> where([sv, q], q.assessment_id == ^assessment_id)
6✔
742
            |> Repo.delete_all()
6✔
743

744
            # Delete all existing questions
745
            Question
746
            |> where(assessment_id: ^assessment_id)
6✔
747
            |> Repo.delete_all()
6✔
748

749
            Assessment.changeset(assessment, params)
6✔
750

751
          force_update ->
5✔
752
            Assessment.changeset(assessment, params)
×
753

754
          true ->
5✔
755
            # if the assessment has submissions, don't edit
756
            create_invalid_changeset_with_error(:assessment, "has submissions")
5✔
757
        end
758
    end
759
  end
760

761
  @spec build_question_changeset_for_assessment_id(map(), number() | String.t()) ::
762
          Ecto.Changeset.t()
763
  defp build_question_changeset_for_assessment_id(params, assessment_id)
764
       when is_ecto_id(assessment_id) do
765
    params_with_assessment_id = Map.put_new(params, :assessment_id, assessment_id)
53✔
766

767
    Question.changeset(%Question{}, params_with_assessment_id)
53✔
768
  end
769

770
  def reassign_voting(assessment_id, is_reassigning_voting) do
771
    if is_reassigning_voting do
9✔
772
      if is_voting_published(assessment_id) do
1✔
773
        Submission
774
        |> where(assessment_id: ^assessment_id)
1✔
775
        |> delete_submission_association(assessment_id)
1✔
776

777
        Question
778
        |> where(assessment_id: ^assessment_id)
1✔
779
        |> Repo.all()
780
        |> Enum.each(fn q ->
1✔
781
          delete_submission_votes_association(q)
1✔
782
        end)
783
      end
784

785
      voting_assigned_question_ids =
1✔
786
        SubmissionVotes
787
        |> select([v], v.question_id)
1✔
788
        |> Repo.all()
789

790
      unpublished_voting_questions =
1✔
791
        Question
792
        |> where(type: :voting)
793
        |> where([q], q.id not in ^voting_assigned_question_ids)
794
        |> where(assessment_id: ^assessment_id)
1✔
795
        |> join(:inner, [q], asst in assoc(q, :assessment))
796
        |> select([q, asst], %{course_id: asst.course_id, question: q.question, id: q.id})
1✔
797
        |> Repo.all()
798

799
      for q <- unpublished_voting_questions do
1✔
800
        insert_voting(q.course_id, q.question["contest_number"], q.id)
1✔
801
      end
802

803
      {:ok, "voting assigned"}
804
    else
805
      {:ok, "no change to voting"}
806
    end
807
  end
808

809
  defp is_voting_published(assessment_id) do
810
    voting_assigned_question_ids =
1✔
811
      SubmissionVotes
812
      |> select([v], v.question_id)
1✔
813

814
    Question
815
    |> where(type: :voting)
816
    |> where(assessment_id: ^assessment_id)
817
    |> where([q], q.id in subquery(voting_assigned_question_ids))
1✔
818
    |> Repo.exists?() || false
1✔
819
  end
820

821
  def update_final_contest_entries do
822
    # 1435 = 1 day - 5 minutes
823
    if Log.log_execution("update_final_contest_entries", Duration.from_minutes(1435)) do
2✔
824
      Logger.info("Started update of contest entry pools")
2✔
825
      questions = fetch_unassigned_voting_questions()
2✔
826

827
      for q <- questions do
2✔
828
        insert_voting(q.course_id, q.question["contest_number"], q.question_id)
17✔
829
      end
830

831
      Logger.info("Successfully update contest entry pools")
2✔
832
    end
833
  end
834

835
  # fetch voting questions where entries have not been assigned
836
  def fetch_unassigned_voting_questions do
837
    voting_assigned_question_ids =
3✔
838
      SubmissionVotes
839
      |> select([v], v.question_id)
3✔
840
      |> Repo.all()
841

842
    valid_assessments =
3✔
843
      Assessment
844
      |> select([a], %{number: a.number, course_id: a.course_id})
3✔
845
      |> Repo.all()
846

847
    valid_questions =
3✔
848
      Question
849
      |> where(type: :voting)
850
      |> where([q], q.id not in ^voting_assigned_question_ids)
3✔
851
      |> join(:inner, [q], asst in assoc(q, :assessment))
852
      |> select([q, asst], %{course_id: asst.course_id, question: q.question, question_id: q.id})
3✔
853
      |> Repo.all()
854

855
    # fetch only voting where there is a corresponding contest
856
    Enum.filter(valid_questions, fn q ->
3✔
857
      Enum.any?(
21✔
858
        valid_assessments,
859
        fn a -> a.number == q.question["contest_number"] and a.course_id == q.course_id end
316✔
860
      )
861
    end)
862
  end
863

864
  @doc """
865
  Generates and assigns contest entries for users with given usernames.
866
  """
867
  def insert_voting(
868
        course_id,
869
        contest_number,
870
        question_id
871
      ) do
872
    contest_assessment = Repo.get_by(Assessment, number: contest_number, course_id: course_id)
41✔
873

874
    if is_nil(contest_assessment) do
41✔
875
      changeset = change(%Assessment{}, %{number: ""})
2✔
876

877
      error_changeset =
2✔
878
        Ecto.Changeset.add_error(
879
          changeset,
880
          :number,
881
          "invalid contest number"
882
        )
883

884
      {:error, error_changeset}
885
    else
886
      if Timex.compare(contest_assessment.close_at, Timex.now()) < 0 do
39✔
887
        compile_entries(course_id, contest_assessment, question_id)
6✔
888
      else
889
        # contest has not closed, do nothing
890
        {:ok, nil}
891
      end
892
    end
893
  end
894

895
  def compile_entries(
896
        course_id,
897
        contest_assessment,
898
        question_id
899
      ) do
900
    # Returns contest submission ids with answers that contain "return"
901
    contest_submission_ids =
6✔
902
      Submission
903
      |> join(:inner, [s], ans in assoc(s, :answers))
6✔
904
      |> join(:inner, [s, ans], cr in assoc(s, :student))
905
      |> where([s, ans, cr], cr.role == "student")
906
      |> where([s, _], s.assessment_id == ^contest_assessment.id and s.status == "submitted")
6✔
907
      |> where(
908
        [_, ans, cr],
909
        fragment(
910
          "?->>'code' like ?",
911
          ans.answer,
912
          "%return%"
913
        )
914
      )
915
      |> select([s, _ans], {s.student_id, s.id})
6✔
916
      |> Repo.all()
917
      |> Enum.into(%{})
918

919
    contest_submission_ids_length = Enum.count(contest_submission_ids)
6✔
920

921
    voter_ids =
6✔
922
      CourseRegistration
923
      |> where(role: "student", course_id: ^course_id)
924
      |> select([cr], cr.id)
6✔
925
      |> Repo.all()
926

927
    votes_per_user = min(contest_submission_ids_length, 10)
6✔
928

929
    votes_per_submission =
6✔
930
      if Enum.empty?(contest_submission_ids) do
×
931
        0
932
      else
933
        trunc(Float.ceil(votes_per_user * length(voter_ids) / contest_submission_ids_length))
6✔
934
      end
935

936
    submission_id_list =
6✔
937
      contest_submission_ids
938
      |> Enum.map(fn {_, s_id} -> s_id end)
37✔
939
      |> Enum.shuffle()
940
      |> List.duplicate(votes_per_submission)
941
      |> List.flatten()
942

943
    {_submission_map, submission_votes_changesets} =
6✔
944
      voter_ids
945
      |> Enum.reduce({submission_id_list, []}, fn voter_id, acc ->
254✔
946
        {submission_list, submission_votes} = acc
46✔
947

948
        user_contest_submission_id = Map.get(contest_submission_ids, voter_id)
46✔
949

950
        {votes, rest} =
46✔
951
          submission_list
952
          |> Enum.reduce_while({MapSet.new(), submission_list}, fn s_id, acc ->
953
            {user_votes, submissions} = acc
376✔
954

955
            max_votes =
376✔
956
              if votes_per_user == contest_submission_ids_length and
×
957
                   not is_nil(user_contest_submission_id) do
376✔
958
                # no. of submssions is less than 10. Unable to find
959
                votes_per_user - 1
325✔
960
              else
961
                votes_per_user
51✔
962
              end
963

964
            if MapSet.size(user_votes) < max_votes do
376✔
965
              if s_id != user_contest_submission_id and not MapSet.member?(user_votes, s_id) do
330✔
966
                new_user_votes = MapSet.put(user_votes, s_id)
254✔
967
                new_submissions = List.delete(submissions, s_id)
254✔
968
                {:cont, {new_user_votes, new_submissions}}
969
              else
970
                {:cont, {user_votes, submissions}}
971
              end
972
            else
973
              {:halt, acc}
974
            end
975
          end)
976

977
        votes = MapSet.to_list(votes)
46✔
978

979
        new_submission_votes =
46✔
980
          votes
981
          |> Enum.map(fn s_id ->
982
            %SubmissionVotes{
254✔
983
              voter_id: voter_id,
984
              submission_id: s_id,
985
              question_id: question_id
986
            }
987
          end)
988
          |> Enum.concat(submission_votes)
989

990
        {rest, new_submission_votes}
991
      end)
992

993
    submission_votes_changesets
994
    |> Enum.with_index()
995
    |> Enum.reduce(Multi.new(), fn {changeset, index}, multi ->
996
      Multi.insert(multi, Integer.to_string(index), changeset)
254✔
997
    end)
998
    |> Repo.transaction()
6✔
999
  end
1000

1001
  def update_assessment(id, params) when is_ecto_id(id) do
1002
    simple_update(
10✔
1003
      Assessment,
1004
      id,
1005
      using: &Assessment.changeset/2,
1006
      params: params
1007
    )
1008
  end
1009

1010
  def update_question(id, params) when is_ecto_id(id) do
1011
    simple_update(
1✔
1012
      Question,
1013
      id,
1014
      using: &Question.changeset/2,
1015
      params: params
1016
    )
1017
  end
1018

1019
  def publish_assessment(id) when is_ecto_id(id) do
1020
    update_assessment(id, %{is_published: true})
1✔
1021
  end
1022

1023
  def create_question_for_assessment(params, assessment_id) when is_ecto_id(assessment_id) do
1024
    assessment =
5✔
1025
      Assessment
1026
      |> where(id: ^assessment_id)
5✔
1027
      |> join(:left, [a], q in assoc(a, :questions))
1028
      |> preload([_, q], questions: q)
5✔
1029
      |> Repo.one()
1030

1031
    if assessment do
5✔
1032
      params_with_assessment_id = Map.put_new(params, :assessment_id, assessment.id)
5✔
1033

1034
      %Question{}
1035
      |> Question.changeset(params_with_assessment_id)
1036
      |> put_display_order(assessment.questions)
5✔
1037
      |> Repo.insert()
5✔
1038
    else
1039
      {:error, "Assessment not found"}
1040
    end
1041
  end
1042

1043
  def get_question(id) when is_ecto_id(id) do
1044
    Question
1045
    |> where(id: ^id)
60✔
1046
    |> join(:inner, [q], assessment in assoc(q, :assessment))
1047
    |> preload([_, a], assessment: a)
60✔
1048
    |> Repo.one()
60✔
1049
  end
1050

1051
  def delete_question(id) when is_ecto_id(id) do
1052
    question = Repo.get(Question, id)
1✔
1053
    Repo.delete(question)
1✔
1054
  end
1055

1056
  @doc """
1057
  Public internal api to submit new answers for a question. Possible return values are:
1058
  `{:ok, nil}` -> success
1059
  `{:error, error}` -> failed. `error` is in the format of `{http_response_code, error message}`
1060

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

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

1077
      {:ok, nil}
1078
    else
1079
      {:status, _} ->
1✔
1080
        {:error, {:forbidden, "Assessment submission already finalised"}}
1081

1082
      {:error, :race_condition} ->
×
1083
        {:error, {:internal_server_error, "Please try again later."}}
1084

1085
      {:error, :team_not_found} ->
1✔
1086
        {:error, {:bad_request, "Your existing Team has been deleted!"}}
1087

1088
      {:error, :invalid_vote} ->
3✔
1089
        {:error, {:bad_request, "Invalid vote! Vote is not saved."}}
1090

1091
      _ ->
6✔
1092
        {:error, {:bad_request, "Missing or invalid parameter(s)"}}
1093
    end
1094
  end
1095

1096
  def is_team_assessment?(assessment_id) when is_ecto_id(assessment_id) do
1097
    max_team_size =
653✔
1098
      Assessment
1099
      |> where(id: ^assessment_id)
1100
      |> select([a], a.max_team_size)
653✔
1101
      |> Repo.one()
1102

1103
    max_team_size > 1
653✔
1104
  end
1105

1106
  defp find_teams(cr_id) when is_ecto_id(cr_id) do
1107
    query =
35✔
1108
      from(t in Team,
35✔
1109
        join: tm in assoc(t, :team_members),
1110
        where: tm.student_id == ^cr_id
1111
      )
1112

1113
    Repo.all(query)
35✔
1114
  end
1115

1116
  defp find_team(assessment_id, cr_id)
1117
       when is_ecto_id(assessment_id) and is_ecto_id(cr_id) do
1118
    query =
602✔
1119
      from(t in Team,
602✔
1120
        where: t.assessment_id == ^assessment_id,
1121
        join: tm in assoc(t, :team_members),
1122
        where: tm.student_id == ^cr_id,
1123
        limit: 1
1124
      )
1125

1126
    if is_team_assessment?(assessment_id) do
602✔
1127
      case Repo.one(query) do
10✔
1128
        nil -> {:error, :team_not_found}
3✔
1129
        team -> {:ok, team}
7✔
1130
      end
1131
    else
1132
      # team is nil for individual assessments
1133
      {:ok, nil}
1134
    end
1135
  end
1136

1137
  def get_submission(assessment_id, %CourseRegistration{id: cr_id})
1138
      when is_ecto_id(assessment_id) do
1139
    {:ok, team} = find_team(assessment_id, cr_id)
364✔
1140

1141
    case team do
364✔
1142
      %Team{} ->
1143
        Submission
1144
        |> where(assessment_id: ^assessment_id)
1145
        |> where(team_id: ^team.id)
1✔
1146
        |> join(:inner, [s], a in assoc(s, :assessment))
1147
        |> preload([_, a], assessment: a)
1✔
1148
        |> Repo.one()
1✔
1149

1150
      nil ->
1151
        Submission
1152
        |> where(assessment_id: ^assessment_id)
1153
        |> where(student_id: ^cr_id)
363✔
1154
        |> join(:inner, [s], a in assoc(s, :assessment))
1155
        |> preload([_, a], assessment: a)
363✔
1156
        |> Repo.one()
363✔
1157
    end
1158
  end
1159

1160
  def get_submission_by_id(submission_id) when is_ecto_id(submission_id) do
1161
    Submission
1162
    |> where(id: ^submission_id)
1✔
1163
    |> join(:inner, [s], a in assoc(s, :assessment))
1164
    |> preload([_, a], assessment: a)
1✔
1165
    |> Repo.one()
1✔
1166
  end
1167

1168
  def finalise_submission(submission = %Submission{}) do
1169
    with {:status, :attempted} <- {:status, submission.status},
360✔
1170
         {:ok, updated_submission} <- update_submission_status(submission) do
358✔
1171
      # Couple with update_submission_status and update_xp_bonus to ensure notification is sent
1172
      submission = Repo.preload(submission, assessment: [:config])
358✔
1173

1174
      if submission.assessment.config.is_manually_graded do
358✔
1175
        Notifications.write_notification_when_student_submits(submission)
358✔
1176
      end
1177

1178
      # Send email notification to avenger
1179
      %{notification_type: "assessment_submission", submission_id: updated_submission.id}
358✔
1180
      |> Cadet.Workers.NotificationWorker.new()
1181
      |> Oban.insert()
358✔
1182

1183
      # Begin autograding job
1184
      GradingJob.force_grade_individual_submission(updated_submission)
358✔
1185
      update_xp_bonus(updated_submission)
358✔
1186

1187
      {:ok, nil}
1188
    else
1189
      {:status, :attempting} ->
1✔
1190
        {:error, {:bad_request, "Some questions have not been attempted"}}
1191

1192
      {:status, :submitted} ->
1✔
1193
        {:error, {:forbidden, "Assessment has already been submitted"}}
1194

1195
      _ ->
×
1196
        {:error, {:internal_server_error, "Please try again later."}}
1197
    end
1198
  end
1199

1200
  def unsubmit_submission(
1201
        submission_id,
1202
        cr = %CourseRegistration{id: course_reg_id, role: role}
1203
      )
1204
      when is_ecto_id(submission_id) do
1205
    submission =
9✔
1206
      Submission
1207
      |> join(:inner, [s], a in assoc(s, :assessment))
1208
      |> preload([_, a], assessment: a)
9✔
1209
      |> Repo.get(submission_id)
1210

1211
    # allows staff to unsubmit own assessment
1212
    bypass = role in @bypass_closed_roles and submission.student_id == course_reg_id
9✔
1213

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

1249
            {:ok, _} ->
4✔
1250
              {:cont,
1251
               answer
1252
               |> Answer.grading_changeset(%{
1253
                 xp: 0,
1254
                 xp_adjustment: 0,
1255
                 autograding_status: :none,
1256
                 autograding_results: []
1257
               })
1258
               |> Repo.update()}
1259
          end
1260
        end)
1261
      end)
1262
      |> Repo.transaction()
5✔
1263

1264
      case submission.student_id do
5✔
1265
        # Team submission, handle notifications for team members
1266
        nil ->
1267
          team = Repo.get(Team, submission.team_id)
1✔
1268

1269
          query =
1✔
1270
            from(t in Team,
1✔
1271
              join: tm in TeamMember,
1272
              on: t.id == tm.team_id,
1273
              join: cr in CourseRegistration,
1274
              on: tm.student_id == cr.id,
1275
              where: t.id == ^team.id,
1✔
1276
              select: cr.id
1277
            )
1278

1279
          team_members = Repo.all(query)
1✔
1280

1281
          Enum.each(team_members, fn tm_id ->
1✔
1282
            Notifications.handle_unsubmit_notifications(
2✔
1283
              submission.assessment.id,
2✔
1284
              Repo.get(CourseRegistration, tm_id)
1285
            )
1286
          end)
1287

1288
        student_id ->
1289
          Notifications.handle_unsubmit_notifications(
4✔
1290
            submission.assessment.id,
4✔
1291
            Repo.get(CourseRegistration, student_id)
1292
          )
1293
      end
1294

1295
      # Remove grading notifications for submissions
1296
      Notification
1297
      |> where(submission_id: ^submission_id, type: :submitted)
1298
      |> select([n], n.id)
5✔
1299
      |> Repo.all()
1300
      |> Notifications.acknowledge(cr)
5✔
1301

1302
      {:ok, nil}
1303
    else
1304
      {:submission_found?, false} ->
×
1305
        {:error, {:not_found, "Submission not found"}}
1306

1307
      {:is_open?, false} ->
1✔
1308
        {:error, {:forbidden, "Assessment not open"}}
1309

1310
      {:status, :attempting} ->
×
1311
        {:error, {:bad_request, "Some questions have not been attempted"}}
1312

1313
      {:status, :attempted} ->
1✔
1314
        {:error, {:bad_request, "Assessment has not been submitted"}}
1315

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

1319
      {:is_grading_published?, true} ->
1✔
1320
        {:error, {:forbidden, "Grading has not been unpublished"}}
1321

1322
      _ ->
×
1323
        {:error, {:internal_server_error, "Please try again later."}}
1324
    end
1325
  end
1326

1327
  defp can_publish?(submission_id, cr = %CourseRegistration{id: course_reg_id, role: role}) do
1328
    submission =
7✔
1329
      Submission
1330
      |> join(:inner, [s], a in assoc(s, :assessment))
7✔
1331
      |> join(:inner, [_, a], c in assoc(a, :config))
1332
      |> preload([_, a, c], assessment: {a, config: c})
7✔
1333
      |> Repo.get(submission_id)
1334

1335
    # allows staff to unpublish own assessment
1336
    bypass = role in @bypass_closed_roles and submission.student_id == course_reg_id
7✔
1337

1338
    # assumption: if team assessment, all team members are under the same avenger
1339
    effective_student_id =
7✔
1340
      if is_nil(submission.student_id) do
7✔
1341
        Teams.get_first_member(submission.team_id).student_id
×
1342
      else
1343
        submission.student_id
7✔
1344
      end
1345

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

1359
  @doc """
1360
    Unpublishes grading for a submission and send notification to student.
1361
    Requires admin or staff who is group leader of student.
1362

1363
    Only manually graded assessments can be individually unpublished. We can only
1364
    unpublish all submissions for auto-graded assessments.
1365

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

1376
        # assumption: if team assessment, all team members are under the same avenger
1377
        effective_student_id =
2✔
1378
          if is_nil(submission.student_id) do
2✔
1379
            Teams.get_first_member(submission.team_id).student_id
×
1380
          else
1381
            submission.student_id
2✔
1382
          end
1383

1384
        Notifications.handle_unpublish_grades_notifications(
2✔
1385
          submission.assessment.id,
2✔
1386
          Repo.get(CourseRegistration, effective_student_id)
1387
        )
1388

1389
        {:ok, nil}
1390

1391
      {:submission_found?, false} ->
×
1392
        {:error, {:not_found, "Submission not found"}}
1393

1394
      {:allowed_to_publish?, false} ->
1✔
1395
        {:error,
1396
         {:forbidden, "Only Avenger of student or Admin is permitted to unpublish grading"}}
1397

1398
      {:is_manually_graded?, false} ->
×
1399
        {:error,
1400
         {:bad_request, "Only manually graded assessments can be individually unpublished"}}
1401

1402
      _ ->
×
1403
        {:error, {:internal_server_error, "Please try again later."}}
1404
    end
1405
  end
1406

1407
  @doc """
1408
    Publishes grading for a submission and send notification to student.
1409
    Requires admin or staff who is group leader of student and all answers to be graded.
1410

1411
    Only manually graded assessments can be individually published. We can only
1412
    publish all submissions for auto-graded assessments.
1413

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

1424
        update_xp_bonus(submission)
2✔
1425

1426
        Notifications.write_notification_when_published(
2✔
1427
          submission.id,
2✔
1428
          :published_grading
1429
        )
1430

1431
        Notification
1432
        |> where(submission_id: ^submission.id, type: :submitted)
2✔
1433
        |> select([n], n.id)
2✔
1434
        |> Repo.all()
1435
        |> Notifications.acknowledge(cr)
2✔
1436

1437
        {:ok, nil}
1438

1439
      {:submission_found?, false} ->
×
1440
        {:error, {:not_found, "Submission not found"}}
1441

1442
      {:status, :attempting} ->
×
1443
        {:error, {:bad_request, "Some questions have not been attempted"}}
1444

1445
      {:status, :attempted} ->
1✔
1446
        {:error, {:bad_request, "Assessment has not been submitted"}}
1447

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

1451
      {:is_manually_graded?, false} ->
×
1452
        {:error, {:bad_request, "Only manually graded assessments can be individually published"}}
1453

1454
      {:fully_graded?, false} ->
×
1455
        {:error, {:bad_request, "Some answers are not graded"}}
1456

1457
      _ ->
×
1458
        {:error, {:internal_server_error, "Please try again later."}}
1459
    end
1460
  end
1461

1462
  @doc """
1463
    Publishes grading for a submission and send notification to student.
1464
    This function is used by the auto-grading system to publish grading. Bypasses Course Reg checks.
1465

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

1476
    with {:submission_found?, true} <- {:submission_found?, is_map(submission)},
14✔
1477
         {:status, :submitted} <- {:status, submission.status} do
14✔
1478
      submission
1479
      |> Submission.changeset(%{is_grading_published: true})
1480
      |> Repo.update()
14✔
1481

1482
      Notifications.write_notification_when_published(
14✔
1483
        submission.id,
14✔
1484
        :published_grading
1485
      )
1486

1487
      {:ok, nil}
1488
    else
1489
      {:submission_found?, false} ->
×
1490
        {:error, {:not_found, "Submission not found"}}
1491

1492
      {:status, :attempting} ->
×
1493
        {:error, {:bad_request, "Some questions have not been attempted"}}
1494

1495
      {:status, :attempted} ->
×
1496
        {:error, {:bad_request, "Assessment has not been submitted"}}
1497

1498
      _ ->
×
1499
        {:error, {:internal_server_error, "Please try again later."}}
1500
    end
1501
  end
1502

1503
  @doc """
1504
    Publishes grading for all graded submissions for an assessment and sends notifications to students.
1505
    Requires admin.
1506

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

1520
      question_query =
2✔
1521
        Question
1522
        |> group_by([q], q.assessment_id)
2✔
1523
        |> join(:inner, [q], a in Assessment, on: q.assessment_id == a.id)
1524
        |> select([q, a], %{
2✔
1525
          assessment_id: q.assessment_id,
1526
          question_count: count(q.id)
1527
        })
1528

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

1547
      submissions = Repo.all(submission_query)
2✔
1548

1549
      Repo.update_all(submission_query, set: [is_grading_published: true])
2✔
1550

1551
      Enum.each(submissions, fn submission ->
2✔
1552
        Notifications.write_notification_when_published(
2✔
1553
          submission.id,
2✔
1554
          :published_grading
1555
        )
1556
      end)
1557

1558
      {:ok, nil}
1559
    else
1560
      {:error, {:forbidden, "Only Admin is permitted to publish all grades"}}
1561
    end
1562
  end
1563

1564
  @doc """
1565
     Unpublishes grading for all submissions with grades published for an assessment and sends notifications to students.
1566
     Requires admin role.
1567

1568
     Returns `{:ok, nil}` on success, otherwise `{:error, {status, message}}`.
1569
  """
1570

1571
  def unpublish_all(publisher = %CourseRegistration{}, assessment_id) do
1572
    if publisher.role == :admin do
2✔
1573
      submission_query =
2✔
1574
        Submission
1575
        |> join(:inner, [s], cr in CourseRegistration, on: s.student_id == cr.id)
1576
        |> where([s, cr], cr.course_id == ^publisher.course_id)
2✔
1577
        |> where([s, cr], s.is_grading_published == true)
1578
        |> where([s, cr], s.assessment_id == ^assessment_id)
1579
        |> select([s, cr], %{
2✔
1580
          id: s.id,
1581
          student_id: cr.id
1582
        })
1583

1584
      submissions = Repo.all(submission_query)
2✔
1585

1586
      Repo.update_all(submission_query, set: [is_grading_published: false])
2✔
1587

1588
      Enum.each(submissions, fn submission ->
2✔
1589
        Notifications.handle_unpublish_grades_notifications(
2✔
1590
          assessment_id,
1591
          Repo.get(CourseRegistration, submission.student_id)
2✔
1592
        )
1593
      end)
1594

1595
      {:ok, nil}
1596
    else
1597
      {:error, {:forbidden, "Only Admin is permitted to unpublish all grades"}}
1598
    end
1599
  end
1600

1601
  @spec update_submission_status(Submission.t()) ::
1602
          {:ok, Submission.t()} | {:error, Ecto.Changeset.t()}
1603
  defp update_submission_status(submission = %Submission{}) do
1604
    submission
1605
    |> Submission.changeset(%{status: :submitted, submitted_at: Timex.now()})
1606
    |> Repo.update()
358✔
1607
  end
1608

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

1618
      max_bonus_xp = assessment_conifg.early_submission_xp
364✔
1619
      early_hours = assessment_conifg.hours_before_early_xp_decay
364✔
1620

1621
      ans_xp =
364✔
1622
        Answer
1623
        |> where(submission_id: ^submission_id)
1624
        |> order_by(:question_id)
1625
        |> select([a], %{
364✔
1626
          total_xp: a.xp + a.xp_adjustment
1627
        })
1628

1629
      total =
364✔
1630
        ans_xp
1631
        |> subquery
1632
        |> select([a], %{
364✔
1633
          total_xp: coalesce(sum(a.total_xp), 0)
1634
        })
1635
        |> Repo.one()
1636

1637
      cur_time =
364✔
1638
        if submission.submitted_at == nil do
364✔
1639
          Timex.now()
4✔
1640
        else
1641
          submission.submitted_at
360✔
1642
        end
1643

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

1655
            remaining_hours = Enum.max([0, Timex.diff(assessment.close_at, cur_time, :hours)])
202✔
1656
            proportion = if(decaying_hours > 0, do: remaining_hours / decaying_hours, else: 1)
202✔
1657
            bonus_xp = round(max_bonus_xp * proportion)
202✔
1658
            Enum.max([0, bonus_xp])
202✔
1659
          end
1660
        end
1661

1662
      submission
1663
      |> Submission.changeset(%{xp_bonus: xp_bonus})
1664
      |> Repo.update()
364✔
1665
    end
1666
  end
1667

1668
  defp update_submission_status_router(submission = %Submission{}, question = %Question{}) do
1669
    case question.type do
43✔
1670
      :voting -> update_contest_voting_submission_status(submission, question)
12✔
1671
      :mcq -> update_submission_status(submission, question.assessment)
17✔
1672
      :programming -> update_submission_status(submission, question.assessment)
14✔
1673
    end
1674
  end
1675

1676
  defp update_submission_status(submission = %Submission{}, assessment = %Assessment{}) do
1677
    model_assoc_count = fn model, assoc, id ->
31✔
1678
      model
1679
      |> where(id: ^id)
62✔
1680
      |> join(:inner, [m], a in assoc(m, ^assoc))
1681
      |> select([_, a], count(a.id))
62✔
1682
      |> Repo.one()
62✔
1683
    end
1684

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

1702
  defp update_contest_voting_submission_status(submission = %Submission{}, question = %Question{}) do
1703
    has_nil_entries =
12✔
1704
      SubmissionVotes
1705
      |> where(question_id: ^question.id)
12✔
1706
      |> where(voter_id: ^submission.student_id)
12✔
1707
      |> where([sv], is_nil(sv.score))
12✔
1708
      |> Repo.exists?()
1709

1710
    unless has_nil_entries do
12✔
1711
      submission |> Submission.changeset(%{status: :attempted}) |> Repo.update()
12✔
1712
    end
1713
  end
1714

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

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

1741
          leaderboard_results =
267✔
1742
            if is_nil(question_id) do
249✔
1743
              []
1744
            else
1745
              if leaderboard_open?(assessment, q) or role in @open_all_assessment_roles do
18✔
1746
                fetch_top_relative_score_answers(question_id, visible_entries)
15✔
1747
              else
1748
                []
1749
              end
1750
            end
1751

1752
          # populate entries to vote for and leaderboard data into the question
1753
          voting_question =
267✔
1754
            q.question
267✔
1755
            |> Map.put(:contest_entries, submission_votes)
1756
            |> Map.put(
1757
              :contest_leaderboard,
1758
              leaderboard_results
1759
            )
1760
            |> Map.put(
1761
              :popular_leaderboard,
1762
              popular_results
1763
            )
1764

1765
          Map.put(q, :question, voting_question)
267✔
1766
        else
1767
          q
536✔
1768
        end
1769
      end
1770
    )
1771
  end
1772

1773
  defp all_submission_votes_by_question_id_and_voter_id(question_id, voter_id) do
1774
    SubmissionVotes
1775
    |> where([v], v.voter_id == ^voter_id and v.question_id == ^question_id)
267✔
1776
    |> join(:inner, [v], s in assoc(v, :submission))
267✔
1777
    |> join(:inner, [v, s], a in assoc(s, :answers))
1778
    |> select([v, s, a], %{submission_id: v.submission_id, answer: a.answer, score: v.score})
267✔
1779
    |> Repo.all()
267✔
1780
  end
1781

1782
  # Finds the contest_question_id associated with the given voting_question id
1783
  def fetch_associated_contest_question_id(course_id, voting_question) do
1784
    contest_number = voting_question.question["contest_number"]
272✔
1785

1786
    if is_nil(contest_number) do
272✔
1787
      nil
1788
    else
1789
      Assessment
1790
      |> where(number: ^contest_number, course_id: ^course_id)
272✔
1791
      |> join(:inner, [a], q in assoc(a, :questions))
1792
      |> order_by([a, q], q.display_order)
1793
      |> select([a, q], q.id)
272✔
1794
      |> Repo.one()
272✔
1795
    end
1796
  end
1797

1798
  defp leaderboard_open?(assessment, voting_question) do
1799
    Timex.before?(
36✔
1800
      Timex.shift(assessment.close_at, hours: voting_question.question["reveal_hours"]),
36✔
1801
      Timex.now()
1802
    )
1803
  end
1804

1805
  def fetch_contest_voting_assesment_id(assessment_id) do
1806
    contest_number =
2✔
1807
      Assessment
1808
      |> where(id: ^assessment_id)
1809
      |> select([a], a.number)
2✔
1810
      |> Repo.one()
1811

1812
    if is_nil(contest_number) do
2✔
1813
      nil
1814
    else
1815
      Assessment
1816
      |> join(:inner, [a], q in assoc(a, :questions))
1817
      |> where([a, q], q.question["contest_number"] == ^contest_number)
1818
      |> select([a], a.id)
2✔
1819
      |> Repo.one()
2✔
1820
    end
1821
  end
1822

1823
  @doc """
1824
  Fetches all contests for the course id where the voting assessment has been published
1825

1826
  Used for contest leaderboard dropdown fetching
1827
  """
1828
  def fetch_all_contests(course_id) do
NEW
1829
    contest_numbers =
×
1830
      Question
1831
      |> where(type: :voting)
NEW
1832
      |> select([q], q.question["contest_number"])
×
1833
      |> Repo.all()
NEW
1834
      |> Enum.reject(&is_nil/1)
×
1835

NEW
1836
    if contest_numbers == [] do
×
1837
      []
1838
    else
1839
      Assessment
NEW
1840
      |> where([a], a.number in ^contest_numbers and a.course_id == ^course_id)
×
1841
      |> join(:inner, [a], ac in AssessmentConfig, on: a.config_id == ac.id)
1842
      |> where([a, ac], ac.type == "Contests")
NEW
1843
      |> select([a], %{contest_id: a.id, title: a.title, published: a.is_published})
×
NEW
1844
      |> Repo.all()
×
1845
    end
1846
  end
1847

1848
  @doc """
1849
  Fetches top answers for the given question, based on the contest relative_score
1850

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

1879
    final_query =
24✔
1880
      from(r in subquery(subquery),
24✔
1881
        where: r.rank <= ^number_of_answers
1882
      )
1883

1884
    Repo.all(final_query)
24✔
1885
  end
1886

1887
  @doc """
1888
  Fetches top answers for the given question, based on the contest popular_score
1889

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

1918
    final_query =
16✔
1919
      from(r in subquery(subquery),
16✔
1920
        where: r.rank <= ^number_of_answers
1921
      )
1922

1923
    Repo.all(final_query)
16✔
1924
  end
1925

1926
  @doc """
1927
  Computes rolling leaderboard for contest votes that are still open.
1928
  """
1929
  def update_rolling_contest_leaderboards do
1930
    # 115 = 2 hours - 5 minutes is default.
1931
    if Log.log_execution("update_rolling_contest_leaderboards", Duration.from_minutes(115)) do
1✔
1932
      Logger.info("Started update_rolling_contest_leaderboards")
1✔
1933

1934
      voting_questions_to_update = fetch_active_voting_questions()
1✔
1935

1936
      _ =
1✔
1937
        voting_questions_to_update
1938
        |> Enum.map(fn qn -> compute_relative_score(qn.id) end)
1✔
1939

1940
      Logger.info("Successfully update_rolling_contest_leaderboards")
1✔
1941
    end
1942
  end
1943

1944
  def fetch_active_voting_questions do
1945
    Question
1946
    |> join(:left, [q], a in assoc(q, :assessment))
1947
    |> where([q, a], q.type == "voting")
1948
    |> where([q, a], a.is_published)
1949
    |> where([q, a], a.open_at <= ^Timex.now() and a.close_at >= ^Timex.now())
2✔
1950
    |> Repo.all()
2✔
1951
  end
1952

1953
  @doc """
1954
  Computes final leaderboard for contest votes that have closed.
1955
  """
1956
  def update_final_contest_leaderboards do
1957
    # 1435 = 24 hours - 5 minutes
1958
    if Log.log_execution("update_final_contest_leaderboards", Duration.from_minutes(1435)) do
2✔
1959
      Logger.info("Started update_final_contest_leaderboards")
2✔
1960

1961
      voting_questions_to_update = fetch_voting_questions_due_yesterday() || []
2✔
1962

1963
      voting_questions_to_update =
2✔
1964
        if is_nil(voting_questions_to_update), do: [], else: voting_questions_to_update
2✔
1965

1966
      scores =
2✔
1967
        Enum.map(voting_questions_to_update, fn qn ->
1968
          compute_relative_score(qn.id)
1✔
1969
        end)
1970

1971
      if Enum.empty?(voting_questions_to_update) do
2✔
1972
        Logger.warn("No voting questions to update.")
1✔
1973
      else
1974
        # Process each voting question
1975
        Enum.each(voting_questions_to_update, fn qn ->
1✔
1976
          assign_winning_contest_entries_xp(qn.id)
1✔
1977
        end)
1978

1979
        Logger.info("Successfully update_final_contest_leaderboards")
1✔
1980
      end
1981

1982
      scores
2✔
1983
    end
1984
  end
1985

1986
  def fetch_voting_questions_due_yesterday do
1987
    Question
1988
    |> join(:left, [q], a in assoc(q, :assessment))
1989
    |> where([q, a], q.type == "voting")
1990
    |> where([q, a], a.is_published)
1991
    |> where([q, a], a.open_at <= ^Timex.now())
1992
    |> where(
3✔
1993
      [q, a],
1994
      a.close_at < ^Timex.now() and a.close_at >= ^Timex.shift(Timex.now(), days: -1)
1995
    )
1996
    |> Repo.all()
3✔
1997
  end
1998

1999
  @doc """
2000
  Automatically assigns XP to the winning contest entries
2001
  """
2002
  def assign_winning_contest_entries_xp(contest_voting_question_id) do
2003
    voting_questions =
6✔
2004
      Question
2005
      |> where(type: :voting)
2006
      |> where(id: ^contest_voting_question_id)
6✔
2007
      |> Repo.one()
2008

2009
    contest_question_id =
6✔
2010
      SubmissionVotes
2011
      |> where(question_id: ^contest_voting_question_id)
6✔
2012
      |> join(:inner, [sv], ans in Answer, on: sv.submission_id == ans.submission_id)
2013
      |> select([sv, ans], ans.question_id)
2014
      |> limit(1)
6✔
2015
      |> Repo.one()
2016

2017
    if is_nil(contest_question_id) do
6✔
NEW
2018
      Logger.warn("Contest question ID is missing. Terminating.")
×
2019
      :ok
2020
    else
2021
      default_xp_values = %Cadet.Assessments.QuestionTypes.VotingQuestion{} |> Map.get(:xp_values)
6✔
2022
      scores = voting_questions.question["xp_values"] || default_xp_values
6✔
2023

2024
      if scores == [] do
6✔
NEW
2025
        Logger.warn("No XP values provided. Terminating.")
×
2026
        :ok
2027
      else
2028
        Repo.transaction(fn ->
6✔
2029
          submission_ids =
6✔
2030
            Answer
2031
            |> where(question_id: ^contest_question_id)
2032
            |> select([a], a.submission_id)
6✔
2033
            |> Repo.all()
2034

2035
          Submission
2036
          |> where([s], s.id in ^submission_ids)
6✔
2037
          |> Repo.update_all(set: [is_grading_published: true])
6✔
2038

2039
          winning_popular_entries =
6✔
2040
            Answer
2041
            |> where(question_id: ^contest_question_id)
2042
            |> select([a], %{
6✔
2043
              id: a.id,
2044
              rank: fragment("rank() OVER (ORDER BY ? DESC)", a.popular_score)
2045
            })
2046
            |> Repo.all()
2047

2048
          winning_popular_entries
2049
          |> Enum.each(fn %{id: answer_id, rank: rank} ->
6✔
2050
            increment = Enum.at(scores, rank - 1, 0)
30✔
2051
            answer = Repo.get!(Answer, answer_id)
30✔
2052
            Repo.update!(Changeset.change(answer, %{xp_adjustment: increment}))
30✔
2053
          end)
2054

2055
          winning_score_entries =
6✔
2056
            Answer
2057
            |> where(question_id: ^contest_question_id)
2058
            |> select([a], %{
6✔
2059
              id: a.id,
2060
              rank: fragment("rank() OVER (ORDER BY ? DESC)", a.relative_score)
2061
            })
2062
            |> Repo.all()
2063

2064
          winning_score_entries
2065
          |> Enum.each(fn %{id: answer_id, rank: rank} ->
6✔
2066
            increment = Enum.at(scores, rank - 1, 0)
30✔
2067
            answer = Repo.get!(Answer, answer_id)
30✔
2068
            new_value = answer.xp_adjustment + increment
30✔
2069
            Repo.update!(Changeset.change(answer, %{xp_adjustment: new_value}))
30✔
2070
          end)
2071
        end)
2072

2073
        Logger.info("XP assigned to winning contest entries")
6✔
2074
      end
2075
    end
2076
  end
2077

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

2090
    if is_nil(voting_questions) do
3✔
NEW
2091
      IO.puts("Voting question not found, skipping score computation.")
×
2092
      :ok
2093
    else
2094
      course_id =
3✔
2095
        Assessment
2096
        |> where(id: ^voting_questions.assessment_id)
3✔
2097
        |> select([a], a.course_id)
3✔
2098
        |> Repo.one()
2099

2100
      if is_nil(course_id) do
3✔
NEW
2101
        IO.puts("Course ID not found, skipping score computation.")
×
2102
        :ok
2103
      else
2104
        contest_question_id = fetch_associated_contest_question_id(course_id, voting_questions)
3✔
2105

2106
        if !is_nil(contest_question_id) do
3✔
2107
          # reset all scores to 0 first
2108
          Answer
2109
          |> where([ans], ans.question_id == ^contest_question_id)
NEW
2110
          |> update([ans], set: [popular_score: 0.0, relative_score: 0.0])
×
NEW
2111
          |> Repo.update_all([])
×
2112
        end
2113
      end
2114
    end
2115

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

2131
    token_divider =
3✔
2132
      Question
2133
      |> select([q], q.question["token_divider"])
3✔
2134
      |> Repo.get_by(id: contest_voting_question_id)
2135

2136
    entry_scores = map_eligible_votes_to_entry_score(eligible_votes, token_divider)
3✔
2137
    normalized_scores = map_eligible_votes_to_popular_score(eligible_votes, token_divider)
3✔
2138

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

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

2168
  defp map_eligible_votes_to_entry_score(eligible_votes, token_divider) do
2169
    # converts eligible votes to the {total cumulative score, number of votes, tokens}
2170
    entry_vote_data =
3✔
2171
      Enum.reduce(eligible_votes, %{}, fn %{ans_id: ans_id, score: score, ans: ans}, tracker ->
2172
        {prev_score, prev_count, _ans_tokens} = Map.get(tracker, ans_id, {0, 0, 0})
75✔
2173

2174
        Map.put(
75✔
2175
          tracker,
2176
          ans_id,
2177
          # assume each voter is assigned 10 entries which will make it fair.
2178
          {prev_score + score, prev_count + 1, Lexer.count_tokens(ans)}
2179
        )
2180
      end)
2181

2182
    # calculate the score based on formula {ans_id, score}
2183
    Enum.map(
3✔
2184
      entry_vote_data,
2185
      fn {ans_id, {sum_of_scores, number_of_voters, tokens}} ->
15✔
2186
        {ans_id, calculate_formula_score(sum_of_scores, number_of_voters, tokens, token_divider)}
2187
      end
2188
    )
2189
  end
2190

2191
  defp map_eligible_votes_to_popular_score(eligible_votes, token_divider) do
2192
    # converts eligible votes to the {total cumulative score, number of votes, tokens}
2193
    entry_vote_data =
3✔
2194
      Enum.reduce(eligible_votes, %{}, fn %{ans_id: ans_id, score: score, ans: ans}, tracker ->
2195
        {prev_score, prev_count, _ans_tokens} = Map.get(tracker, ans_id, {0, 0, 0})
75✔
2196

2197
        Map.put(
75✔
2198
          tracker,
2199
          ans_id,
2200
          # assume each voter is assigned 10 entries which will make it fair.
2201
          {prev_score + score, prev_count + 1, Lexer.count_tokens(ans)}
2202
        )
2203
      end)
2204

2205
    # calculate the score based on formula {ans_id, score}
2206
    Enum.map(
3✔
2207
      entry_vote_data,
2208
      fn {ans_id, {sum_of_scores, number_of_voters, tokens}} ->
15✔
2209
        {ans_id,
2210
         calculate_normalized_score(sum_of_scores, number_of_voters, tokens, token_divider)}
2211
      end
2212
    )
2213
  end
2214

2215
  # Calculate the score based on formula
2216
  # score(v,t) = v - 2^(t/token_divider) where v is the normalized_voting_score
2217
  # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100
2218
  defp calculate_formula_score(sum_of_scores, number_of_voters, tokens, token_divider) do
2219
    normalized_voting_score =
15✔
2220
      calculate_normalized_score(sum_of_scores, number_of_voters, tokens, token_divider)
2221

2222
    normalized_voting_score - :math.pow(2, min(1023.5, tokens / token_divider))
15✔
2223
  end
2224

2225
  # Calculate the normalized score based on formula
2226
  # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100
2227
  defp calculate_normalized_score(sum_of_scores, number_of_voters, _tokens, _token_divider) do
2228
    sum_of_scores / number_of_voters / 10 * 100
30✔
2229
  end
2230

2231
  @doc """
2232
  Function returning submissions under a grader. This function returns only the
2233
  fields that are exposed in the /grading endpoint.
2234

2235
  The input parameters are the user and query parameters. Query parameters are
2236
  used to filter the submissions.
2237

2238
  The return value is `{:ok, %{"count": count, "data": submissions}}`
2239

2240
  # Params
2241
  Refer to admin_grading_controller.ex/index for the list of query parameters.
2242

2243
  # Implementation
2244
  Uses helper functions to build the filter query. Helper functions are separated by tables in the database.
2245
  """
2246

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

2274
    question_answers_query =
41✔
2275
      from(q in Question,
41✔
2276
        group_by: q.assessment_id,
2277
        join: a in Assessment,
2278
        on: q.assessment_id == a.id,
2279
        select: %{
2280
          assessment_id: q.assessment_id,
2281
          question_count: count(q.id),
2282
          title: max(a.title),
2283
          config_id: max(a.config_id)
2284
        }
2285
      )
2286

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

2331
    query = sort_submission(query, params[:sort_by], params[:sort_direction])
41✔
2332

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

2336
    submissions = Repo.all(query)
41✔
2337

2338
    count_query =
41✔
2339
      from(s in Submission,
41✔
2340
        left_join: ans in subquery(submission_answers_query),
2341
        on: ans.submission_id == s.id,
2342
        as: :ans,
2343
        left_join: asst in subquery(question_answers_query),
2344
        on: asst.assessment_id == s.assessment_id,
2345
        as: :asst,
2346
        where: s.assessment_id in subquery(build_assessment_filter(params, course_id)),
2347
        where: s.assessment_id in subquery(build_assessment_config_filter(params)),
2348
        where: ^build_user_filter(params),
2349
        where: ^build_submission_filter(params),
2350
        where: ^build_course_registration_filter(params, grader),
2351
        select: count(s.id)
2352
      )
2353

2354
    count = Repo.one(count_query)
41✔
2355

2356
    {:ok, %{count: count, data: generate_grading_summary_view_model(submissions, course_id)}}
2357
  end
2358

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

2369
      :assessment_type ->
2370
        from([s, ans, asst, cr, user, group, config] in query,
2✔
2371
          order_by: [{^sort_direction, asst.config_id}]
2372
        )
2373

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

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

2384
      :group_name ->
2385
        from([s, ans, asst, cr, user, group, config] in query,
×
2386
          order_by: [{^sort_direction, fragment("upper(?)", group.name)}]
2387
        )
2388

2389
      :progress_status ->
2390
        from([s, ans, asst, cr, user, group, config] in query,
×
2391
          order_by: [
2392
            {^sort_direction, config.is_manually_graded},
2393
            {^sort_direction, s.status},
2394
            {^sort_direction, ans.graded_count - asst.question_count},
2395
            {^sort_direction, s.is_grading_published}
2396
          ]
2397
        )
2398

2399
      :xp ->
2400
        from([s, ans, asst, cr, user, group, config] in query,
2✔
2401
          order_by: [{^sort_direction, ans.xp + ans.xp_adjustment}]
2402
        )
2403

2404
      _ ->
2405
        query
×
2406
    end
2407
  end
2408

2409
  defp sort_submission(query, _sort_by, _sort_direction), do: query
35✔
2410

2411
  def parse_sort_direction(params) do
2412
    case params[:sort_direction] do
5✔
2413
      "sort-asc" -> Map.put(params, :sort_direction, :asc)
×
2414
      "sort-desc" -> Map.put(params, :sort_direction, :desc)
×
2415
      _ -> Map.put(params, :sort_direction, nil)
5✔
2416
    end
2417
  end
2418

2419
  def parse_sort_by(params) do
2420
    case params[:sort_by] do
5✔
2421
      "assessmentName" -> Map.put(params, :sort_by, :assessment_name)
×
2422
      "assessmentType" -> Map.put(params, :sort_by, :assessment_type)
×
2423
      "studentName" -> Map.put(params, :sort_by, :student_name)
×
2424
      "studentUsername" -> Map.put(params, :sort_by, :student_username)
×
2425
      "groupName" -> Map.put(params, :sort_by, :group_name)
×
2426
      "progressStatus" -> Map.put(params, :sort_by, :progress_status)
×
2427
      "xp" -> Map.put(params, :sort_by, :xp)
×
2428
      _ -> Map.put(params, :sort_by, nil)
5✔
2429
    end
2430
  end
2431

2432
  defp build_assessment_filter(params, course_id) do
2433
    assessments_filters =
82✔
2434
      Enum.reduce(params, dynamic(true), fn
82✔
2435
        {:title, value}, dynamic ->
2436
          dynamic([assessment], ^dynamic and ilike(assessment.title, ^"%#{value}%"))
2✔
2437

2438
        {_, _}, dynamic ->
2439
          dynamic
206✔
2440
      end)
2441

2442
    from(a in Assessment,
82✔
2443
      where: a.course_id == ^course_id,
2444
      where: ^assessments_filters,
2445
      select: a.id
2446
    )
2447
  end
2448

2449
  defp build_submission_filter(params) do
2450
    Enum.reduce(params, dynamic(true), fn
82✔
2451
      {:status, value}, dynamic ->
2452
        dynamic([submission], ^dynamic and submission.status == ^value)
6✔
2453

2454
      {:is_fully_graded, value}, dynamic ->
2455
        dynamic(
4✔
2456
          [ans: ans, asst: asst],
2457
          ^dynamic and asst.question_count == ans.graded_count == ^value
2458
        )
2459

2460
      {:is_grading_published, value}, dynamic ->
2461
        dynamic([submission], ^dynamic and submission.is_grading_published == ^value)
4✔
2462

2463
      {_, _}, dynamic ->
2464
        dynamic
194✔
2465
    end)
2466
  end
2467

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

2484
      {:group_name, value}, dynamic ->
2485
        dynamic(
4✔
2486
          [submission],
2487
          ^dynamic and
2488
            submission.student_id in subquery(
2489
              from(cr in CourseRegistration,
4✔
2490
                join: g in Group,
2491
                on: cr.group_id == g.id,
2492
                where: g.name == ^value,
2493
                select: cr.id
2494
              )
2495
            )
2496
        )
2497

2498
      {_, _}, dynamic ->
2499
        dynamic
194✔
2500
    end)
2501
  end
2502

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

2519
      {:username, value}, dynamic ->
2520
        dynamic(
6✔
2521
          [submission],
2522
          ^dynamic and
2523
            submission.student_id in subquery(
2524
              from(user in User,
6✔
2525
                where: ilike(user.username, ^"%#{value}%"),
6✔
2526
                inner_join: cr in CourseRegistration,
2527
                on: user.id == cr.user_id,
2528
                select: cr.id
2529
              )
2530
            )
2531
        )
2532

2533
      {_, _}, dynamic ->
2534
        dynamic
196✔
2535
    end)
2536
  end
2537

2538
  defp build_assessment_config_filter(params) do
2539
    assessment_config_filters =
82✔
2540
      Enum.reduce(params, dynamic(true), fn
82✔
2541
        {:type, value}, dynamic ->
2542
          dynamic([assessment_config: config], ^dynamic and config.type == ^value)
6✔
2543

2544
        {:is_manually_graded, value}, dynamic ->
2545
          dynamic([assessment_config: config], ^dynamic and config.is_manually_graded == ^value)
4✔
2546

2547
        {_, _}, dynamic ->
2548
          dynamic
198✔
2549
      end)
2550

2551
    from(a in Assessment,
82✔
2552
      inner_join: config in AssessmentConfig,
2553
      on: a.config_id == config.id,
2554
      as: :assessment_config,
2555
      where: ^assessment_config_filters,
2556
      select: a.id
2557
    )
2558
  end
2559

2560
  defp generate_grading_summary_view_model(submissions, course_id) do
2561
    users =
41✔
2562
      CourseRegistration
2563
      |> where([cr], cr.course_id == ^course_id)
41✔
2564
      |> join(:inner, [cr], u in assoc(cr, :user))
41✔
2565
      |> join(:left, [cr, u], g in assoc(cr, :group))
2566
      |> preload([cr, u, g], user: u, group: g)
41✔
2567
      |> Repo.all()
2568

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

2571
    assessments =
41✔
2572
      Assessment
2573
      |> where([a], a.id in ^assessment_ids)
41✔
2574
      |> join(:left, [a], q in assoc(a, :questions))
41✔
2575
      |> join(:inner, [a], ac in assoc(a, :config))
2576
      |> preload([a, q, ac], questions: q, config: ac)
41✔
2577
      |> Repo.all()
2578

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

2581
    teams =
41✔
2582
      Team
2583
      |> where([t], t.id in ^team_ids)
41✔
2584
      |> Repo.all()
2585

2586
    team_members =
41✔
2587
      TeamMember
2588
      |> where([tm], tm.team_id in ^team_ids)
41✔
2589
      |> Repo.all()
2590

2591
    %{
41✔
2592
      users: users,
2593
      assessments: assessments,
2594
      submissions: submissions,
2595
      teams: teams,
2596
      team_members: team_members
2597
    }
2598
  end
2599

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

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

2642
    if answers == [] do
4✔
2643
      {:error, {:bad_request, "Submission is not found."}}
2644
    else
2645
      assessment_id = Submission |> where(id: ^id) |> select([s], s.assessment_id) |> Repo.one()
2✔
2646
      assessment = Assessment |> where(id: ^assessment_id) |> Repo.one()
2✔
2647
      {:ok, {answers, assessment}}
2648
    end
2649
  end
2650

2651
  defp is_fully_graded?(submission_id) do
2652
    submission =
6✔
2653
      Submission
2654
      |> Repo.get_by(id: submission_id)
2655

2656
    question_count =
6✔
2657
      Question
2658
      |> where(assessment_id: ^submission.assessment_id)
6✔
2659
      |> select([q], count(q.id))
6✔
2660
      |> Repo.one()
2661

2662
    graded_count =
6✔
2663
      Answer
2664
      |> where([a], submission_id: ^submission_id)
2665
      |> where([a], not is_nil(a.grader_id))
2666
      |> select([a], count(a.id))
6✔
2667
      |> Repo.one()
2668

2669
    question_count == graded_count
6✔
2670
  end
2671

2672
  def is_fully_autograded?(submission_id) do
2673
    submission =
59✔
2674
      Submission
2675
      |> Repo.get_by(id: submission_id)
2676

2677
    question_count =
59✔
2678
      Question
2679
      |> where(assessment_id: ^submission.assessment_id)
59✔
2680
      |> select([q], count(q.id))
59✔
2681
      |> Repo.one()
2682

2683
    graded_count =
59✔
2684
      Answer
2685
      |> where([a], submission_id: ^submission_id)
2686
      |> where([a], a.autograding_status == :success)
2687
      |> select([a], count(a.id))
59✔
2688
      |> Repo.one()
2689

2690
    question_count == graded_count
59✔
2691
  end
2692

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

2708
    answer_query =
8✔
2709
      Answer
2710
      |> where(submission_id: ^submission_id)
2711
      |> where(question_id: ^question_id)
8✔
2712

2713
    answer_query =
8✔
2714
      answer_query
2715
      |> join(:inner, [a], s in assoc(a, :submission))
2716
      |> preload([_, s], submission: s)
8✔
2717

2718
    answer = Repo.one(answer_query)
8✔
2719

2720
    is_own_submission = grader_id == answer.submission.student_id
8✔
2721

2722
    submission =
8✔
2723
      Submission
2724
      |> join(:inner, [s], a in assoc(s, :assessment))
2725
      |> preload([_, a], assessment: {a, :config})
8✔
2726
      |> Repo.get(submission_id)
2727

2728
    is_grading_auto_published = submission.assessment.config.is_grading_auto_published
8✔
2729

2730
    with {:answer_found?, true} <- {:answer_found?, is_map(answer)},
8✔
2731
         {:status, true} <-
8✔
2732
           {:status, answer.submission.status == :submitted or is_own_submission},
8✔
2733
         {:valid, changeset = %Ecto.Changeset{valid?: true}} <-
7✔
2734
           {:valid, Answer.grading_changeset(answer, attrs)},
2735
         {:ok, _} <- Repo.update(changeset) do
6✔
2736
      update_xp_bonus(submission)
6✔
2737

2738
      if is_grading_auto_published and is_fully_graded?(submission_id) do
6✔
2739
        publish_grading(submission_id, cr)
×
2740
      end
2741

2742
      {:ok, nil}
2743
    else
2744
      {:answer_found?, false} ->
×
2745
        {:error, {:bad_request, "Answer not found or user not permitted to grade."}}
2746

2747
      {:valid, changeset} ->
1✔
2748
        {:error, {:bad_request, full_error_messages(changeset)}}
2749

2750
      {:status, _} ->
1✔
2751
        {:error, {:method_not_allowed, "Submission is not submitted yet."}}
2752

2753
      {:error, _} ->
×
2754
        {:error, {:internal_server_error, "Please try again later."}}
2755
    end
2756
  end
2757

2758
  def update_grading_info(
1✔
2759
        _,
2760
        _,
2761
        _
2762
      ) do
2763
    {:error, {:forbidden, "User is not permitted to grade."}}
2764
  end
2765

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

2781
      {:status, false} ->
×
2782
        {:error, {:bad_request, "Submission not submitted yet"}}
2783
    end
2784
  end
2785

2786
  def force_regrade_submission(_, _) do
×
2787
    {:error, {:forbidden, "User is not permitted to grade."}}
2788
  end
2789

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

2808
    with {:get, answer} when not is_nil(answer) <- {:get, answer},
2✔
2809
         {:status, true} <-
1✔
2810
           {:status,
2811
            answer.submission.student_id == grader_id or answer.submission.status == :submitted} do
1✔
2812
      GradingJob.grade_answer(answer, answer.question, true)
1✔
2813
      {:ok, nil}
2814
    else
2815
      {:get, nil} ->
1✔
2816
        {:error, {:not_found, "Answer not found"}}
2817

2818
      {:status, false} ->
×
2819
        {:error, {:bad_request, "Submission not submitted yet"}}
2820
    end
2821
  end
2822

2823
  def force_regrade_answer(_, _, _) do
×
2824
    {:error, {:forbidden, "User is not permitted to grade."}}
2825
  end
2826

2827
  defp find_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do
2828
    {:ok, team} = find_team(assessment.id, cr.id)
68✔
2829

2830
    submission =
68✔
2831
      case team do
2832
        %Team{} ->
2833
          Submission
2834
          |> where(team_id: ^team.id)
2✔
2835
          |> where(assessment_id: ^assessment.id)
2✔
2836
          |> Repo.one()
2✔
2837

2838
        nil ->
2839
          Submission
2840
          |> where(student_id: ^cr.id)
66✔
2841
          |> where(assessment_id: ^assessment.id)
66✔
2842
          |> Repo.one()
66✔
2843
      end
2844

2845
    if submission do
68✔
2846
      {:ok, submission}
2847
    else
2848
      {:error, nil}
2849
    end
2850
  end
2851

2852
  # Checks if an assessment is open and published.
2853
  @spec is_open?(Assessment.t()) :: boolean()
2854
  def is_open?(%Assessment{open_at: open_at, close_at: close_at, is_published: is_published}) do
2855
    Timex.between?(Timex.now(), open_at, close_at, inclusive: :start) and is_published
390✔
2856
  end
2857

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

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

2898
    showing_configs =
1✔
2899
      AssessmentConfig
2900
      |> where([ac], ac.course_id == ^course_id and ac.show_grading_summary)
2901
      |> order_by(:order)
2902
      |> group_by([ac], ac.id)
2903
      |> select([ac], %{
1✔
2904
        id: ac.id,
2905
        type: ac.type
2906
      })
2907
      |> Repo.all()
2908

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

2926
    headings =
1✔
2927
      showing_configs
2928
      |> Enum.reduce([], fn config, acc ->
2929
        acc ++ ["submitted" <> config.type, "ungraded" <> config.type]
2✔
2930
      end)
2931

2932
    default_row_data =
1✔
2933
      headings
2934
      |> Enum.reduce(%{}, fn heading, acc ->
2935
        put_in(acc, [heading], 0)
4✔
2936
      end)
2937

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

2941
    {:ok, cols, rows}
1✔
2942
  end
2943

2944
  defp create_empty_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do
2945
    {:ok, team} = find_team(assessment.id, cr.id)
17✔
2946

2947
    case team do
17✔
2948
      %Team{} ->
2949
        %Submission{}
2950
        |> Submission.changeset(%{team: team, assessment: assessment})
2951
        |> Repo.insert()
2952
        |> case do
1✔
2953
          {:ok, submission} -> {:ok, submission}
1✔
2954
        end
2955

2956
      nil ->
2957
        %Submission{}
2958
        |> Submission.changeset(%{student: cr, assessment: assessment})
2959
        |> Repo.insert()
2960
        |> case do
16✔
2961
          {:ok, submission} -> {:ok, submission}
16✔
2962
        end
2963
    end
2964
  end
2965

2966
  defp find_or_create_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do
2967
    case find_submission(cr, assessment) do
58✔
2968
      {:ok, submission} -> {:ok, submission}
41✔
2969
      {:error, _} -> create_empty_submission(cr, assessment)
17✔
2970
    end
2971
  end
2972

2973
  defp insert_or_update_answer(
2974
         submission = %Submission{},
2975
         question = %Question{},
2976
         raw_answer,
2977
         course_reg_id
2978
       ) do
2979
    answer_content = build_answer_content(raw_answer, question.type)
52✔
2980

2981
    if question.type == :voting do
52✔
2982
      insert_or_update_voting_answer(submission.id, course_reg_id, question.id, answer_content)
15✔
2983
    else
2984
      answer_changeset =
37✔
2985
        %Answer{}
2986
        |> Answer.changeset(%{
2987
          answer: answer_content,
2988
          question_id: question.id,
37✔
2989
          submission_id: submission.id,
37✔
2990
          type: question.type,
37✔
2991
          last_modified_at: Timex.now()
2992
        })
2993

2994
      Repo.insert(
37✔
2995
        answer_changeset,
2996
        on_conflict: [
2997
          set: [answer: get_change(answer_changeset, :answer), last_modified_at: Timex.now()]
2998
        ],
2999
        conflict_target: [:submission_id, :question_id]
3000
      )
3001
    end
3002
  end
3003

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

3020
  defp answer_last_modified?(
3021
         submission = %Submission{},
3022
         question = %Question{},
3023
         last_modified_at
3024
       ) do
3025
    case Repo.get_by(Answer, submission_id: submission.id, question_id: question.id) do
2✔
3026
      %Answer{last_modified_at: existing_last_modified_at} ->
3027
        existing_iso8601 = DateTime.to_iso8601(existing_last_modified_at)
1✔
3028

3029
        if existing_iso8601 == last_modified_at do
1✔
3030
          {:ok, false}
3031
        else
3032
          {:ok, true}
3033
        end
3034

3035
      nil ->
1✔
3036
        {:ok, false}
3037
    end
3038
  end
3039

3040
  def insert_or_update_voting_answer(submission_id, course_reg_id, question_id, answer_content) do
3041
    set_score_to_nil =
15✔
3042
      SubmissionVotes
3043
      |> where(voter_id: ^course_reg_id, question_id: ^question_id)
15✔
3044

3045
    voting_multi =
15✔
3046
      Multi.new()
3047
      |> Multi.update_all(:set_score_to_nil, set_score_to_nil, set: [score: nil])
3048

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

3075
        _ ->
3✔
3076
          {:ok, nil}
3077
      end
3078
    end)
3079
    |> Repo.transaction()
3080
    |> case do
15✔
3081
      {:ok, _result} -> {:ok, nil}
12✔
3082
      {:error, _name, _changeset, _error} -> {:error, :invalid_vote}
3✔
3083
    end
3084
  end
3085

3086
  defp build_answer_content(raw_answer, question_type) do
3087
    case question_type do
52✔
3088
      :mcq ->
3089
        %{choice_id: raw_answer}
20✔
3090

3091
      :programming ->
3092
        %{code: raw_answer}
17✔
3093

3094
      :voting ->
3095
        raw_answer
3096
        |> Enum.map(fn ans ->
15✔
3097
          for {key, value} <- ans, into: %{}, do: {String.to_existing_atom(key), value}
15✔
3098
        end)
3099
    end
3100
  end
3101
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