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

source-academy / backend / 18dc689a4df4836fc6967bf0f74dc252964bd175-PR-1180

08 Sep 2024 06:14PM UTC coverage: 79.088% (-15.3%) from 94.372%
18dc689a4df4836fc6967bf0f74dc252964bd175-PR-1180

Pull #1180

github

josh1248
Change appropriate routes into admin scope
Pull Request #1180: Transfer groundControl (and admin panel) from staff to admin route

7 of 12 new or added lines in 1 file covered. (58.33%)

499 existing lines in 25 files now uncovered.

2602 of 3290 relevant lines covered (79.09%)

1023.2 hits per line

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

82.57
/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
  import Ecto.Query
8

9
  require Logger
10

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

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

30
  require Decimal
31

32
  @open_all_assessment_roles ~w(staff admin)a
33

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

38
  def delete_assessment(id) do
39
    assessment = Repo.get(Assessment, id)
5✔
40

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

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

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

66
      Repo.delete(assessment)
4✔
67
    end
68
  end
69

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

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

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

89
    Repo.delete_all(submissions)
5✔
90
  end
91

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

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

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

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

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

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

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

145
    total_achievement_xp + total_assessment_xp
13✔
146
  end
147

148
  defp decimal_to_integer(decimal) do
149
    if Decimal.is_decimal(decimal) do
380✔
150
      Decimal.to_integer(decimal)
375✔
151
    else
152
      0
153
    end
154
  end
155

156
  def user_current_story(cr = %CourseRegistration{}) do
157
    {:ok, %{result: story}} =
2✔
158
      Multi.new()
159
      |> Multi.run(:unattempted, fn _repo, _ ->
2✔
160
        {:ok, get_user_story_by_type(cr, :unattempted)}
161
      end)
162
      |> Multi.run(:result, fn _repo, %{unattempted: unattempted_story} ->
163
        if unattempted_story do
2✔
164
          {:ok, %{play_story?: true, story: unattempted_story}}
165
        else
166
          {:ok, %{play_story?: false, story: get_user_story_by_type(cr, :attempted)}}
167
        end
168
      end)
169
      |> Repo.transaction()
170

171
    story
2✔
172
  end
173

174
  @spec get_user_story_by_type(CourseRegistration.t(), :unattempted | :attempted) ::
175
          String.t() | nil
176
  def get_user_story_by_type(%CourseRegistration{id: cr_id}, type)
177
      when is_atom(type) do
178
    filter_and_sort = fn query ->
2✔
179
      case type do
2✔
180
        :unattempted ->
181
          query
182
          |> where([_, s], is_nil(s.id))
183
          |> order_by([a], asc: a.open_at)
2✔
184

185
        :attempted ->
186
          query |> order_by([a], desc: a.close_at)
×
187
      end
188
    end
189

190
    Assessment
191
    |> where(is_published: true)
192
    |> where([a], not is_nil(a.story))
193
    |> where([a], a.open_at <= from_now(0, "second") and a.close_at >= from_now(0, "second"))
2✔
194
    |> join(:left, [a], s in Submission, on: s.assessment_id == a.id and s.student_id == ^cr_id)
2✔
195
    |> filter_and_sort.()
196
    |> order_by([a], a.config_id)
197
    |> select([a], a.story)
2✔
198
    |> first()
199
    |> Repo.one()
2✔
200
  end
201

202
  def assessment_with_questions_and_answers(
203
        assessment = %Assessment{password: nil},
204
        cr = %CourseRegistration{},
205
        nil
206
      ) do
207
    assessment_with_questions_and_answers(assessment, cr)
87✔
208
  end
209

210
  def assessment_with_questions_and_answers(
211
        assessment = %Assessment{password: nil},
212
        cr = %CourseRegistration{},
213
        _
214
      ) do
215
    assessment_with_questions_and_answers(assessment, cr)
3✔
216
  end
217

218
  def assessment_with_questions_and_answers(
219
        assessment = %Assessment{password: password},
220
        cr = %CourseRegistration{},
221
        given_password
222
      ) do
223
    cond do
11✔
224
      Timex.compare(Timex.now(), assessment.close_at) >= 0 ->
11✔
225
        assessment_with_questions_and_answers(assessment, cr)
1✔
226

227
      match?({:ok, _}, find_submission(cr, assessment)) ->
10✔
228
        assessment_with_questions_and_answers(assessment, cr)
1✔
229

230
      given_password == nil ->
9✔
231
        {:error, {:forbidden, "Missing Password."}}
232

233
      password == given_password ->
6✔
234
        find_or_create_submission(cr, assessment)
3✔
235
        assessment_with_questions_and_answers(assessment, cr)
3✔
236

237
      true ->
3✔
238
        {:error, {:forbidden, "Invalid Password."}}
239
    end
240
  end
241

242
  def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}, password)
243
      when is_ecto_id(id) do
244
    role = cr.role
101✔
245

246
    assessment =
101✔
247
      if role in @open_all_assessment_roles do
248
        Assessment
249
        |> where(id: ^id)
250
        |> preload(:config)
66✔
251
        |> Repo.one()
66✔
252
      else
253
        Assessment
254
        |> where(id: ^id)
255
        |> where(is_published: true)
256
        |> preload(:config)
35✔
257
        |> Repo.one()
35✔
258
      end
259

260
    if assessment do
101✔
261
      assessment_with_questions_and_answers(assessment, cr, password)
100✔
262
    else
263
      {:error, {:bad_request, "Assessment not found"}}
264
    end
265
  end
266

267
  def assessment_with_questions_and_answers(
268
        assessment = %Assessment{id: id},
269
        course_reg = %CourseRegistration{role: role}
270
      ) do
271
    team_id =
99✔
272
      case find_team(id, course_reg.id) do
99✔
273
        {:ok, nil} ->
274
          -1
96✔
275

276
        {:ok, team} ->
277
          team.id
1✔
278

279
        {:error, :team_not_found} ->
280
          -1
2✔
281
      end
282

283
    if Timex.compare(Timex.now(), assessment.open_at) >= 0 or role in @open_all_assessment_roles do
99✔
284
      answer_query =
98✔
285
        Answer
286
        |> join(:inner, [a], s in assoc(a, :submission))
287
        |> where([_, s], s.student_id == ^course_reg.id or s.team_id == ^team_id)
98✔
288

289
      questions =
98✔
290
        Question
291
        |> where(assessment_id: ^id)
98✔
292
        |> join(:left, [q], a in subquery(answer_query), on: q.id == a.question_id)
98✔
293
        |> join(:left, [_, a], g in assoc(a, :grader))
98✔
294
        |> join(:left, [_, _, g], u in assoc(g, :user))
295
        |> select([q, a, g, u], {q, a, g, u})
296
        |> order_by(:display_order)
98✔
297
        |> Repo.all()
298
        |> Enum.map(fn
299
          {q, nil, _, _} -> %{q | answer: %Answer{grader: nil}}
540✔
300
          {q, a, nil, _} -> %{q | answer: %Answer{a | grader: nil}}
216✔
301
          {q, a, g, u} -> %{q | answer: %Answer{a | grader: %CourseRegistration{g | user: u}}}
47✔
302
        end)
303
        |> load_contest_voting_entries(course_reg, assessment)
304

305
      is_grading_published =
98✔
306
        Submission
307
        |> where(assessment_id: ^id)
308
        |> where(student_id: ^course_reg.id)
98✔
309
        |> select([s], s.is_grading_published)
98✔
310
        |> Repo.one()
311

312
      assessment =
98✔
313
        assessment
314
        |> Map.put(:questions, questions)
315
        |> Map.put(:is_grading_published, is_grading_published)
316

317
      {:ok, assessment}
318
    else
319
      {:error, {:forbidden, "Assessment not open"}}
320
    end
321
  end
322

323
  def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}) do
324
    assessment_with_questions_and_answers(id, cr, nil)
92✔
325
  end
326

327
  @doc """
328
  Returns a list of assessments with all fields and an indicator showing whether it has been attempted
329
  by the supplied user
330
  """
331
  def all_assessments(cr = %CourseRegistration{}) do
332
    teams = find_teams(cr.id)
16✔
333
    submission_ids = get_submission_ids(cr.id, teams)
16✔
334

335
    submission_aggregates =
16✔
336
      Submission
337
      |> join(:left, [s], ans in Answer, on: ans.submission_id == s.id)
338
      |> where(
339
        [s],
340
        s.id in ^submission_ids
341
      )
342
      |> group_by([s], s.assessment_id)
343
      |> select([s, ans], %{
16✔
344
        assessment_id: s.assessment_id,
345
        # s.xp_bonus should be the same across the group, but we need an aggregate function here
346
        xp: fragment("? + ? + ?", sum(ans.xp), sum(ans.xp_adjustment), max(s.xp_bonus)),
347
        graded_count: ans.id |> count() |> filter(not is_nil(ans.grader_id))
348
      })
349

350
    submission_status =
16✔
351
      Submission
352
      |> where(
353
        [s],
354
        s.id in ^submission_ids
355
      )
356
      |> select([s], [:assessment_id, :status, :is_grading_published])
16✔
357

358
    assessments =
16✔
359
      cr.course_id
16✔
360
      |> Query.all_assessments_with_aggregates()
361
      |> subquery()
362
      |> join(
16✔
363
        :left,
364
        [a],
365
        sa in subquery(submission_aggregates),
366
        on: a.id == sa.assessment_id
367
      )
368
      |> join(:left, [a, _], s in subquery(submission_status), on: a.id == s.assessment_id)
369
      |> select([a, sa, s], %{
16✔
370
        a
371
        | xp: sa.xp,
372
          graded_count: sa.graded_count,
373
          user_status: s.status,
374
          is_grading_published: s.is_grading_published
375
      })
376
      |> filter_published_assessments(cr)
377
      |> order_by(:open_at)
378
      |> preload(:config)
16✔
379
      |> Repo.all()
380

381
    {:ok, assessments}
382
  end
383

384
  defp get_submission_ids(cr_id, teams) do
385
    query =
32✔
386
      from(s in Submission,
387
        where: s.student_id == ^cr_id or s.team_id in ^Enum.map(teams, & &1.id),
1✔
388
        select: s.id
389
      )
390

391
    Repo.all(query)
32✔
392
  end
393

394
  @doc """
395
  A helper function which removes grading information from all assessments
396
  if it's grading is not published.
397
  """
398
  def format_all_assessments(assessments) do
399
    Enum.map(assessments, fn a ->
16✔
400
      if a.is_grading_published do
82✔
401
        a
8✔
402
      else
403
        a
404
        |> Map.put(:xp, 0)
405
        |> Map.put(:graded_count, 0)
74✔
406
      end
407
    end)
408
  end
409

410
  @doc """
411
  A helper function which removes grading information from the assessment
412
  if it's grading is not published.
413
  """
414
  def format_assessment_with_questions_and_answers(assessment) do
415
    if assessment.is_grading_published do
89✔
416
      assessment
6✔
417
    else
418
      %{
419
        assessment
420
        | questions:
83✔
421
            Enum.map(assessment.questions, fn q ->
83✔
422
              %{
423
                q
424
                | answer: %{
730✔
425
                    q.answer
730✔
426
                    | xp: 0,
427
                      xp_adjustment: 0,
428
                      autograding_status: :none,
429
                      autograding_results: [],
430
                      grader: nil,
431
                      grader_id: nil,
432
                      comments: nil
433
                  }
434
              }
435
            end)
436
      }
437
    end
438
  end
439

440
  def filter_published_assessments(assessments, cr) do
441
    role = cr.role
16✔
442

443
    case role do
16✔
444
      :student -> where(assessments, is_published: true)
10✔
445
      _ -> assessments
6✔
446
    end
447
  end
448

449
  def create_assessment(params) do
450
    %Assessment{}
451
    |> Assessment.changeset(params)
452
    |> Repo.insert()
1✔
453
  end
454

455
  @doc """
456
  The main function that inserts or updates assessments from the XML Parser
457
  """
458
  @spec insert_or_update_assessments_and_questions(map(), [map()], boolean()) ::
459
          {:ok, any()}
460
          | {:error, Ecto.Multi.name(), any(), %{optional(Ecto.Multi.name()) => any()}}
461
  def insert_or_update_assessments_and_questions(
462
        assessment_params,
463
        questions_params,
464
        force_update
465
      ) do
466
    assessment_multi =
26✔
467
      Multi.insert_or_update(
468
        Multi.new(),
469
        :assessment,
470
        insert_or_update_assessment_changeset(assessment_params, force_update)
471
      )
472

473
    if force_update and invalid_force_update(assessment_multi, questions_params) do
26✔
474
      {:error, "Question count is different"}
475
    else
476
      questions_params
477
      |> Enum.with_index(1)
478
      |> Enum.reduce(assessment_multi, fn {question_params, index}, multi ->
479
        Multi.run(multi, "question#{index}", fn _repo, %{assessment: %Assessment{id: id}} ->
75✔
480
          question =
50✔
481
            Question
482
            |> where([q], q.display_order == ^index and q.assessment_id == ^id)
50✔
483
            |> Repo.one()
484

485
          # the is_nil(question) check allows for force updating of brand new assessments
486
          if !force_update or is_nil(question) do
50✔
487
            {status, new_question} =
50✔
488
              question_params
489
              |> Map.put(:display_order, index)
490
              |> build_question_changeset_for_assessment_id(id)
491
              |> Repo.insert()
492

493
            if status == :ok and new_question.type == :voting do
50✔
494
              insert_voting(
15✔
495
                assessment_params.course_id,
15✔
496
                question_params.question.contest_number,
15✔
497
                new_question.id
15✔
498
              )
499
            else
500
              {status, new_question}
501
            end
502
          else
503
            params =
×
504
              question_params
505
              |> Map.put_new(:max_xp, 0)
506
              |> Map.put(:display_order, index)
507

508
            if question_params.type != Atom.to_string(question.type) do
×
509
              {:error,
510
               create_invalid_changeset_with_error(
511
                 :question,
512
                 "Question types should remain the same"
513
               )}
514
            else
515
              question
516
              |> Question.changeset(params)
517
              |> Repo.update()
×
518
            end
519
          end
520
        end)
521
      end)
522
      |> Repo.transaction()
25✔
523
    end
524
  end
525

526
  # Function that checks if the force update is invalid. The force update is only invalid
527
  # if the new question count is different from the old question count.
528
  defp invalid_force_update(assessment_multi, questions_params) do
529
    assessment_id =
1✔
530
      (assessment_multi.operations
1✔
531
       |> List.first()
532
       |> elem(1)
533
       |> elem(1)).data.id
1✔
534

535
    if assessment_id do
1✔
536
      open_date = Repo.get(Assessment, assessment_id).open_at
1✔
537
      # check if assessment is already opened
538
      if Timex.compare(open_date, Timex.now()) >= 0 do
1✔
539
        false
540
      else
541
        existing_questions_count =
1✔
542
          Question
543
          |> where([q], q.assessment_id == ^assessment_id)
1✔
544
          |> Repo.all()
545
          |> Enum.count()
546

547
        new_questions_count = Enum.count(questions_params)
1✔
548
        existing_questions_count != new_questions_count
1✔
549
      end
550
    else
551
      false
552
    end
553
  end
554

555
  @spec insert_or_update_assessment_changeset(map(), boolean()) :: Ecto.Changeset.t()
556
  defp insert_or_update_assessment_changeset(
557
         params = %{number: number, course_id: course_id},
558
         force_update
559
       ) do
560
    Assessment
561
    |> where(number: ^number)
562
    |> where(course_id: ^course_id)
26✔
563
    |> Repo.one()
564
    |> case do
26✔
565
      nil ->
566
        Assessment.changeset(%Assessment{}, params)
15✔
567

568
      %{id: assessment_id} = assessment ->
569
        answers_exist =
11✔
570
          Answer
571
          |> join(:inner, [a], q in assoc(a, :question))
11✔
572
          |> join(:inner, [a, q], asst in assoc(q, :assessment))
573
          |> where([a, q, asst], asst.id == ^assessment_id)
11✔
574
          |> Repo.exists?()
575

576
        # Maintain the same open/close date when updating an assessment
577
        params =
11✔
578
          params
579
          |> Map.delete(:open_at)
580
          |> Map.delete(:close_at)
581
          |> Map.delete(:is_published)
582

583
        cond do
11✔
584
          not answers_exist ->
585
            # Delete all realted submission_votes
586
            SubmissionVotes
587
            |> join(:inner, [sv, q], q in assoc(sv, :question))
588
            |> where([sv, q], q.assessment_id == ^assessment_id)
6✔
589
            |> Repo.delete_all()
6✔
590

591
            # Delete all existing questions
592
            Question
593
            |> where(assessment_id: ^assessment_id)
6✔
594
            |> Repo.delete_all()
6✔
595

596
            Assessment.changeset(assessment, params)
6✔
597

598
          force_update ->
5✔
599
            Assessment.changeset(assessment, params)
×
600

601
          true ->
5✔
602
            # if the assessment has submissions, don't edit
603
            create_invalid_changeset_with_error(:assessment, "has submissions")
5✔
604
        end
605
    end
606
  end
607

608
  @spec build_question_changeset_for_assessment_id(map(), number() | String.t()) ::
609
          Ecto.Changeset.t()
610
  defp build_question_changeset_for_assessment_id(params, assessment_id)
611
       when is_ecto_id(assessment_id) do
612
    params_with_assessment_id = Map.put_new(params, :assessment_id, assessment_id)
50✔
613

614
    Question.changeset(%Question{}, params_with_assessment_id)
50✔
615
  end
616

617
  def reassign_voting(assessment_id, is_reassigning_voting) do
618
    if is_reassigning_voting do
1✔
619
      if is_voting_published(assessment_id) do
1✔
620
        Submission
621
        |> where(assessment_id: ^assessment_id)
1✔
622
        |> delete_submission_association(assessment_id)
1✔
623

624
        Question
625
        |> where(assessment_id: ^assessment_id)
1✔
626
        |> Repo.all()
627
        |> Enum.each(fn q ->
1✔
628
          delete_submission_votes_association(q)
1✔
629
        end)
630
      end
631

632
      voting_assigned_question_ids =
1✔
633
        SubmissionVotes
634
        |> select([v], v.question_id)
1✔
635
        |> Repo.all()
636

637
      unpublished_voting_questions =
1✔
638
        Question
639
        |> where(type: :voting)
640
        |> where([q], q.id not in ^voting_assigned_question_ids)
641
        |> where(assessment_id: ^assessment_id)
1✔
642
        |> join(:inner, [q], asst in assoc(q, :assessment))
643
        |> select([q, asst], %{course_id: asst.course_id, question: q.question, id: q.id})
1✔
644
        |> Repo.all()
645

646
      for q <- unpublished_voting_questions do
1✔
647
        insert_voting(q.course_id, q.question["contest_number"], q.id)
1✔
648
      end
649

650
      {:ok, "voting assigned"}
651
    else
652
      {:ok, "no change to voting"}
653
    end
654
  end
655

656
  def is_voting_published(assessment_id) do
657
    voting_assigned_question_ids =
40✔
658
      SubmissionVotes
659
      |> select([v], v.question_id)
40✔
660
      |> Repo.all()
661

662
    Question
663
    |> where(type: :voting)
664
    |> where(assessment_id: ^assessment_id)
665
    |> where([q], q.id in ^voting_assigned_question_ids)
40✔
666
    |> Repo.exists?()
40✔
667
  end
668

669
  def update_final_contest_entries do
670
    # 1435 = 1 day - 5 minutes
671
    if Log.log_execution("update_final_contest_entries", Duration.from_minutes(1435)) do
1✔
672
      Logger.info("Started update of contest entry pools")
1✔
673
      questions = fetch_unassigned_voting_questions()
1✔
674

675
      for q <- questions do
1✔
676
        insert_voting(q.course_id, q.question["contest_number"], q.question_id)
2✔
677
      end
678

679
      Logger.info("Successfully update contest entry pools")
1✔
680
    end
681
  end
682

683
  # fetch voting questions where entries have not been assigned
684
  def fetch_unassigned_voting_questions do
685
    voting_assigned_question_ids =
2✔
686
      SubmissionVotes
687
      |> select([v], v.question_id)
2✔
688
      |> Repo.all()
689

690
    valid_assessments =
2✔
691
      Assessment
692
      |> select([a], %{number: a.number, course_id: a.course_id})
2✔
693
      |> Repo.all()
694

695
    valid_questions =
2✔
696
      Question
697
      |> where(type: :voting)
698
      |> where([q], q.id not in ^voting_assigned_question_ids)
2✔
699
      |> join(:inner, [q], asst in assoc(q, :assessment))
700
      |> select([q, asst], %{course_id: asst.course_id, question: q.question, question_id: q.id})
2✔
701
      |> Repo.all()
702

703
    # fetch only voting where there is a corresponding contest
704
    Enum.filter(valid_questions, fn q ->
2✔
705
      Enum.any?(
6✔
706
        valid_assessments,
707
        fn a -> a.number == q.question["contest_number"] and a.course_id == q.course_id end
64✔
708
      )
709
    end)
710
  end
711

712
  @doc """
713
  Generates and assigns contest entries for users with given usernames.
714
  """
715
  def insert_voting(
716
        course_id,
717
        contest_number,
718
        question_id
719
      ) do
720
    contest_assessment = Repo.get_by(Assessment, number: contest_number, course_id: course_id)
25✔
721

722
    if is_nil(contest_assessment) do
25✔
723
      changeset = change(%Assessment{}, %{number: ""})
2✔
724

725
      error_changeset =
2✔
726
        Ecto.Changeset.add_error(
727
          changeset,
728
          :number,
729
          "invalid contest number"
730
        )
731

732
      {:error, error_changeset}
733
    else
734
      if Timex.compare(contest_assessment.close_at, Timex.now()) < 0 do
23✔
735
        compile_entries(course_id, contest_assessment, question_id)
6✔
736
      else
737
        # contest has not closed, do nothing
738
        {:ok, nil}
739
      end
740
    end
741
  end
742

743
  def compile_entries(
744
        course_id,
745
        contest_assessment,
746
        question_id
747
      ) do
748
    # Returns contest submission ids with answers that contain "return"
749
    contest_submission_ids =
6✔
750
      Submission
751
      |> join(:inner, [s], ans in assoc(s, :answers))
6✔
752
      |> join(:inner, [s, ans], cr in assoc(s, :student))
753
      |> where([s, ans, cr], cr.role == "student")
754
      |> where([s, _], s.assessment_id == ^contest_assessment.id and s.status == "submitted")
6✔
755
      |> where(
756
        [_, ans, cr],
757
        fragment(
758
          "?->>'code' like ?",
759
          ans.answer,
760
          "%return%"
761
        )
762
      )
763
      |> select([s, _ans], {s.student_id, s.id})
6✔
764
      |> Repo.all()
765
      |> Enum.into(%{})
766

767
    contest_submission_ids_length = Enum.count(contest_submission_ids)
6✔
768

769
    voter_ids =
6✔
770
      CourseRegistration
771
      |> where(role: "student", course_id: ^course_id)
772
      |> select([cr], cr.id)
6✔
773
      |> Repo.all()
774

775
    votes_per_user = min(contest_submission_ids_length, 10)
6✔
776

777
    votes_per_submission =
6✔
778
      if Enum.empty?(contest_submission_ids) do
×
779
        0
780
      else
781
        trunc(Float.ceil(votes_per_user * length(voter_ids) / contest_submission_ids_length))
6✔
782
      end
783

784
    submission_id_list =
6✔
785
      contest_submission_ids
786
      |> Enum.map(fn {_, s_id} -> s_id end)
37✔
787
      |> Enum.shuffle()
788
      |> List.duplicate(votes_per_submission)
789
      |> List.flatten()
790

791
    {_submission_map, submission_votes_changesets} =
6✔
792
      voter_ids
793
      |> Enum.reduce({submission_id_list, []}, fn voter_id, acc ->
254✔
794
        {submission_list, submission_votes} = acc
46✔
795

796
        user_contest_submission_id = Map.get(contest_submission_ids, voter_id)
46✔
797

798
        {votes, rest} =
46✔
799
          submission_list
800
          |> Enum.reduce_while({MapSet.new(), submission_list}, fn s_id, acc ->
801
            {user_votes, submissions} = acc
367✔
802

803
            max_votes =
367✔
804
              if votes_per_user == contest_submission_ids_length and
×
805
                   not is_nil(user_contest_submission_id) do
367✔
806
                # no. of submssions is less than 10. Unable to find
807
                votes_per_user - 1
316✔
808
              else
809
                votes_per_user
51✔
810
              end
811

812
            if MapSet.size(user_votes) < max_votes do
367✔
813
              if s_id != user_contest_submission_id and not MapSet.member?(user_votes, s_id) do
321✔
814
                new_user_votes = MapSet.put(user_votes, s_id)
254✔
815
                new_submissions = List.delete(submissions, s_id)
254✔
816
                {:cont, {new_user_votes, new_submissions}}
817
              else
818
                {:cont, {user_votes, submissions}}
819
              end
820
            else
821
              {:halt, acc}
822
            end
823
          end)
824

825
        votes = MapSet.to_list(votes)
46✔
826

827
        new_submission_votes =
46✔
828
          votes
829
          |> Enum.map(fn s_id ->
830
            %SubmissionVotes{
254✔
831
              voter_id: voter_id,
832
              submission_id: s_id,
833
              question_id: question_id
834
            }
835
          end)
836
          |> Enum.concat(submission_votes)
837

838
        {rest, new_submission_votes}
839
      end)
840

841
    submission_votes_changesets
842
    |> Enum.with_index()
843
    |> Enum.reduce(Multi.new(), fn {changeset, index}, multi ->
844
      Multi.insert(multi, Integer.to_string(index), changeset)
254✔
845
    end)
846
    |> Repo.transaction()
6✔
847
  end
848

849
  def update_assessment(id, params) when is_ecto_id(id) do
850
    simple_update(
2✔
851
      Assessment,
852
      id,
853
      using: &Assessment.changeset/2,
854
      params: params
855
    )
856
  end
857

858
  def update_question(id, params) when is_ecto_id(id) do
859
    simple_update(
1✔
860
      Question,
861
      id,
862
      using: &Question.changeset/2,
863
      params: params
864
    )
865
  end
866

867
  def publish_assessment(id) when is_ecto_id(id) do
868
    update_assessment(id, %{is_published: true})
1✔
869
  end
870

871
  def create_question_for_assessment(params, assessment_id) when is_ecto_id(assessment_id) do
872
    assessment =
5✔
873
      Assessment
874
      |> where(id: ^assessment_id)
5✔
875
      |> join(:left, [a], q in assoc(a, :questions))
876
      |> preload([_, q], questions: q)
5✔
877
      |> Repo.one()
878

879
    if assessment do
5✔
880
      params_with_assessment_id = Map.put_new(params, :assessment_id, assessment.id)
5✔
881

882
      %Question{}
883
      |> Question.changeset(params_with_assessment_id)
884
      |> put_display_order(assessment.questions)
5✔
885
      |> Repo.insert()
5✔
886
    else
887
      {:error, "Assessment not found"}
888
    end
889
  end
890

891
  def get_question(id) when is_ecto_id(id) do
892
    Question
893
    |> where(id: ^id)
60✔
894
    |> join(:inner, [q], assessment in assoc(q, :assessment))
895
    |> preload([_, a], assessment: a)
60✔
896
    |> Repo.one()
60✔
897
  end
898

899
  def delete_question(id) when is_ecto_id(id) do
900
    question = Repo.get(Question, id)
1✔
901
    Repo.delete(question)
1✔
902
  end
903

904
  @doc """
905
  Public internal api to submit new answers for a question. Possible return values are:
906
  `{:ok, nil}` -> success
907
  `{:error, error}` -> failed. `error` is in the format of `{http_response_code, error message}`
908

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

912
  """
913
  def answer_question(
914
        question = %Question{},
915
        cr = %CourseRegistration{id: cr_id},
916
        raw_answer,
917
        force_submit
918
      ) do
919
    with {:ok, team} <- find_team(question.assessment.id, cr_id),
54✔
920
         {:ok, submission} <- find_or_create_submission(cr, question.assessment),
53✔
921
         {:status, true} <- {:status, force_submit or submission.status != :submitted},
53✔
922
         {:ok, _answer} <- insert_or_update_answer(submission, question, raw_answer, cr_id) do
52✔
923
      update_submission_status_router(submission, question)
43✔
924

925
      {:ok, nil}
926
    else
927
      {:status, _} ->
928
        {:error, {:forbidden, "Assessment submission already finalised"}}
929

930
      {:error, :race_condition} ->
931
        {:error, {:internal_server_error, "Please try again later."}}
932

933
      {:error, :team_not_found} ->
934
        {:error, {:bad_request, "Your existing Team has been deleted!"}}
935

936
      {:error, :invalid_vote} ->
937
        {:error, {:bad_request, "Invalid vote! Vote is not saved."}}
938

939
      _ ->
940
        {:error, {:bad_request, "Missing or invalid parameter(s)"}}
941
    end
942
  end
943

944
  defp find_teams(cr_id) do
945
    query =
32✔
946
      from(t in Team,
32✔
947
        join: tm in assoc(t, :team_members),
948
        where: tm.student_id == ^cr_id
949
      )
950

951
    Repo.all(query)
32✔
952
  end
953

954
  defp find_team(assessment_id, cr_id)
955
       when is_ecto_id(assessment_id) and is_ecto_id(cr_id) do
956
    query =
602✔
957
      from(t in Team,
602✔
958
        where: t.assessment_id == ^assessment_id,
959
        join: tm in assoc(t, :team_members),
960
        where: tm.student_id == ^cr_id,
961
        limit: 1
962
      )
963

964
    assessment_team_size =
602✔
965
      Map.get(
966
        Repo.one(
967
          from(a in Assessment,
968
            where: a.id == ^assessment_id,
969
            select: %{max_team_size: a.max_team_size}
970
          )
971
        ),
972
        :max_team_size,
973
        0
974
      )
975

976
    case assessment_team_size > 1 do
602✔
977
      true ->
978
        case Repo.one(query) do
10✔
979
          nil -> {:error, :team_not_found}
3✔
980
          team -> {:ok, team}
7✔
981
        end
982

983
      # team is nil for individual assessments
984
      false ->
592✔
985
        {:ok, nil}
986
    end
987
  end
988

989
  def get_submission(assessment_id, %CourseRegistration{id: cr_id})
990
      when is_ecto_id(assessment_id) do
991
    {:ok, team} = find_team(assessment_id, cr_id)
364✔
992

993
    case team do
364✔
994
      %Team{} ->
995
        Submission
996
        |> where(assessment_id: ^assessment_id)
997
        |> where(team_id: ^team.id)
1✔
998
        |> join(:inner, [s], a in assoc(s, :assessment))
999
        |> preload([_, a], assessment: a)
1✔
1000
        |> Repo.one()
1✔
1001

1002
      nil ->
1003
        Submission
1004
        |> where(assessment_id: ^assessment_id)
1005
        |> where(student_id: ^cr_id)
363✔
1006
        |> join(:inner, [s], a in assoc(s, :assessment))
1007
        |> preload([_, a], assessment: a)
363✔
1008
        |> Repo.one()
363✔
1009
    end
1010
  end
1011

1012
  def get_submission_by_id(submission_id) when is_ecto_id(submission_id) do
1013
    Submission
1014
    |> where(id: ^submission_id)
1✔
1015
    |> join(:inner, [s], a in assoc(s, :assessment))
1016
    |> preload([_, a], assessment: a)
1✔
1017
    |> Repo.one()
1✔
1018
  end
1019

1020
  def finalise_submission(submission = %Submission{}) do
1021
    with {:status, :attempted} <- {:status, submission.status},
360✔
1022
         {:ok, updated_submission} <- update_submission_status(submission) do
358✔
1023
      # Couple with update_submission_status and update_xp_bonus to ensure notification is sent
1024
      Notifications.write_notification_when_student_submits(submission)
358✔
1025
      # Send email notification to avenger
1026
      %{notification_type: "assessment_submission", submission_id: updated_submission.id}
358✔
1027
      |> Cadet.Workers.NotificationWorker.new()
358✔
1028
      |> Oban.insert()
1029

1030
      # Begin autograding job
1031
      GradingJob.force_grade_individual_submission(updated_submission)
358✔
1032
      update_xp_bonus(submission)
1033

358✔
1034
      {:ok, nil}
1035
    else
1036
      {:status, :attempting} ->
358✔
1037
        {:error, {:bad_request, "Some questions have not been attempted"}}
358✔
1038

1039
      {:status, :submitted} ->
1040
        {:error, {:forbidden, "Assessment has already been submitted"}}
1041

1042
      _ ->
1043
        {:error, {:internal_server_error, "Please try again later."}}
1044
    end
1045
  end
1046

1047
  def unsubmit_submission(
1048
        submission_id,
1049
        cr = %CourseRegistration{id: course_reg_id, role: role}
1050
      )
1051
      when is_ecto_id(submission_id) do
1052
    submission =
1053
      Submission
1054
      |> join(:inner, [s], a in assoc(s, :assessment))
1055
      |> preload([_, a], assessment: a)
1056
      |> Repo.get(submission_id)
1057

1✔
1058
    # allows staff to unsubmit own assessment
1059
    bypass = role in @bypass_closed_roles and submission.student_id == course_reg_id
1060

1✔
1061
    with {:submission_found?, true} <- {:submission_found?, is_map(submission)},
1062
         {:is_open?, true} <- {:is_open?, bypass or is_open?(submission.assessment)},
1063
         {:status, :submitted} <- {:status, submission.status},
1064
         {:allowed_to_unsubmit?, true} <-
1✔
1065
           {:allowed_to_unsubmit?,
1066
            role == :admin or bypass or is_nil(submission.student_id) or
1✔
1067
              Cadet.Accounts.Query.avenger_of?(cr, submission.student_id)},
1✔
1068
         {:is_grading_published?, false} <-
1✔
1069
           {:is_grading_published?, submission.is_grading_published} do
1✔
1070
      Multi.new()
1071
      |> Multi.run(
1✔
UNCOV
1072
        :rollback_submission,
×
1073
        fn _repo, _ ->
1✔
1074
          submission
1✔
1075
          |> Submission.changeset(%{
1076
            status: :attempted,
1077
            xp_bonus: 0,
1078
            unsubmitted_by_id: course_reg_id,
1079
            unsubmitted_at: Timex.now()
1080
          })
1081
          |> Repo.update()
1082
        end
1083
      )
1084
      |> Multi.run(:rollback_answers, fn _repo, _ ->
1085
        Answer
1086
        |> join(:inner, [a], q in assoc(a, :question))
1✔
1087
        |> join(:inner, [a, _], s in assoc(a, :submission))
1088
        |> preload([_, q, s], question: q, submission: s)
1089
        |> where(submission_id: ^submission.id)
1090
        |> Repo.all()
1091
        |> Enum.reduce_while({:ok, nil}, fn answer, acc ->
1✔
1092
          case acc do
1093
            {:error, _} ->
1094
              {:halt, acc}
1✔
1095

1096
            {:ok, _} ->
1✔
UNCOV
1097
              {:cont,
×
1098
               answer
×
1099
               |> Answer.grading_changeset(%{
1100
                 xp: 0,
UNCOV
1101
                 xp_adjustment: 0,
×
1102
                 autograding_status: :none,
1103
                 autograding_results: []
1104
               })
1105
               |> Repo.update()}
1106
          end
1107
        end)
1108
      end)
1109
      |> Repo.transaction()
1110

1111
      case submission.student_id do
1112
        # Team submission, handle notifications for team members
1113
        nil ->
1114
          team = Repo.get(Team, submission.team_id)
1✔
1115

1116
          query =
1✔
1117
            from(t in Team,
1118
              join: tm in TeamMember,
1119
              on: t.id == tm.team_id,
1✔
1120
              join: cr in CourseRegistration,
1121
              on: tm.student_id == cr.id,
1✔
1122
              where: t.id == ^team.id,
1✔
1123
              select: cr.id
1124
            )
1125

1126
          team_members = Repo.all(query)
1127

1✔
1128
          Enum.each(team_members, fn tm_id ->
1129
            Notifications.handle_unsubmit_notifications(
1130
              submission.assessment.id,
1131
              Repo.get(CourseRegistration, tm_id)
1✔
1132
            )
1133
          end)
1✔
1134

2✔
1135
        student_id ->
2✔
1136
          Notifications.handle_unsubmit_notifications(
1137
            submission.assessment.id,
1138
            Repo.get(CourseRegistration, student_id)
1139
          )
1140
      end
UNCOV
1141

×
UNCOV
1142
      # Remove grading notifications for submissions
×
1143
      Notification
1144
      |> where(submission_id: ^submission_id, type: :submitted)
1145
      |> select([n], n.id)
1146
      |> Repo.all()
1147
      |> Notifications.acknowledge(cr)
1148

1149
      {:ok, nil}
1150
    else
1✔
1151
      {:submission_found?, false} ->
1152
        {:error, {:not_found, "Submission not found"}}
1✔
1153

1154
      {:is_open?, false} ->
1155
        {:error, {:forbidden, "Assessment not open"}}
1156

1157
      {:status, :attempting} ->
1158
        {:error, {:bad_request, "Some questions have not been attempted"}}
1159

1160
      {:status, :attempted} ->
1161
        {:error, {:bad_request, "Assessment has not been submitted"}}
1162

1163
      {:allowed_to_unsubmit?, false} ->
1164
        {:error, {:forbidden, "Only Avenger of student or Admin is permitted to unsubmit"}}
1165

1166
      {:is_grading_published?, true} ->
1167
        {:error, {:forbidden, "Grading has not been unpublished"}}
1168

1169
      _ ->
1170
        {:error, {:internal_server_error, "Please try again later."}}
1171
    end
1172
  end
1173

1174
  defp can_publish?(submission_id, cr = %CourseRegistration{id: course_reg_id, role: role}) do
1175
    submission =
1176
      Submission
1177
      |> join(:inner, [s], a in assoc(s, :assessment))
1178
      |> join(:inner, [_, a], c in assoc(a, :config))
1179
      |> preload([_, a, c], assessment: {a, config: c})
UNCOV
1180
      |> Repo.get(submission_id)
×
1181

UNCOV
1182
    # allows staff to unpublish own assessment
×
1183
    bypass = role in @bypass_closed_roles and submission.student_id == course_reg_id
UNCOV
1184

×
1185
    with {:submission_found?, true} <- {:submission_found?, is_map(submission)},
1186
         {:status, :submitted} <- {:status, submission.status},
1187
         {:is_manually_graded?, true} <-
UNCOV
1188
           {:is_manually_graded?, submission.assessment.config.is_manually_graded},
×
1189
         {:fully_graded?, true} <- {:fully_graded?, is_fully_graded?(submission_id)},
UNCOV
1190
         {:allowed_to_publish?, true} <-
×
UNCOV
1191
           {:allowed_to_publish?,
×
UNCOV
1192
            role == :admin or bypass or
×
UNCOV
1193
              Cadet.Accounts.Query.avenger_of?(cr, submission.student_id)} do
×
UNCOV
1194
      {:ok, submission}
×
UNCOV
1195
    end
×
1196
  end
UNCOV
1197

×
UNCOV
1198
  @doc """
×
1199
    Unpublishes grading for a submission and send notification to student.
1200
    Requires admin or staff who is group leader of student.
1201

1202
    Only manually graded assessments can be individually unpublished. We can only
1203
    unpublish all submissions for auto-graded assessments.
1204

1205
    Returns `{:ok, nil}` on success, otherwise `{:error, {status, message}}`.
1206
  """
1207
  def unpublish_grading(submission_id, cr = %CourseRegistration{})
1208
      when is_ecto_id(submission_id) do
1209
    case can_publish?(submission_id, cr) do
1210
      {:ok, submission} ->
1211
        submission
1212
        |> Submission.changeset(%{is_grading_published: false})
1213
        |> Repo.update()
UNCOV
1214

×
1215
        Notifications.handle_unpublish_grades_notifications(
1216
          submission.assessment.id,
1217
          Repo.get(CourseRegistration, submission.student_id)
UNCOV
1218
        )
×
1219

UNCOV
1220
        {:ok, nil}
×
UNCOV
1221

×
UNCOV
1222
      {:submission_found?, false} ->
×
1223
        {:error, {:not_found, "Submission not found"}}
1224

1225
      {:allowed_to_publish?, false} ->
1226
        {:error,
1227
         {:forbidden, "Only Avenger of student or Admin is permitted to unpublish grading"}}
×
1228

1229
      {:is_manually_graded?, false} ->
UNCOV
1230
        {:error,
×
1231
         {:bad_request, "Only manually graded assessments can be individually unpublished"}}
1232

1233
      _ ->
1234
        {:error, {:internal_server_error, "Please try again later."}}
×
1235
    end
1236
  end
1237

1238
  @doc """
×
1239
    Publishes grading for a submission and send notification to student.
1240
    Requires admin or staff who is group leader of student and all answers to be graded.
1241

1242
    Only manually graded assessments can be individually published. We can only
1243
    publish all submissions for auto-graded assessments.
1244

1245
    Returns `{:ok, nil}` on success, otherwise `{:error, {status, message}}`.
1246
  """
1247
  def publish_grading(submission_id, cr = %CourseRegistration{})
1248
      when is_ecto_id(submission_id) do
1249
    case can_publish?(submission_id, cr) do
1250
      {:ok, submission} ->
1251
        submission
1252
        |> Submission.changeset(%{is_grading_published: true})
1253
        |> Repo.update()
UNCOV
1254

×
1255
        Notifications.write_notification_when_published(
1256
          submission.id,
1257
          :published_grading
UNCOV
1258
        )
×
1259

UNCOV
1260
        Notification
×
UNCOV
1261
        |> where(submission_id: ^submission.id, type: :submitted)
×
1262
        |> select([n], n.id)
1263
        |> Repo.all()
1264
        |> Notifications.acknowledge(cr)
1265

UNCOV
1266
        {:ok, nil}
×
UNCOV
1267

×
1268
      {:submission_found?, false} ->
UNCOV
1269
        {:error, {:not_found, "Submission not found"}}
×
1270

1271
      {:status, :attempting} ->
1272
        {:error, {:bad_request, "Some questions have not been attempted"}}
1273

×
1274
      {:status, :attempted} ->
1275
        {:error, {:bad_request, "Assessment has not been submitted"}}
1276

×
1277
      {:allowed_to_publish?, false} ->
1278
        {:error, {:forbidden, "Only Avenger of student or Admin is permitted to publish grading"}}
UNCOV
1279

×
1280
      {:is_manually_graded?, false} ->
1281
        {:error, {:bad_request, "Only manually graded assessments can be individually published"}}
UNCOV
1282

×
1283
      {:fully_graded?, false} ->
1284
        {:error, {:bad_request, "Some answers are not graded"}}
1285

×
1286
      _ ->
1287
        {:error, {:internal_server_error, "Please try again later."}}
1288
    end
×
1289
  end
1290

1291
  @doc """
×
1292
    Publishes grading for a submission and send notification to student.
1293
    This function is used by the auto-grading system to publish grading. Bypasses Course Reg checks.
1294

1295
    Returns `{:ok, nil}` on success, otherwise `{:error, {status, message}}`.
1296
  """
1297
  def publish_grading(submission_id)
1298
      when is_ecto_id(submission_id) do
1299
    submission =
1300
      Submission
1301
      |> join(:inner, [s], a in assoc(s, :assessment))
1302
      |> preload([_, a], assessment: a)
1303
      |> Repo.get(submission_id)
1304

14✔
1305
    with {:submission_found?, true} <- {:submission_found?, is_map(submission)},
1306
         {:status, :submitted} <- {:status, submission.status} do
1307
      submission
14✔
1308
      |> Submission.changeset(%{is_grading_published: true})
1309
      |> Repo.update()
1310

14✔
1311
      Notifications.write_notification_when_published(
14✔
1312
        submission.id,
1313
        :published_grading
1314
      )
14✔
1315

1316
      {:ok, nil}
14✔
1317
    else
14✔
1318
      {:submission_found?, false} ->
1319
        {:error, {:not_found, "Submission not found"}}
1320

1321
      {:status, :attempting} ->
1322
        {:error, {:bad_request, "Some questions have not been attempted"}}
1323

1324
      {:status, :attempted} ->
1325
        {:error, {:bad_request, "Assessment has not been submitted"}}
1326

1327
      _ ->
1328
        {:error, {:internal_server_error, "Please try again later."}}
1329
    end
1330
  end
1331

1332
  @doc """
1333
    Publishes grading for all graded submissions for an assessment and sends notifications to students.
1334
    Requires admin.
1335

1336
    Returns `{:ok, nil}` on success, otherwise `{:error, {status, message}}`.
1337
  """
1338
  def publish_all_graded(publisher = %CourseRegistration{}, assessment_id) do
1339
    if publisher.role == :admin do
1340
      answers_query =
1341
        Answer
1342
        |> group_by([ans], ans.submission_id)
1343
        |> select([ans], %{
1344
          submission_id: ans.submission_id,
2✔
1345
          graded_count: filter(count(ans.id), not is_nil(ans.grader_id)),
2✔
1346
          autograded_count: filter(count(ans.id), ans.autograding_status == :success)
1347
        })
1348

2✔
1349
      question_query =
1350
        Question
1351
        |> group_by([q], q.assessment_id)
1352
        |> join(:inner, [q], a in Assessment, on: q.assessment_id == a.id)
1353
        |> select([q, a], %{
1354
          assessment_id: q.assessment_id,
2✔
1355
          question_count: count(q.id)
1356
        })
2✔
1357

1358
      submission_query =
2✔
1359
        Submission
1360
        |> join(:inner, [s], ans in subquery(answers_query), on: ans.submission_id == s.id)
1361
        |> join(:inner, [s, ans], asst in subquery(question_query),
1362
          on: s.assessment_id == asst.assessment_id
1363
        )
2✔
1364
        |> join(:inner, [s, ans, asst], cr in CourseRegistration, on: s.student_id == cr.id)
1365
        |> where([s, ans, asst, cr], cr.course_id == ^publisher.course_id)
2✔
1366
        |> where(
2✔
1367
          [s, ans, asst, cr],
1368
          asst.question_count == ans.graded_count or asst.question_count == ans.autograded_count
1369
        )
1370
        |> where([s, ans, asst, cr], s.is_grading_published == false)
2✔
1371
        |> where([s, ans, asst, cr], s.assessment_id == ^assessment_id)
1372
        |> select([s, ans, asst, cr], %{
1373
          id: s.id
1374
        })
1375

1376
      submissions = Repo.all(submission_query)
1377

2✔
1378
      Repo.update_all(submission_query, set: [is_grading_published: true])
1379

1380
      Enum.each(submissions, fn submission ->
1381
        Notifications.write_notification_when_published(
2✔
1382
          submission.id,
1383
          :published_grading
2✔
1384
        )
1385
      end)
2✔
1386

2✔
1387
      {:ok, nil}
2✔
1388
    else
1389
      {:error, {:forbidden, "Only Admin is permitted to publish all grades"}}
1390
    end
1391
  end
1392

1393
  @doc """
1394
     Unpublishes grading for all submissions with grades published for an assessment and sends notifications to students.
1395
     Requires admin role.
1396

1397
     Returns `{:ok, nil}` on success, otherwise `{:error, {status, message}}`.
1398
  """
1399

1400
  def unpublish_all(publisher = %CourseRegistration{}, assessment_id) do
1401
    if publisher.role == :admin do
1402
      submission_query =
1403
        Submission
1404
        |> join(:inner, [s], cr in CourseRegistration, on: s.student_id == cr.id)
1405
        |> where([s, cr], cr.course_id == ^publisher.course_id)
1406
        |> where([s, cr], s.is_grading_published == true)
2✔
1407
        |> where([s, cr], s.assessment_id == ^assessment_id)
2✔
1408
        |> select([s, cr], %{
1409
          id: s.id,
1410
          student_id: cr.id
2✔
1411
        })
1412

1413
      submissions = Repo.all(submission_query)
2✔
1414

1415
      Repo.update_all(submission_query, set: [is_grading_published: false])
1416

1417
      Enum.each(submissions, fn submission ->
1418
        Notifications.handle_unpublish_grades_notifications(
2✔
1419
          assessment_id,
1420
          Repo.get(CourseRegistration, submission.student_id)
2✔
1421
        )
1422
      end)
2✔
1423

2✔
1424
      {:ok, nil}
1425
    else
2✔
1426
      {:error, {:forbidden, "Only Admin is permitted to unpublish all grades"}}
1427
    end
1428
  end
1429

1430
  @spec update_submission_status(Submission.t()) ::
1431
          {:ok, Submission.t()} | {:error, Ecto.Changeset.t()}
1432
  defp update_submission_status(submission = %Submission{}) do
1433
    submission
1434
    |> Submission.changeset(%{status: :submitted, submitted_at: Timex.now()})
1435
    |> Repo.update()
1436
  end
1437

1438
  @spec update_xp_bonus(Submission.t()) ::
1439
          {:ok, Submission.t()} | {:error, Ecto.Changeset.t()}
1440
  defp update_xp_bonus(submission = %Submission{id: submission_id}) do
358✔
1441
    # to ensure backwards compatibility
1442
    if submission.xp_bonus == 0 do
1443
      assessment = submission.assessment
1444
      assessment_conifg = Repo.get_by(AssessmentConfig, id: assessment.config_id)
1445

1446
      max_bonus_xp = assessment_conifg.early_submission_xp
1447
      early_hours = assessment_conifg.hours_before_early_xp_decay
362✔
1448

362✔
1449
      ans_xp =
362✔
1450
        Answer
1451
        |> where(submission_id: ^submission_id)
362✔
1452
        |> order_by(:question_id)
362✔
1453
        |> group_by([a], a.id)
1454
        |> select([a], %{
362✔
1455
          # grouping by submission, so s.xp_bonus will be the same, but we need an
1456
          # aggregate function
1457
          total_xp: sum(a.xp) + sum(a.xp_adjustment)
1458
        })
1459

362✔
1460
      total =
1461
        ans_xp
1462
        |> subquery
1463
        |> select([a], %{
1464
          total_xp: sum(a.total_xp)
1465
        })
362✔
1466
        |> Repo.one()
1467

1468
      xp = decimal_to_integer(total.total_xp)
362✔
1469

1470
      cur_time =
1471
        if submission.submitted_at == nil do
1472
          Timex.now()
1473
        else
362✔
1474
          submission.submitted_at
1475
        end
362✔
1476

362✔
1477
      xp_bonus =
360✔
1478
        if xp <= 0 do
1479
          0
2✔
1480
        else
1481
          if Timex.before?(cur_time, Timex.shift(assessment.open_at, hours: early_hours)) do
1482
            max_bonus_xp
362✔
1483
          else
158✔
1484
            # This logic interpolates from max bonus at early hour to 0 bonus at close time
1485
            decaying_hours =
1486
              Timex.diff(assessment.close_at, assessment.open_at, :hours) - early_hours
204✔
1487

2✔
1488
            remaining_hours = Enum.max([0, Timex.diff(assessment.close_at, cur_time, :hours)])
1489
            proportion = if(decaying_hours > 0, do: remaining_hours / decaying_hours, else: 1)
1490
            bonus_xp = round(max_bonus_xp * proportion)
202✔
1491
            Enum.max([0, bonus_xp])
202✔
1492
          end
1493
        end
202✔
1494

202✔
1495
      submission
202✔
1496
      |> Submission.changeset(%{xp_bonus: xp_bonus})
202✔
1497
      |> Repo.update()
1498
    end
1499
  end
1500

1501
  defp update_submission_status_router(submission = %Submission{}, question = %Question{}) do
1502
    case question.type do
362✔
1503
      :voting -> update_contest_voting_submission_status(submission, question)
1504
      :mcq -> update_submission_status(submission, question.assessment)
1505
      :programming -> update_submission_status(submission, question.assessment)
1506
    end
1507
  end
43✔
1508

12✔
1509
  defp update_submission_status(submission = %Submission{}, assessment = %Assessment{}) do
17✔
1510
    model_assoc_count = fn model, assoc, id ->
14✔
1511
      model
1512
      |> where(id: ^id)
1513
      |> join(:inner, [m], a in assoc(m, ^assoc))
1514
      |> select([_, a], count(a.id))
1515
      |> Repo.one()
31✔
1516
    end
1517

62✔
1518
    Multi.new()
1519
    |> Multi.run(:assessment, fn _repo, _ ->
62✔
1520
      {:ok, model_assoc_count.(Assessment, :questions, assessment.id)}
62✔
1521
    end)
1522
    |> Multi.run(:submission, fn _repo, _ ->
1523
      {:ok, model_assoc_count.(Submission, :answers, submission.id)}
1524
    end)
31✔
1525
    |> Multi.run(:update, fn _repo, %{submission: s_count, assessment: a_count} ->
31✔
1526
      if s_count == a_count do
1527
        submission |> Submission.changeset(%{status: :attempted}) |> Repo.update()
31✔
1528
      else
31✔
1529
        {:ok, nil}
1530
      end
1531
    end)
31✔
1532
    |> Repo.transaction()
5✔
1533
  end
1534

1535
  defp update_contest_voting_submission_status(submission = %Submission{}, question = %Question{}) do
1536
    has_nil_entries =
1537
      SubmissionVotes
31✔
1538
      |> where(question_id: ^question.id)
1539
      |> where(voter_id: ^submission.student_id)
1540
      |> where([sv], is_nil(sv.score))
1541
      |> Repo.exists?()
12✔
1542

1543
    unless has_nil_entries do
12✔
1544
      submission |> Submission.changeset(%{status: :attempted}) |> Repo.update()
12✔
1545
    end
12✔
1546
  end
1547

1548
  defp load_contest_voting_entries(
12✔
1549
         questions,
12✔
1550
         %CourseRegistration{role: role, course_id: course_id, id: voter_id},
1551
         assessment
1552
       ) do
1553
    Enum.map(
1554
      questions,
1555
      fn q ->
1556
        if q.type == :voting do
1557
          submission_votes = all_submission_votes_by_question_id_and_voter_id(q.id, voter_id)
1558
          # fetch top 10 contest voting entries with the contest question id
98✔
1559
          question_id = fetch_associated_contest_question_id(course_id, q)
1560

1561
          # fetch top 10 contest coting entries with contest question id based on popular score
803✔
1562
          popular_results =
267✔
1563
            if is_nil(question_id) do
1564
              []
267✔
1565
            else
1566
              if leaderboard_open?(assessment, q) or role in @open_all_assessment_roles do
1567
                fetch_top_popular_score_answers(question_id, 10)
267✔
1568
              else
249✔
1569
                []
1570
              end
1571
            end
18✔
1572

15✔
1573
          leaderboard_results =
1574
            if is_nil(question_id) do
1575
              []
1576
            else
1577
              if leaderboard_open?(assessment, q) or role in @open_all_assessment_roles do
1578
                fetch_top_relative_score_answers(question_id, 10)
267✔
1579
              else
249✔
1580
                []
1581
              end
1582
            end
18✔
1583

15✔
1584
          # populate entries to vote for and leaderboard data into the question
1585
          voting_question =
1586
            q.question
1587
            |> Map.put(:contest_entries, submission_votes)
1588
            |> Map.put(
1589
              :contest_leaderboard,
1590
              leaderboard_results
267✔
1591
            )
267✔
1592
            |> Map.put(
1593
              :popular_leaderboard,
1594
              popular_results
1595
            )
1596

1597
          Map.put(q, :question, voting_question)
1598
        else
1599
          q
1600
        end
1601
      end
1602
    )
267✔
1603
  end
1604

536✔
1605
  defp all_submission_votes_by_question_id_and_voter_id(question_id, voter_id) do
1606
    SubmissionVotes
1607
    |> where([v], v.voter_id == ^voter_id and v.question_id == ^question_id)
1608
    |> join(:inner, [v], s in assoc(v, :submission))
1609
    |> join(:inner, [v, s], a in assoc(s, :answers))
1610
    |> select([v, s, a], %{submission_id: v.submission_id, answer: a.answer, score: v.score})
1611
    |> Repo.all()
1612
  end
267✔
1613

267✔
1614
  # Finds the contest_question_id associated with the given voting_question id
1615
  def fetch_associated_contest_question_id(course_id, voting_question) do
267✔
1616
    contest_number = voting_question.question["contest_number"]
267✔
1617

1618
    if is_nil(contest_number) do
1619
      nil
1620
    else
1621
      Assessment
267✔
1622
      |> where(number: ^contest_number, course_id: ^course_id)
1623
      |> join(:inner, [a], q in assoc(a, :questions))
267✔
1624
      |> order_by([a, q], q.display_order)
1625
      |> select([a, q], q.id)
1626
      |> Repo.one()
1627
    end
267✔
1628
  end
1629

1630
  defp leaderboard_open?(assessment, voting_question) do
267✔
1631
    Timex.before?(
267✔
1632
      Timex.shift(assessment.close_at, hours: voting_question.question["reveal_hours"]),
1633
      Timex.now()
1634
    )
1635
  end
1636

36✔
1637
  @doc """
36✔
1638
  Fetches top answers for the given question, based on the contest relative_score
1639

1640
  Used for contest leaderboard fetching
1641
  """
1642
  def fetch_top_relative_score_answers(question_id, number_of_answers) do
1643
    Answer
1644
    |> where(question_id: ^question_id)
1645
    |> where(
1646
      [a],
1647
      fragment(
1648
        "?->>'code' like ?",
1649
        a.answer,
1650
        "%return%"
1651
      )
1652
    )
1653
    |> order_by(desc: :relative_score)
1654
    |> join(:left, [a], s in assoc(a, :submission))
1655
    |> join(:left, [a, s], student in assoc(s, :student))
1656
    |> join(:inner, [a, s, student], student_user in assoc(student, :user))
1657
    |> where([a, s, student], student.role == "student")
1658
    |> select([a, s, student, student_user], %{
23✔
1659
      submission_id: a.submission_id,
23✔
1660
      answer: a.answer,
23✔
1661
      relative_score: a.relative_score,
1662
      student_name: student_user.name
1663
    })
1664
    |> limit(^number_of_answers)
1665
    |> Repo.all()
1666
  end
1667

1668
  @doc """
1669
  Fetches top answers for the given question, based on the contest popular_score
23✔
1670

23✔
1671
  Used for contest leaderboard fetching
1672
  """
1673
  def fetch_top_popular_score_answers(question_id, number_of_answers) do
1674
    Answer
1675
    |> where(question_id: ^question_id)
1676
    |> where(
1677
      [a],
1678
      fragment(
1679
        "?->>'code' like ?",
1680
        a.answer,
1681
        "%return%"
1682
      )
1683
    )
1684
    |> order_by(desc: :popular_score)
1685
    |> join(:left, [a], s in assoc(a, :submission))
1686
    |> join(:left, [a, s], student in assoc(s, :student))
1687
    |> join(:inner, [a, s, student], student_user in assoc(student, :user))
1688
    |> where([a, s, student], student.role == "student")
1689
    |> select([a, s, student, student_user], %{
15✔
1690
      submission_id: a.submission_id,
15✔
1691
      answer: a.answer,
15✔
1692
      popular_score: a.popular_score,
1693
      student_name: student_user.name
1694
    })
1695
    |> limit(^number_of_answers)
1696
    |> Repo.all()
1697
  end
1698

1699
  @doc """
1700
  Computes rolling leaderboard for contest votes that are still open.
15✔
1701
  """
15✔
1702
  def update_rolling_contest_leaderboards do
1703
    # 115 = 2 hours - 5 minutes is default.
1704
    if Log.log_execution("update_rolling_contest_leaderboards", Duration.from_minutes(115)) do
1705
      Logger.info("Started update_rolling_contest_leaderboards")
1706

1707
      voting_questions_to_update = fetch_active_voting_questions()
1708

1709
      _ =
1✔
1710
        voting_questions_to_update
1✔
1711
        |> Enum.map(fn qn -> compute_relative_score(qn.id) end)
1712

1✔
1713
      Logger.info("Successfully update_rolling_contest_leaderboards")
1714
    end
1✔
1715
  end
1716

1✔
1717
  def fetch_active_voting_questions do
1718
    Question
1✔
1719
    |> join(:left, [q], a in assoc(q, :assessment))
1720
    |> where([q, a], q.type == "voting")
1721
    |> where([q, a], a.is_published)
1722
    |> where([q, a], a.open_at <= ^Timex.now() and a.close_at >= ^Timex.now())
1723
    |> Repo.all()
1724
  end
1725

1726
  @doc """
1727
  Computes final leaderboard for contest votes that have closed.
2✔
1728
  """
2✔
1729
  def update_final_contest_leaderboards do
1730
    # 1435 = 24 hours - 5 minutes
1731
    if Log.log_execution("update_final_contest_leaderboards", Duration.from_minutes(1435)) do
1732
      Logger.info("Started update_final_contest_leaderboards")
1733

1734
      voting_questions_to_update = fetch_voting_questions_due_yesterday()
1735

1736
      _ =
1✔
1737
        voting_questions_to_update
1✔
1738
        |> Enum.map(fn qn -> compute_relative_score(qn.id) end)
1739

1✔
1740
      Logger.info("Successfully update_final_contest_leaderboards")
1741
    end
1✔
1742
  end
1743

1✔
1744
  def fetch_voting_questions_due_yesterday do
1745
    Question
1✔
1746
    |> join(:left, [q], a in assoc(q, :assessment))
1747
    |> where([q, a], q.type == "voting")
1748
    |> where([q, a], a.is_published)
1749
    |> where([q, a], a.open_at <= ^Timex.now())
1750
    |> where(
1751
      [q, a],
1752
      a.close_at < ^Timex.now() and a.close_at >= ^Timex.shift(Timex.now(), days: -1)
1753
    )
1754
    |> Repo.all()
1755
  end
2✔
1756

1757
  @doc """
1758
  Computes the current relative_score of each voting submission answer
1759
  based on current submitted votes.
2✔
1760
  """
1761
  def compute_relative_score(contest_voting_question_id) do
1762
    # query all records from submission votes tied to the question id ->
1763
    # map score to user id ->
1764
    # store as grade ->
1765
    # query grade for contest question id.
1766
    eligible_votes =
1767
      SubmissionVotes
1768
      |> where(question_id: ^contest_voting_question_id)
1769
      |> where([sv], not is_nil(sv.score))
1770
      |> join(:inner, [sv], ans in Answer, on: sv.submission_id == ans.submission_id)
1771
      |> select(
3✔
1772
        [sv, ans],
1773
        %{ans_id: ans.id, score: sv.score, ans: ans.answer["code"]}
1774
      )
3✔
1775
      |> Repo.all()
1776

3✔
1777
    token_divider =
1778
      Question
1779
      |> select([q], q.question["token_divider"])
1780
      |> Repo.get_by(id: contest_voting_question_id)
1781

1782
    entry_scores = map_eligible_votes_to_entry_score(eligible_votes, token_divider)
3✔
1783
    normalized_scores = map_eligible_votes_to_popular_score(eligible_votes, token_divider)
1784

3✔
1785
    entry_scores
1786
    |> Enum.map(fn {ans_id, relative_score} ->
1787
      %Answer{id: ans_id}
3✔
1788
      |> Answer.contest_score_update_changeset(%{
3✔
1789
        relative_score: relative_score
1790
      })
1791
    end)
1792
    |> Enum.map(fn changeset ->
1793
      op_key = "answer_#{changeset.data.id}"
15✔
1794
      Multi.update(Multi.new(), op_key, changeset)
1795
    end)
1796
    |> Enum.reduce(Multi.new(), &Multi.append/2)
1797
    |> Repo.transaction()
1798

15✔
1799
    normalized_scores
15✔
1800
    |> Enum.map(fn {ans_id, popular_score} ->
1801
      %Answer{id: ans_id}
1802
      |> Answer.popular_score_update_changeset(%{
3✔
1803
        popular_score: popular_score
1804
      })
1805
    end)
1806
    |> Enum.map(fn changeset ->
1807
      op_key = "answer_#{changeset.data.id}"
15✔
1808
      Multi.update(Multi.new(), op_key, changeset)
1809
    end)
1810
    |> Enum.reduce(Multi.new(), &Multi.append/2)
1811
    |> Repo.transaction()
1812
  end
15✔
1813

15✔
1814
  defp map_eligible_votes_to_entry_score(eligible_votes, token_divider) do
1815
    # converts eligible votes to the {total cumulative score, number of votes, tokens}
1816
    entry_vote_data =
3✔
1817
      Enum.reduce(eligible_votes, %{}, fn %{ans_id: ans_id, score: score, ans: ans}, tracker ->
1818
        {prev_score, prev_count, _ans_tokens} = Map.get(tracker, ans_id, {0, 0, 0})
1819

1820
        Map.put(
1821
          tracker,
3✔
1822
          ans_id,
1823
          # assume each voter is assigned 10 entries which will make it fair.
75✔
1824
          {prev_score + score, prev_count + 1, Lexer.count_tokens(ans)}
1825
        )
75✔
1826
      end)
1827

1828
    # calculate the score based on formula {ans_id, score}
1829
    Enum.map(
1830
      entry_vote_data,
1831
      fn {ans_id, {sum_of_scores, number_of_voters, tokens}} ->
1832
        {ans_id, calculate_formula_score(sum_of_scores, number_of_voters, tokens, token_divider)}
1833
      end
1834
    )
3✔
1835
  end
1836

15✔
1837
  defp map_eligible_votes_to_popular_score(eligible_votes, token_divider) do
1838
    # converts eligible votes to the {total cumulative score, number of votes, tokens}
1839
    entry_vote_data =
1840
      Enum.reduce(eligible_votes, %{}, fn %{ans_id: ans_id, score: score, ans: ans}, tracker ->
1841
        {prev_score, prev_count, _ans_tokens} = Map.get(tracker, ans_id, {0, 0, 0})
1842

1843
        Map.put(
1844
          tracker,
3✔
1845
          ans_id,
1846
          # assume each voter is assigned 10 entries which will make it fair.
75✔
1847
          {prev_score + score, prev_count + 1, Lexer.count_tokens(ans)}
1848
        )
75✔
1849
      end)
1850

1851
    # calculate the score based on formula {ans_id, score}
1852
    Enum.map(
1853
      entry_vote_data,
1854
      fn {ans_id, {sum_of_scores, number_of_voters, tokens}} ->
1855
        {ans_id,
1856
         calculate_normalized_score(sum_of_scores, number_of_voters, tokens, token_divider)}
1857
      end
3✔
1858
    )
1859
  end
15✔
1860

1861
  # Calculate the score based on formula
1862
  # score(v,t) = v - 2^(t/token_divider) where v is the normalized_voting_score
1863
  # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100
1864
  defp calculate_formula_score(sum_of_scores, number_of_voters, tokens, token_divider) do
1865
    normalized_voting_score =
1866
      calculate_normalized_score(sum_of_scores, number_of_voters, tokens, token_divider)
1867

1868
    normalized_voting_score - :math.pow(2, min(1023.5, tokens / token_divider))
1869
  end
1870

15✔
1871
  # Calculate the normalized score based on formula
1872
  # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100
1873
  defp calculate_normalized_score(sum_of_scores, number_of_voters, _tokens, _token_divider) do
15✔
1874
    sum_of_scores / number_of_voters / 10 * 100
1875
  end
1876

1877
  @doc """
1878
  Function returning submissions under a grader. This function returns only the
1879
  fields that are exposed in the /grading endpoint.
30✔
1880

1881
  The input parameters are the user and query parameters. Query parameters are
1882
  used to filter the submissions.
1883

1884
  The return value is `{:ok, %{"count": count, "data": submissions}}`
1885

1886
  # Parameters
1887
  - `pageSize`: Integer. The number of submissions to return. Default is 10.
1888
  - `offset`: Integer. The number of submissions to skip. Default is 0.
1889
  - `title`: String. Assessment title.
1890
  - `status`: String. Submission status.
1891
  - `isFullyGraded`: Boolean. Whether the submission is fully graded.
1892
  - `isGradingPublished`: Boolean. Whether the grading is published.
1893
  - `group`: Boolean. Only the groups under the grader should be returned.
1894
  - `groupName`: String. Group name.
1895
  - `name`: String. User name.
1896
  - `username`: String. User username.
1897
  - `type`: String. Assessment Config type.
1898
  - `isManuallyGraded`: Boolean. Whether the assessment is manually graded.
1899

1900
  # Implementation
1901
  Uses helper functions to build the filter query. Helper functions are separated by tables in the database.
1902
  """
1903

1904
  @spec submissions_by_grader_for_index(CourseRegistration.t()) ::
1905
          {:ok,
1906
           %{
1907
             :count => integer,
1908
             :data => %{
1909
               :assessments => [any()],
1910
               :submissions => [any()],
1911
               :users => [any()],
1912
               :teams => [any()],
1913
               :team_members => [any()]
1914
             }
1915
           }}
1916
  def submissions_by_grader_for_index(
1917
        grader = %CourseRegistration{course_id: course_id},
1918
        params \\ %{
1919
          "group" => "false",
1920
          "isFullyGraded" => "false",
1921
          "pageSize" => "10",
1922
          "offset" => "0",
1923
          "sortBy" => "",
×
1924
          "sortDirection" => ""
1925
        }
1926
      ) do
1927
    submission_answers_query =
1928
      from(ans in Answer,
1929
        group_by: ans.submission_id,
1930
        select: %{
1931
          submission_id: ans.submission_id,
1932
          xp: sum(ans.xp),
34✔
1933
          xp_adjustment: sum(ans.xp_adjustment),
1934
          graded_count: filter(count(ans.id), not is_nil(ans.grader_id))
1935
        }
1936
      )
1937

1938
    question_answers_query =
1939
      from(q in Question,
1940
        group_by: q.assessment_id,
1941
        join: a in Assessment,
1942
        on: q.assessment_id == a.id,
1943
        select: %{
34✔
1944
          assessment_id: q.assessment_id,
34✔
1945
          question_count: count(q.id),
1946
          title: max(a.title),
1947
          config_id: max(a.config_id)
1948
        }
1949
      )
1950

1951
    query =
1952
      from(s in Submission,
1953
        left_join: ans in subquery(submission_answers_query),
1954
        on: ans.submission_id == s.id,
1955
        as: :ans,
1956
        left_join: asst in subquery(question_answers_query),
34✔
1957
        on: asst.assessment_id == s.assessment_id,
34✔
1958
        as: :asst,
1959
        left_join: cr in CourseRegistration,
1960
        on: s.student_id == cr.id,
1961
        as: :cr,
1962
        left_join: user in User,
1963
        on: user.id == cr.user_id,
1964
        as: :user,
1965
        left_join: group in Group,
1966
        on: cr.group_id == group.id,
1967
        as: :group,
1968
        inner_join: config in AssessmentConfig,
1969
        on: asst.config_id == config.id,
1970
        as: :config,
1971
        where: ^build_user_filter(params),
1972
        where: s.assessment_id in subquery(build_assessment_filter(params, course_id)),
1973
        where: s.assessment_id in subquery(build_assessment_config_filter(params)),
1974
        where: ^build_submission_filter(params),
1975
        where: ^build_course_registration_filter(params, grader),
1976
        limit: ^elem(Integer.parse(Map.get(params, "pageSize", "10")), 0),
1977
        offset: ^elem(Integer.parse(Map.get(params, "offset", "0")), 0),
1978
        select: %{
1979
          id: s.id,
1980
          status: s.status,
1981
          xp_bonus: s.xp_bonus,
1982
          unsubmitted_at: s.unsubmitted_at,
1983
          unsubmitted_by_id: s.unsubmitted_by_id,
1984
          student_id: s.student_id,
1985
          team_id: s.team_id,
1986
          assessment_id: s.assessment_id,
1987
          is_grading_published: s.is_grading_published,
1988
          xp: ans.xp,
1989
          xp_adjustment: ans.xp_adjustment,
1990
          graded_count: ans.graded_count,
1991
          question_count: asst.question_count
1992
        }
1993
      )
1994

1995
    query =
1996
      sort_submission(query, Map.get(params, "sortBy", ""), Map.get(params, "sortDirection", ""))
1997

1998
    query =
1999
      from([s, ans, asst, cr, user, group] in query, order_by: [desc: s.inserted_at, asc: s.id])
2000

34✔
2001
    submissions = Repo.all(query)
2002

2003
    count_query =
34✔
2004
      from(s in Submission,
34✔
2005
        left_join: ans in subquery(submission_answers_query),
2006
        on: ans.submission_id == s.id,
34✔
2007
        as: :ans,
2008
        left_join: asst in subquery(question_answers_query),
34✔
2009
        on: asst.assessment_id == s.assessment_id,
34✔
2010
        as: :asst,
2011
        where: s.assessment_id in subquery(build_assessment_filter(params, course_id)),
2012
        where: s.assessment_id in subquery(build_assessment_config_filter(params)),
2013
        where: ^build_user_filter(params),
2014
        where: ^build_submission_filter(params),
2015
        where: ^build_course_registration_filter(params, grader),
2016
        select: count(s.id)
2017
      )
2018

2019
    count = Repo.one(count_query)
2020

2021
    {:ok, %{count: count, data: generate_grading_summary_view_model(submissions, course_id)}}
2022
  end
2023

2024
  # Given a query from submissions_by_grader_for_index,
34✔
2025
  # sorts it by the relevant field and direction
2026
  # sort_by is a string of either "", "assessmentName", "assessmentType", "studentName",
2027
  # "studentUsername", "groupName", "progressStatus", "xp"
2028
  # sort_direction is a string of either "", "sort-asc", "sort-desc"
2029
  defp sort_submission(query, sort_by, sort_direction) do
2030
    cond do
2031
      sort_direction == "sort-asc" ->
2032
        sort_submission_asc(query, sort_by)
2033

2034
      sort_direction == "sort-desc" ->
2035
        sort_submission_desc(query, sort_by)
34✔
2036

2037
      true ->
3✔
2038
        query
2039
    end
31✔
2040
  end
3✔
2041

2042
  defp sort_submission_asc(query, sort_by) do
28✔
2043
    cond do
28✔
2044
      sort_by == "assessmentName" ->
2045
        from([s, ans, asst, cr, user, group, config] in query,
2046
          order_by: fragment("upper(?)", asst.title)
2047
        )
2048

3✔
2049
      sort_by == "assessmentType" ->
2050
        from([s, ans, asst, cr, user, group, config] in query, order_by: asst.config_id)
1✔
2051

2052
      sort_by == "studentName" ->
2053
        from([s, ans, asst, cr, user, group, config] in query,
2054
          order_by: fragment("upper(?)", user.name)
2✔
2055
        )
1✔
2056

2057
      sort_by == "studentUsername" ->
1✔
2058
        from([s, ans, asst, cr, user, group, config] in query,
×
2059
          order_by: fragment("upper(?)", user.username)
2060
        )
2061

2062
      sort_by == "groupName" ->
1✔
2063
        from([s, ans, asst, cr, user, group, config] in query,
×
2064
          order_by: fragment("upper(?)", group.name)
2065
        )
2066

2067
      sort_by == "progressStatus" ->
1✔
2068
        from([s, ans, asst, cr, user, group, config] in query,
×
2069
          order_by: [
2070
            asc: config.is_manually_graded,
2071
            asc: s.status,
2072
            asc: ans.graded_count - asst.question_count,
1✔
2073
            asc: s.is_grading_published
×
2074
          ]
2075
        )
2076

2077
      sort_by == "xp" ->
2078
        from([s, ans, asst, cr, user, group, config] in query,
2079
          order_by: ans.xp + ans.xp_adjustment
2080
        )
2081

2082
      true ->
1✔
2083
        query
1✔
2084
    end
2085
  end
2086

2087
  defp sort_submission_desc(query, sort_by) do
×
2088
    cond do
×
2089
      sort_by == "assessmentName" ->
2090
        from([s, ans, asst, cr, user, group, config] in query,
2091
          order_by: [desc: fragment("upper(?)", asst.title)]
2092
        )
2093

3✔
2094
      sort_by == "assessmentType" ->
2095
        from([s, ans, asst, cr, user, group, config] in query, order_by: [desc: asst.config_id])
1✔
2096

2097
      sort_by == "studentName" ->
2098
        from([s, ans, asst, cr, user, group, config] in query,
2099
          order_by: [desc: fragment("upper(?)", user.name)]
2✔
2100
        )
1✔
2101

2102
      sort_by == "studentUsername" ->
1✔
2103
        from([s, ans, asst, cr, user, group, config] in query,
×
2104
          order_by: [desc: fragment("upper(?)", user.username)]
2105
        )
2106

2107
      sort_by == "groupName" ->
1✔
2108
        from([s, ans, asst, cr, user, group, config] in query,
×
2109
          order_by: [desc: fragment("upper(?)", group.name)]
2110
        )
2111

2112
      sort_by == "progressStatus" ->
1✔
2113
        from([s, ans, asst, cr, user, group, config] in query,
×
2114
          order_by: [
2115
            desc: config.is_manually_graded,
2116
            desc: s.status,
2117
            desc: ans.graded_count - asst.question_count,
1✔
2118
            desc: s.is_grading_published
×
2119
          ]
2120
        )
2121

2122
      sort_by == "xp" ->
2123
        from([s, ans, asst, cr, user, group, config] in query,
2124
          order_by: [desc: ans.xp + ans.xp_adjustment]
2125
        )
2126

2127
      true ->
1✔
2128
        query
1✔
2129
    end
2130
  end
2131

2132
  defp build_assessment_filter(params, course_id) do
×
2133
    assessments_filters =
×
2134
      Enum.reduce(params, dynamic(true), fn
2135
        {"title", value}, dynamic ->
2136
          dynamic([assessment], ^dynamic and ilike(assessment.title, ^"%#{value}%"))
2137

2138
        {_, _}, dynamic ->
68✔
2139
          dynamic
68✔
2140
      end)
2141

2✔
2142
    from(a in Assessment,
2143
      where: a.course_id == ^course_id,
2144
      where: ^assessments_filters,
146✔
2145
      select: a.id
2146
    )
2147
  end
68✔
2148

2149
  defp build_submission_filter(params) do
2150
    Enum.reduce(params, dynamic(true), fn
2151
      {"status", value}, dynamic ->
2152
        dynamic([submission], ^dynamic and submission.status == ^value)
2153

2154
      {"isFullyGraded", value}, dynamic ->
2155
        dynamic(
68✔
2156
          [ans: ans, asst: asst],
2157
          ^dynamic and asst.question_count == ans.graded_count == ^value
6✔
2158
        )
2159

2160
      {"isGradingPublished", value}, dynamic ->
12✔
2161
        dynamic([submission], ^dynamic and submission.is_grading_published == ^value)
2162

2163
      {_, _}, dynamic ->
2164
        dynamic
2165
    end)
2166
  end
4✔
2167

2168
  defp build_course_registration_filter(params, grader) do
2169
    Enum.reduce(params, dynamic(true), fn
126✔
2170
      {"group", "true"}, dynamic ->
2171
        dynamic(
2172
          [submission],
2173
          (^dynamic and
2174
             submission.student_id in subquery(
68✔
2175
               from(cr in CourseRegistration,
2176
                 join: g in Group,
14✔
2177
                 on: cr.group_id == g.id,
2178
                 where: g.leader_id == ^grader.id,
2179
                 select: cr.id
2180
               )
14✔
2181
             )) or submission.student_id == ^grader.id
2182
        )
2183

14✔
2184
      {"groupName", value}, dynamic ->
2185
        dynamic(
2186
          [submission],
14✔
2187
          ^dynamic and
2188
            submission.student_id in subquery(
2189
              from(cr in CourseRegistration,
2190
                join: g in Group,
4✔
2191
                on: cr.group_id == g.id,
2192
                where: g.name == ^value,
2193
                select: cr.id
2194
              )
4✔
2195
            )
2196
        )
2197

2198
      {_, _}, dynamic ->
2199
        dynamic
2200
    end)
2201
  end
2202

2203
  defp build_user_filter(params) do
2204
    Enum.reduce(params, dynamic(true), fn
130✔
2205
      {"name", value}, dynamic ->
2206
        dynamic(
2207
          [submission],
2208
          ^dynamic and
2209
            submission.student_id in subquery(
68✔
2210
              from(user in User,
2211
                where: ilike(user.name, ^"%#{value}%"),
6✔
2212
                inner_join: cr in CourseRegistration,
2213
                on: user.id == cr.user_id,
2214
                select: cr.id
2215
              )
6✔
2216
            )
6✔
2217
        )
2218

2219
      {"username", value}, dynamic ->
2220
        dynamic(
2221
          [submission],
2222
          ^dynamic and
2223
            submission.student_id in subquery(
2224
              from(user in User,
2225
                where: ilike(user.username, ^"%#{value}%"),
6✔
2226
                inner_join: cr in CourseRegistration,
2227
                on: user.id == cr.user_id,
2228
                select: cr.id
2229
              )
6✔
2230
            )
6✔
2231
        )
2232

2233
      {_, _}, dynamic ->
2234
        dynamic
2235
    end)
2236
  end
2237

2238
  defp build_assessment_config_filter(params) do
2239
    assessment_config_filters =
136✔
2240
      Enum.reduce(params, dynamic(true), fn
2241
        {"type", value}, dynamic ->
2242
          dynamic([assessment_config: config], ^dynamic and config.type == ^value)
2243

2244
        {"isManuallyGraded", value}, dynamic ->
68✔
2245
          dynamic([assessment_config: config], ^dynamic and config.is_manually_graded == ^value)
68✔
2246

2247
        {_, _}, dynamic ->
6✔
2248
          dynamic
2249
      end)
2250

4✔
2251
    from(a in Assessment,
2252
      inner_join: config in AssessmentConfig,
2253
      on: a.config_id == config.id,
138✔
2254
      as: :assessment_config,
2255
      where: ^assessment_config_filters,
2256
      select: a.id
68✔
2257
    )
2258
  end
2259

2260
  defp generate_grading_summary_view_model(submissions, course_id) do
2261
    users =
2262
      CourseRegistration
2263
      |> where([cr], cr.course_id == ^course_id)
2264
      |> join(:inner, [cr], u in assoc(cr, :user))
2265
      |> join(:left, [cr, u], g in assoc(cr, :group))
2266
      |> preload([cr, u, g], user: u, group: g)
34✔
2267
      |> Repo.all()
2268

34✔
2269
    assessment_ids = submissions |> Enum.map(& &1.assessment_id) |> Enum.uniq()
34✔
2270

2271
    assessments =
34✔
2272
      Assessment
2273
      |> where([a], a.id in ^assessment_ids)
2274
      |> join(:left, [a], q in assoc(a, :questions))
34✔
2275
      |> join(:inner, [a], ac in assoc(a, :config))
2276
      |> preload([a, q, ac], questions: q, config: ac)
34✔
2277
      |> Repo.all()
2278

34✔
2279
    team_ids = submissions |> Enum.map(& &1.team_id) |> Enum.uniq()
34✔
2280

2281
    teams =
34✔
2282
      Team
2283
      |> where([t], t.id in ^team_ids)
2284
      |> Repo.all()
34✔
2285

2286
    team_members =
34✔
2287
      TeamMember
2288
      |> where([tm], tm.team_id in ^team_ids)
34✔
2289
      |> Repo.all()
2290

2291
    %{
34✔
2292
      users: users,
2293
      assessments: assessments,
34✔
2294
      submissions: submissions,
2295
      teams: teams,
2296
      team_members: team_members
34✔
2297
    }
2298
  end
2299

2300
  @spec get_answers_in_submission(integer() | String.t()) ::
2301
          {:ok, {[Answer.t()], Assessment.t()}}
2302
          | {:error, {:bad_request, String.t()}}
2303
  def get_answers_in_submission(id) when is_ecto_id(id) do
2304
    answer_query =
2305
      Answer
2306
      |> where(submission_id: ^id)
2307
      |> join(:inner, [a], q in assoc(a, :question))
2308
      |> join(:inner, [_, q], ast in assoc(q, :assessment))
UNCOV
2309
      |> join(:inner, [..., ast], ac in assoc(ast, :config))
×
2310
      |> join(:left, [a, ...], g in assoc(a, :grader))
UNCOV
2311
      |> join(:left, [_, ..., g], gu in assoc(g, :user))
×
UNCOV
2312
      |> join(:inner, [a, ...], s in assoc(a, :submission))
×
UNCOV
2313
      |> join(:left, [_, ..., s], st in assoc(s, :student))
×
UNCOV
2314
      |> join(:left, [..., st], u in assoc(st, :user))
×
UNCOV
2315
      |> join(:left, [..., s, _, _], t in assoc(s, :team))
×
UNCOV
2316
      |> join(:left, [..., t], tm in assoc(t, :team_members))
×
UNCOV
2317
      |> join(:left, [..., tm], tms in assoc(tm, :student))
×
UNCOV
2318
      |> join(:left, [..., tms], tmu in assoc(tms, :user))
×
UNCOV
2319
      |> preload([_, q, ast, ac, g, gu, s, st, u, t, tm, tms, tmu],
×
UNCOV
2320
        question: {q, assessment: {ast, config: ac}},
×
UNCOV
2321
        grader: {g, user: gu},
×
UNCOV
2322
        submission:
×
2323
          {s, student: {st, user: u}, team: {t, team_members: {tm, student: {tms, user: tmu}}}}
UNCOV
2324
      )
×
2325

2326
    answers =
2327
      answer_query
2328
      |> Repo.all()
2329
      |> Enum.sort_by(& &1.question.display_order)
2330
      |> Enum.map(fn ans ->
UNCOV
2331
        if ans.question.type == :voting do
×
2332
          empty_contest_entries = Map.put(ans.question.question, :contest_entries, [])
2333
          empty_popular_leaderboard = Map.put(empty_contest_entries, :popular_leaderboard, [])
UNCOV
2334
          empty_contest_leaderboard = Map.put(empty_popular_leaderboard, :contest_leaderboard, [])
×
2335
          question = Map.put(ans.question, :question, empty_contest_leaderboard)
UNCOV
2336
          Map.put(ans, :question, question)
×
UNCOV
2337
        else
×
UNCOV
2338
          ans
×
UNCOV
2339
        end
×
UNCOV
2340
      end)
×
UNCOV
2341

×
2342
    if answers == [] do
UNCOV
2343
      {:error, {:bad_request, "Submission is not found."}}
×
2344
    else
2345
      assessment_id = Submission |> where(id: ^id) |> select([s], s.assessment_id) |> Repo.one()
2346
      assessment = Assessment |> where(id: ^assessment_id) |> Repo.one()
UNCOV
2347
      {:ok, {answers, assessment}}
×
2348
    end
2349
  end
UNCOV
2350

×
UNCOV
2351
  defp is_fully_graded?(submission_id) do
×
2352
    submission =
2353
      Submission
2354
      |> Repo.get_by(id: submission_id)
2355

2356
    question_count =
UNCOV
2357
      Question
×
2358
      |> where(assessment_id: ^submission.assessment_id)
2359
      |> select([q], count(q.id))
2360
      |> Repo.one()
UNCOV
2361

×
2362
    graded_count =
UNCOV
2363
      Answer
×
UNCOV
2364
      |> where([a], submission_id: ^submission_id)
×
2365
      |> where([a], not is_nil(a.grader_id))
2366
      |> select([a], count(a.id))
UNCOV
2367
      |> Repo.one()
×
2368

2369
    question_count == graded_count
2370
  end
UNCOV
2371

×
2372
  def is_fully_autograded?(submission_id) do
2373
    submission =
UNCOV
2374
      Submission
×
2375
      |> Repo.get_by(id: submission_id)
2376

2377
    question_count =
2378
      Question
56✔
2379
      |> where(assessment_id: ^submission.assessment_id)
2380
      |> select([q], count(q.id))
2381
      |> Repo.one()
2382

56✔
2383
    graded_count =
2384
      Answer
56✔
2385
      |> where([a], submission_id: ^submission_id)
56✔
2386
      |> where([a], a.autograding_status == :success)
2387
      |> select([a], count(a.id))
2388
      |> Repo.one()
56✔
2389

2390
    question_count == graded_count
2391
  end
2392

56✔
2393
  @spec update_grading_info(
2394
          %{submission_id: integer() | String.t(), question_id: integer() | String.t()},
2395
          %{},
56✔
2396
          CourseRegistration.t()
2397
        ) ::
2398
          {:ok, nil}
2399
          | {:error, {:forbidden | :bad_request | :internal_server_error, String.t()}}
2400
  def update_grading_info(
2401
        %{submission_id: submission_id, question_id: question_id},
2402
        attrs,
2403
        cr = %CourseRegistration{id: grader_id}
2404
      )
2405
      when is_ecto_id(submission_id) and is_ecto_id(question_id) do
2406
    attrs = Map.put(attrs, "grader_id", grader_id)
2407

2408
    answer_query =
2409
      Answer
2410
      |> where(submission_id: ^submission_id)
2411
      |> where(question_id: ^question_id)
4✔
2412

2413
    answer_query =
4✔
2414
      answer_query
2415
      |> join(:inner, [a], s in assoc(a, :submission))
2416
      |> preload([_, s], submission: s)
4✔
2417

2418
    answer = Repo.one(answer_query)
4✔
2419

2420
    is_own_submission = grader_id == answer.submission.student_id
2421

4✔
2422
    submission =
2423
      Submission
4✔
2424
      |> join(:inner, [s], a in assoc(s, :assessment))
2425
      |> preload([_, a], assessment: {a, :config})
4✔
2426
      |> Repo.get(submission_id)
2427

4✔
2428
    is_grading_auto_published = submission.assessment.config.is_grading_auto_published
2429

2430
    with {:answer_found?, true} <- {:answer_found?, is_map(answer)},
4✔
2431
         {:status, true} <-
2432
           {:status, answer.submission.status == :submitted or is_own_submission},
2433
         {:valid, changeset = %Ecto.Changeset{valid?: true}} <-
4✔
2434
           {:valid, Answer.grading_changeset(answer, attrs)},
2435
         {:ok, _} <- Repo.update(changeset) do
4✔
2436
      update_xp_bonus(submission)
4✔
2437

4✔
2438
      if is_grading_auto_published and is_fully_graded?(submission_id) do
4✔
2439
        publish_grading(submission_id, cr)
2440
      end
4✔
2441

4✔
2442
      {:ok, nil}
2443
    else
4✔
2444
      {:answer_found?, false} ->
×
2445
        {:error, {:bad_request, "Answer not found or user not permitted to grade."}}
2446

2447
      {:valid, changeset} ->
2448
        {:error, {:bad_request, full_error_messages(changeset)}}
2449

2450
      {:status, _} ->
2451
        {:error, {:method_not_allowed, "Submission is not submitted yet."}}
2452

2453
      {:error, _} ->
2454
        {:error, {:internal_server_error, "Please try again later."}}
2455
    end
2456
  end
2457

2458
  def update_grading_info(
2459
        _,
2460
        _,
2461
        _
2462
      ) do
2463
    {:error, {:forbidden, "User is not permitted to grade."}}
1✔
2464
  end
2465

2466
  @spec force_regrade_submission(integer() | String.t(), CourseRegistration.t()) ::
2467
          {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}}
2468
  def force_regrade_submission(
2469
        submission_id,
2470
        _requesting_user = %CourseRegistration{id: grader_id}
2471
      )
2472
      when is_ecto_id(submission_id) do
2473
    with {:get, sub} when not is_nil(sub) <- {:get, Repo.get(Submission, submission_id)},
2474
         {:status, true} <- {:status, sub.student_id == grader_id or sub.status == :submitted} do
2475
      GradingJob.force_grade_individual_submission(sub, true)
2476
      {:ok, nil}
2477
    else
UNCOV
2478
      {:get, nil} ->
×
UNCOV
2479
        {:error, {:not_found, "Submission not found"}}
×
UNCOV
2480

×
2481
      {:status, false} ->
2482
        {:error, {:bad_request, "Submission not submitted yet"}}
2483
    end
2484
  end
2485

2486
  def force_regrade_submission(_, _) do
2487
    {:error, {:forbidden, "User is not permitted to grade."}}
2488
  end
2489

2490
  @spec force_regrade_answer(
2491
          integer() | String.t(),
×
2492
          integer() | String.t(),
2493
          CourseRegistration.t()
2494
        ) ::
2495
          {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}}
2496
  def force_regrade_answer(
2497
        submission_id,
2498
        question_id,
2499
        _requesting_user = %CourseRegistration{id: grader_id}
2500
      )
2501
      when is_ecto_id(submission_id) and is_ecto_id(question_id) do
2502
    answer =
2503
      Answer
2504
      |> where(submission_id: ^submission_id, question_id: ^question_id)
2505
      |> preload([:question, :submission])
2506
      |> Repo.one()
UNCOV
2507

×
2508
    with {:get, answer} when not is_nil(answer) <- {:get, answer},
2509
         {:status, true} <-
UNCOV
2510
           {:status,
×
2511
            answer.submission.student_id == grader_id or answer.submission.status == :submitted} do
2512
      GradingJob.grade_answer(answer, answer.question, true)
UNCOV
2513
      {:ok, nil}
×
UNCOV
2514
    else
×
2515
      {:get, nil} ->
UNCOV
2516
        {:error, {:not_found, "Answer not found"}}
×
UNCOV
2517

×
2518
      {:status, false} ->
2519
        {:error, {:bad_request, "Submission not submitted yet"}}
2520
    end
2521
  end
2522

2523
  def force_regrade_answer(_, _, _) do
2524
    {:error, {:forbidden, "User is not permitted to grade."}}
2525
  end
2526

2527
  defp find_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do
2528
    {:ok, team} = find_team(assessment.id, cr.id)
×
2529

2530
    submission =
2531
      case team do
2532
        %Team{} ->
2533
          Submission
68✔
2534
          |> where(team_id: ^team.id)
2535
          |> where(assessment_id: ^assessment.id)
68✔
2536
          |> Repo.one()
2537

2538
        nil ->
2539
          Submission
2✔
2540
          |> where(student_id: ^cr.id)
2✔
2541
          |> where(assessment_id: ^assessment.id)
2✔
2542
          |> Repo.one()
2543
      end
2544

2545
    if submission do
66✔
2546
      {:ok, submission}
66✔
2547
    else
66✔
2548
      {:error, nil}
2549
    end
2550
  end
68✔
2551

2552
  # Checks if an assessment is open and published.
2553
  @spec is_open?(Assessment.t()) :: boolean()
2554
  def is_open?(%Assessment{open_at: open_at, close_at: close_at, is_published: is_published}) do
2555
    Timex.between?(Timex.now(), open_at, close_at, inclusive: :start) and is_published
2556
  end
2557

2558
  @spec get_group_grading_summary(integer()) ::
2559
          {:ok, [String.t(), ...], []}
2560
  def get_group_grading_summary(course_id) do
384✔
2561
    subs =
2562
      Answer
2563
      |> join(:left, [ans], s in Submission, on: s.id == ans.submission_id)
2564
      |> join(:left, [ans, s], st in CourseRegistration, on: s.student_id == st.id)
2565
      |> join(:left, [ans, s, st], a in Assessment, on: a.id == s.assessment_id)
UNCOV
2566
      |> join(:inner, [ans, s, st, a], ac in AssessmentConfig, on: ac.id == a.config_id)
×
2567
      |> where(
UNCOV
2568
        [ans, s, st, a, ac],
×
UNCOV
2569
        not is_nil(st.group_id) and s.status == ^:submitted and
×
UNCOV
2570
          ac.show_grading_summary and a.course_id == ^course_id
×
2571
      )
2572
      |> group_by([ans, s, st, a, ac], s.id)
2573
      |> select([ans, s, st, a, ac], %{
2574
        group_id: max(st.group_id),
2575
        config_id: max(ac.id),
2576
        config_type: max(ac.type),
2577
        num_submitted: count(),
UNCOV
2578
        num_ungraded: filter(count(), is_nil(ans.grader_id))
×
2579
      })
2580

2581
    raw_data =
2582
      subs
2583
      |> subquery()
2584
      |> join(:left, [t], g in Group, on: t.group_id == g.id)
2585
      |> join(:left, [t, g], l in CourseRegistration, on: l.id == g.leader_id)
UNCOV
2586
      |> join(:left, [t, g, l], lu in User, on: lu.id == l.user_id)
×
2587
      |> group_by([t, g, l, lu], [t.group_id, t.config_id, t.config_type, g.name, lu.name])
2588
      |> select([t, g, l, lu], %{
UNCOV
2589
        group_name: g.name,
×
UNCOV
2590
        leader_name: lu.name,
×
2591
        config_id: t.config_id,
2592
        config_type: t.config_type,
UNCOV
2593
        ungraded: filter(count(), t.num_ungraded > 0),
×
2594
        submitted: count()
2595
      })
2596
      |> Repo.all()
2597

2598
    showing_configs =
2599
      AssessmentConfig
2600
      |> where([ac], ac.course_id == ^course_id and ac.show_grading_summary)
2601
      |> order_by(:order)
2602
      |> group_by([ac], ac.id)
UNCOV
2603
      |> select([ac], %{
×
2604
        id: ac.id,
2605
        type: ac.type
2606
      })
2607
      |> Repo.all()
UNCOV
2608

×
2609
    data_by_groups =
2610
      raw_data
2611
      |> Enum.reduce(%{}, fn raw, acc ->
2612
        if Map.has_key?(acc, raw.group_name) do
2613
          acc
UNCOV
2614
          |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded)
×
2615
          |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted)
2616
        else
UNCOV
2617
          acc
×
2618
          |> put_in([raw.group_name], %{})
2619
          |> put_in([raw.group_name, "groupName"], raw.group_name)
×
2620
          |> put_in([raw.group_name, "leaderName"], raw.leader_name)
×
2621
          |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded)
2622
          |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted)
UNCOV
2623
        end
×
UNCOV
2624
      end)
×
UNCOV
2625

×
UNCOV
2626
    headings =
×
UNCOV
2627
      showing_configs
×
2628
      |> Enum.reduce([], fn config, acc ->
2629
        acc ++ ["submitted" <> config.type, "ungraded" <> config.type]
2630
      end)
UNCOV
2631

×
2632
    default_row_data =
2633
      headings
UNCOV
2634
      |> Enum.reduce(%{}, fn heading, acc ->
×
2635
        put_in(acc, [heading], 0)
2636
      end)
UNCOV
2637

×
2638
    rows = data_by_groups |> Enum.map(fn {_k, row} -> Map.merge(default_row_data, row) end)
2639
    cols = ["groupName", "leaderName"] ++ headings
UNCOV
2640

×
2641
    {:ok, cols, rows}
2642
  end
UNCOV
2643

×
UNCOV
2644
  defp create_empty_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do
×
2645
    {:ok, team} = find_team(assessment.id, cr.id)
UNCOV
2646

×
2647
    case team do
2648
      %Team{} ->
2649
        %Submission{}
2650
        |> Submission.changeset(%{team: team, assessment: assessment})
17✔
2651
        |> Repo.insert()
2652
        |> case do
17✔
2653
          {:ok, submission} -> {:ok, submission}
2654
        end
2655

2656
      nil ->
2657
        %Submission{}
1✔
2658
        |> Submission.changeset(%{student: cr, assessment: assessment})
1✔
2659
        |> Repo.insert()
2660
        |> case do
2661
          {:ok, submission} -> {:ok, submission}
2662
        end
2663
    end
2664
  end
2665

16✔
2666
  defp find_or_create_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do
16✔
2667
    case find_submission(cr, assessment) do
2668
      {:ok, submission} -> {:ok, submission}
2669
      {:error, _} -> create_empty_submission(cr, assessment)
2670
    end
2671
  end
2672

58✔
2673
  defp insert_or_update_answer(
41✔
2674
         submission = %Submission{},
17✔
2675
         question = %Question{},
2676
         raw_answer,
2677
         course_reg_id
2678
       ) do
2679
    answer_content = build_answer_content(raw_answer, question.type)
2680

2681
    if question.type == :voting do
2682
      insert_or_update_voting_answer(submission.id, course_reg_id, question.id, answer_content)
2683
    else
2684
      answer_changeset =
52✔
2685
        %Answer{}
2686
        |> Answer.changeset(%{
52✔
2687
          answer: answer_content,
15✔
2688
          question_id: question.id,
2689
          submission_id: submission.id,
37✔
2690
          type: question.type,
2691
          last_modified_at: Timex.now()
2692
        })
2693

37✔
2694
      Repo.insert(
37✔
2695
        answer_changeset,
37✔
2696
        on_conflict: [
2697
          set: [answer: get_change(answer_changeset, :answer), last_modified_at: Timex.now()]
2698
        ],
2699
        conflict_target: [:submission_id, :question_id]
37✔
2700
      )
2701
    end
2702
  end
2703

2704
  def has_last_modified_answer?(
2705
        question = %Question{},
2706
        cr = %CourseRegistration{id: cr_id},
2707
        last_modified_at,
2708
        force_submit
2709
      ) do
2710
    with {:ok, submission} <- find_or_create_submission(cr, question.assessment),
2711
         {:status, true} <- {:status, force_submit or submission.status != :submitted},
2712
         {:ok, is_modified} <- answer_last_modified?(submission, question, last_modified_at) do
2713
      {:ok, is_modified}
2714
    else
2715
      {:status, _} ->
2✔
2716
        {:error, {:forbidden, "Assessment submission already finalised"}}
2✔
2717

2✔
2718
      {:error, :race_condition} ->
2719
        {:error, {:internal_server_error, "Please try again later."}}
2720

2721
      {:error, :invalid_vote} ->
2722
        {:error, {:bad_request, "Invalid vote! Vote is not saved."}}
2723

2724
      _ ->
2725
        {:error, {:bad_request, "Missing or invalid parameter(s)"}}
2726
    end
2727
  end
2728

2729
  defp answer_last_modified?(
2730
         submission = %Submission{},
2731
         question = %Question{},
2732
         last_modified_at
2733
       ) do
2734
    case Repo.get_by(Answer, submission_id: submission.id, question_id: question.id) do
2735
      %Answer{last_modified_at: existing_last_modified_at} ->
2736
        existing_iso8601 = DateTime.to_iso8601(existing_last_modified_at)
2737

2738
        if existing_iso8601 == last_modified_at do
2739
          {:ok, false}
2✔
2740
        else
2741
          {:ok, true}
1✔
2742
        end
2743

1✔
2744
      nil ->
2745
        {:ok, false}
2746
    end
2747
  end
2748

2749
  def insert_or_update_voting_answer(submission_id, course_reg_id, question_id, answer_content) do
1✔
2750
    set_score_to_nil =
2751
      SubmissionVotes
2752
      |> where(voter_id: ^course_reg_id, question_id: ^question_id)
2753

2754
    voting_multi =
2755
      Multi.new()
15✔
2756
      |> Multi.update_all(:set_score_to_nil, set_score_to_nil, set: [score: nil])
2757

15✔
2758
    answer_content
2759
    |> Enum.with_index(1)
15✔
2760
    |> Enum.reduce(voting_multi, fn {entry, index}, multi ->
2761
      multi
2762
      |> Multi.run("update#{index}", fn _repo, _ ->
2763
        SubmissionVotes
2764
        |> Repo.get_by(
2765
          voter_id: course_reg_id,
2766
          submission_id: entry.submission_id
2767
        )
15✔
2768
        |> SubmissionVotes.changeset(%{score: entry.score})
2769
        |> Repo.insert_or_update()
2770
      end)
2771
    end)
15✔
2772
    |> Multi.run("insert into answer table", fn _repo, _ ->
2773
      Answer
15✔
2774
      |> Repo.get_by(submission_id: submission_id, question_id: question_id)
15✔
2775
      |> case do
2776
        nil ->
2777
          Repo.insert(%Answer{
2778
            answer: %{completed: true},
2779
            submission_id: submission_id,
2780
            question_id: question_id,
12✔
2781
            type: :voting
2782
          })
9✔
2783

2784
        _ ->
2785
          {:ok, nil}
2786
      end
2787
    end)
2788
    |> Repo.transaction()
2789
    |> case do
3✔
2790
      {:ok, _result} -> {:ok, nil}
2791
      {:error, _name, _changeset, _error} -> {:error, :invalid_vote}
2792
    end
2793
  end
2794

15✔
2795
  defp build_answer_content(raw_answer, question_type) do
12✔
2796
    case question_type do
3✔
2797
      :mcq ->
2798
        %{choice_id: raw_answer}
2799

2800
      :programming ->
2801
        %{code: raw_answer}
52✔
2802

2803
      :voting ->
20✔
2804
        raw_answer
2805
        |> Enum.map(fn ans ->
2806
          for {key, value} <- ans, into: %{}, do: {String.to_existing_atom(key), value}
17✔
2807
        end)
2808
    end
2809
  end
2810
end
15✔
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