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

source-academy / backend / 415c28e8ee8b88e6acc822aca1500269b5f844ad

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

push

github

web-flow
Leaderboard (#1238)

* added 'enable_leaderboard' columns in courses table

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

* added 'top_leaderboard_display' columns in courses table

* added 'all_user_total_xp' function for leaderboard display

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

* added contest scores fetching and contest score calculation

* Refactor query execution in assessments module for improved readability

* added functions to fetch contest scoring and voting

* changes to default values

* updated tests

* Fixed xp fetching for all users

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

* Added automatic XP assignment for winning contest entries

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

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

* No tiebreak for contest scoring

* Refactor contest scoring endpoints for authentication errors

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

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

* Temporary Assessment Workspace leaderboard fix for testing

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

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

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

* temporary fix for STePS

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

* Post-STePS f... (continued)

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

7 existing lines in 2 files now uncovered.

3216 of 3514 relevant lines covered (91.52%)

7703.7 hits per line

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

89.38
/lib/cadet_web/controllers/assessments_controller.ex
1
defmodule CadetWeb.AssessmentsController do
2
  use CadetWeb, :controller
3

4
  use PhoenixSwagger
5

6
  import Ecto.Query, only: [where: 2]
7

8
  alias Cadet.{Assessments, Repo}
9
  alias Cadet.Assessments.Question
10
  alias CadetWeb.AssessmentsHelpers
11

12
  # These roles can save and finalise answers for closed assessments and
13
  # submitted answers
14
  @bypass_closed_roles ~w(staff admin)a
15

16
  def submit(conn, %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do
17
    cr = conn.assigns.course_reg
363✔
18

19
    with {:submission, submission} when not is_nil(submission) <-
363✔
20
           {:submission, Assessments.get_submission(assessment_id, cr)},
21
         {:is_open?, true} <-
361✔
22
           {:is_open?,
23
            cr.role in @bypass_closed_roles or Assessments.is_open?(submission.assessment)},
361✔
24
         {:ok, _nil} <- Assessments.finalise_submission(submission) do
360✔
25
      text(conn, "OK")
358✔
26
    else
27
      {:submission, nil} ->
28
        conn
29
        |> put_status(:not_found)
30
        |> text("Submission not found")
2✔
31

32
      {:is_open?, false} ->
33
        conn
34
        |> put_status(:forbidden)
35
        |> text("Assessment not open")
1✔
36

37
      {:error, {status, message}} ->
38
        conn
39
        |> put_status(status)
40
        |> text(message)
2✔
41
    end
42
  end
43

44
  def index(conn, _) do
45
    cr = conn.assigns.course_reg
16✔
46
    {:ok, assessments} = Assessments.all_assessments(cr)
16✔
47
    assessments = Assessments.format_all_assessments(assessments)
16✔
48
    render(conn, "index.json", assessments: assessments)
16✔
49
  end
50

51
  def show(conn, %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do
52
    cr = conn.assigns.course_reg
92✔
53

54
    case Assessments.assessment_with_questions_and_answers(assessment_id, cr) do
92✔
55
      {:ok, assessment} ->
56
        assessment = Assessments.format_assessment_with_questions_and_answers(assessment)
87✔
57
        render(conn, "show.json", assessment: assessment)
87✔
58

59
      {:error, {status, message}} ->
60
        send_resp(conn, status, message)
5✔
61
    end
62
  end
63

64
  def unlock(conn, %{"assessmentid" => assessment_id, "password" => password})
65
      when is_ecto_id(assessment_id) do
66
    cr = conn.assigns.course_reg
9✔
67

68
    case Assessments.assessment_with_questions_and_answers(assessment_id, cr, password) do
9✔
69
      {:ok, assessment} -> render(conn, "show.json", assessment: assessment)
6✔
70
      {:error, {status, message}} -> send_resp(conn, status, message)
3✔
71
    end
72
  end
73

74
  def combined_total_xp_for_all_users(conn, %{"course_id" => course_id}) do
NEW
75
    users_with_xp = Assessments.all_user_total_xp(course_id)
×
NEW
76
    json(conn, %{users: users_with_xp.users})
×
77
  end
78

79
  def paginated_total_xp_for_leaderboard_display(conn, %{"course_id" => course_id}) do
NEW
80
    offset = String.to_integer(conn.params["offset"] || "0")
×
NEW
81
    page_size = String.to_integer(conn.params["page_size"] || "25")
×
NEW
82
    paginated_display = Assessments.all_user_total_xp(course_id, offset, page_size)
×
NEW
83
    json(conn, paginated_display)
×
84
  end
85

86
  def get_score_leaderboard(conn, %{
87
        "assessmentid" => assessment_id,
88
        "course_id" => course_id
89
      }) do
90
    visible_entries = String.to_integer(conn.params["visible_entries"] || "10")
1✔
91
    voting_id = Assessments.fetch_contest_voting_assesment_id(assessment_id)
1✔
92

93
    voting_questions =
1✔
94
      Question
95
      |> where(type: :voting)
96
      |> where(assessment_id: ^assessment_id)
1✔
97
      |> Repo.one()
98
      |> case do
99
        nil ->
100
          Question
101
          |> where(type: :voting)
NEW
102
          |> where(assessment_id: ^voting_id)
×
NEW
103
          |> Repo.one()
×
104

105
        question ->
106
          question
1✔
107
      end
108

109
    contest_id = Assessments.fetch_associated_contest_question_id(course_id, voting_questions)
1✔
110

111
    result =
1✔
112
      contest_id
113
      |> Assessments.fetch_top_relative_score_answers(visible_entries)
114
      |> Enum.map(fn entry ->
115
        updated_entry = %{
5✔
116
          entry
117
          | answer: entry.answer["code"]
5✔
118
        }
119

120
        AssessmentsHelpers.build_contest_leaderboard_entry(updated_entry)
5✔
121
      end)
122

123
    json(conn, %{leaderboard: result, voting_id: voting_id})
1✔
124
  end
125

126
  def get_popular_leaderboard(conn, %{
127
        "assessmentid" => assessment_id,
128
        "course_id" => course_id
129
      }) do
130
    visible_entries = String.to_integer(conn.params["visible_entries"] || "10")
1✔
131
    voting_id = Assessments.fetch_contest_voting_assesment_id(assessment_id)
1✔
132

133
    voting_questions =
1✔
134
      Question
135
      |> where(type: :voting)
136
      |> where(assessment_id: ^assessment_id)
1✔
137
      |> Repo.one()
138
      |> case do
139
        nil ->
140
          Question
141
          |> where(type: :voting)
NEW
142
          |> where(assessment_id: ^voting_id)
×
NEW
143
          |> Repo.one()
×
144

145
        question ->
146
          question
1✔
147
      end
148

149
    contest_id = Assessments.fetch_associated_contest_question_id(course_id, voting_questions)
1✔
150

151
    result =
1✔
152
      contest_id
153
      |> Assessments.fetch_top_popular_score_answers(visible_entries)
154
      |> Enum.map(fn entry ->
155
        updated_entry = %{
5✔
156
          entry
157
          | answer: entry.answer["code"]
5✔
158
        }
159

160
        AssessmentsHelpers.build_popular_leaderboard_entry(updated_entry)
5✔
161
      end)
162

163
    json(conn, %{leaderboard: result, voting_id: voting_id})
1✔
164
  end
165

166
  def get_all_contests(conn, %{"course_id" => course_id}) do
NEW
167
    contests = Assessments.fetch_all_contests(course_id)
×
NEW
168
    json(conn, contests)
×
169
  end
170

171
  swagger_path :submit do
1✔
172
    post("/courses/{course_id}/assessments/{assessmentId}/submit")
173
    summary("Finalise submission for an assessment")
174
    security([%{JWT: []}])
175

176
    parameters do
177
      assessmentId(:path, :integer, "Assessment ID", required: true)
178
    end
179

180
    response(200, "OK")
181

182
    response(
183
      400,
184
      "Invalid parameters or incomplete submission (submission with unanswered questions)"
185
    )
186

187
    response(403, "User not permitted to answer questions or assessment not open")
188
    response(404, "Submission not found")
189
  end
190

191
  swagger_path :index do
1✔
192
    get("/courses/{course_id}/assessments")
193

194
    summary("Get a list of all assessments")
195

196
    security([%{JWT: []}])
197

198
    produces("application/json")
199

200
    response(200, "OK", Schema.ref(:AssessmentsList))
201
    response(401, "Unauthorised")
202
  end
203

204
  swagger_path :show do
1✔
205
    get("/courses/{course_id}/assessments/{assessmentId}")
206

207
    summary("Get information about one particular assessment")
208

209
    security([%{JWT: []}])
210

211
    consumes("application/json")
212
    produces("application/json")
213

214
    parameters do
215
      assessmentId(:path, :integer, "Assessment ID", required: true)
216
    end
217

218
    response(200, "OK", Schema.ref(:Assessment))
219
    response(400, "Missing parameter(s) or invalid assessmentId")
220
    response(401, "Unauthorised")
221
  end
222

223
  swagger_path :unlock do
1✔
224
    post("/courses/{course_id}/assessments/{assessmentId}/unlock")
225

226
    summary("Unlocks a password-protected assessment and returns its information")
227

228
    security([%{JWT: []}])
229

230
    consumes("application/json")
231
    produces("application/json")
232

233
    parameters do
234
      assessmentId(:path, :integer, "Assessment ID", required: true)
235

236
      password(:body, Schema.ref(:UnlockAssessmentPayload), "Password to unlock assessment",
237
        required: true
238
      )
239
    end
240

241
    response(200, "OK", Schema.ref(:Assessment))
242
    response(400, "Missing parameter(s) or invalid assessmentId")
243
    response(401, "Unauthorised")
244
    response(403, "Password incorrect")
245
  end
246

247
  def swagger_definitions do
248
    %{
1✔
249
      AssessmentsList:
250
        swagger_schema do
1✔
251
          description("A list of all assessments")
252
          type(:array)
253
          items(Schema.ref(:AssessmentOverview))
254
        end,
255
      AssessmentOverview:
256
        swagger_schema do
1✔
257
          properties do
1✔
258
            id(:integer, "The assessment ID", required: true)
259
            title(:string, "The title of the assessment", required: true)
260

261
            config(Schema.ref(:AssessmentConfig), "The assessment config", required: true)
262

263
            shortSummary(:string, "Short summary", required: true)
264

265
            number(
266
              :string,
267
              "The string identifying the relative position of this assessment",
268
              required: true
269
            )
270

271
            story(:string, "The story that should be shown for this assessment")
272
            reading(:string, "The reading for this assessment")
273
            openAt(:string, "The opening date", format: "date-time", required: true)
274
            closeAt(:string, "The closing date", format: "date-time", required: true)
275

276
            status(
277
              Schema.ref(:AssessmentStatus),
278
              "One of 'not_attempted/attempting/attempted/submitted' indicating whether the assessment has been attempted by the current user",
279
              required: true
280
            )
281

282
            hasTokenCounter(:boolean, "Does the assessment have Token Counter enabled?")
283

284
            maxXp(
285
              :integer,
286
              "The maximum XP for this assessment",
287
              required: true
288
            )
289

290
            xp(:integer, "The XP earned for this assessment", required: true)
291

292
            coverImage(:string, "The URL to the cover picture", required: true)
293

294
            private(:boolean, "Is this an private assessment?", required: true)
295

296
            isPublished(:boolean, "Is the assessment published?", required: true)
297

298
            questionCount(:integer, "The number of questions in this assessment", required: true)
299

300
            gradedCount(
301
              :integer,
302
              "The number of answers in the submission which have been graded",
303
              required: true
304
            )
305

306
            maxTeamSize(:integer, "The maximum team size allowed", required: true)
1✔
307
          end
308
        end,
309
      Assessment:
310
        swagger_schema do
1✔
311
          properties do
1✔
312
            id(:integer, "The assessment ID", required: true)
313
            title(:string, "The title of the assessment", required: true)
314

315
            config(Schema.ref(:AssessmentConfig), "The assessment config", required: true)
316

317
            number(
318
              :string,
319
              "The string identifying the relative position of this assessment",
320
              required: true
321
            )
322

323
            story(:string, "The story that should be shown for this assessment")
324
            reading(:string, "The reading for this assessment")
325
            longSummary(:string, "Long summary", required: true)
326
            hasTokenCounter(:boolean, "Does the assessment have Token Counter enabled?")
327
            missionPDF(:string, "The URL to the assessment pdf")
328

329
            questions(Schema.ref(:Questions), "The list of questions for this assessment")
1✔
330
          end
331
        end,
332
      AssessmentConfig:
333
        swagger_schema do
1✔
334
          description("Assessment config")
335
          type(:string)
336
          enum([:mission, :sidequest, :path, :contest, :practical])
337
        end,
338
      AssessmentStatus:
339
        swagger_schema do
1✔
340
          type(:string)
341
          enum([:not_attempted, :attempting, :attempted, :submitted])
342
        end,
343
      Questions:
344
        swagger_schema do
1✔
345
          description("A list of questions")
346
          type(:array)
347
          items(Schema.ref(:Question))
348
        end,
349
      Question:
350
        swagger_schema do
1✔
351
          properties do
1✔
352
            id(:integer, "The question ID", required: true)
353
            type(:string, "The question type (mcq/programming)", required: true)
354
            content(:string, "The question content", required: true)
355

356
            choices(
357
              Schema.new do
1✔
358
                type(:array)
359
                items(Schema.ref(:MCQChoice))
1✔
360
              end,
361
              "MCQ choices if question type is mcq"
362
            )
363

364
            solution(:integer, "Solution to a mcq question if it belongs to path assessment")
365

366
            answer(
367
              # Note: this is technically an invalid type in Swagger/OpenAPI 2.0,
368
              # but represents that a string or integer could be returned.
369
              :string_or_integer,
370
              "Previous answer for this question (string/int) depending on question type",
371
              required: true
372
            )
373

374
            library(
375
              Schema.ref(:Library),
376
              "The library used for this question"
377
            )
378

379
            prepend(:string, "Prepend program for programming questions")
380
            solutionTemplate(:string, "Solution template for programming questions")
381
            postpend(:string, "Postpend program for programming questions")
382

383
            testcases(
384
              Schema.new do
1✔
385
                type(:array)
386
                items(Schema.ref(:Testcase))
1✔
387
              end,
388
              "Testcase programs for programming questions"
389
            )
390

391
            grader(Schema.ref(:GraderInfo))
392

393
            gradedAt(:string, "Last graded at", format: "date-time", required: false)
394

395
            xp(:integer, "Final XP given to this question. Only provided for students.")
396
            grade(:integer, "Final grade given to this question. Only provided for students.")
397
            comments(:string, "String of comments given to a student's answer", required: false)
398

399
            maxGrade(
400
              :integer,
401
              "The max grade for this question",
402
              required: true
403
            )
404

405
            maxXp(
406
              :integer,
407
              "The max xp for this question",
408
              required: true
409
            )
410

411
            autogradingStatus(Schema.ref(:AutogradingStatus), "The status of the autograder")
412

413
            autogradingResults(
1✔
414
              Schema.new do
1✔
415
                type(:array)
416
                items(Schema.ref(:AutogradingResult))
1✔
417
              end
418
            )
419
          end
420
        end,
421
      MCQChoice:
422
        swagger_schema do
1✔
423
          properties do
1✔
424
            content(:string, "The choice content", required: true)
425
            hint(:string, "The hint", required: true)
1✔
426
          end
427
        end,
428
      ExternalLibrary:
429
        swagger_schema do
1✔
430
          properties do
1✔
431
            name(:string, "Name of the external library", required: true)
432

433
            symbols(
1✔
434
              Schema.new do
1✔
435
                type(:array)
436

437
                items(
1✔
438
                  Schema.new do
1✔
439
                    type(:string)
1✔
440
                  end
441
                )
442
              end
443
            )
444
          end
445
        end,
446
      Library:
447
        swagger_schema do
1✔
448
          properties do
1✔
449
            chapter(:integer)
450

451
            globals(
452
              Schema.new do
1✔
453
                type(:array)
454

455
                items(
1✔
456
                  Schema.new do
1✔
457
                    type(:string)
1✔
458
                  end
459
                )
460
              end
461
            )
462

463
            external(
1✔
464
              Schema.ref(:ExternalLibrary),
465
              "The external library for this question"
466
            )
467
          end
468
        end,
469
      Testcase:
470
        swagger_schema do
1✔
471
          properties do
1✔
472
            answer(:string)
473
            score(:integer)
474
            program(:string)
475
            type(Schema.ref(:TestcaseType), "One of public/opaque/secret")
1✔
476
          end
477
        end,
478
      TestcaseType:
479
        swagger_schema do
1✔
480
          type(:string)
481
          enum([:public, :opaque, :secret])
482
        end,
483
      AutogradingResult:
484
        swagger_schema do
1✔
485
          properties do
1✔
486
            resultType(Schema.ref(:AutogradingResultType), "One of pass/fail/error")
487
            expected(:string)
488
            actual(:string)
1✔
489
          end
490
        end,
491
      AutogradingResultType:
492
        swagger_schema do
1✔
493
          type(:string)
494
          enum([:pass, :fail, :error])
495
        end,
496
      AutogradingStatus:
497
        swagger_schema do
1✔
498
          type(:string)
499
          enum([:none, :processing, :success, :failed])
500
        end,
501
      Leaderboard:
502
        swagger_schema do
1✔
503
          description("A list of top entries for leaderboard")
504
          type(:array)
505
          items(Schema.ref(:ContestEntries))
506
        end,
507
      ContestEntries:
508
        swagger_schema do
1✔
509
          properties do
1✔
510
            student_name(:string, "Name of the student", required: true)
511
            answer(:string, "The code that the student submitted", required: true)
512
            final_score(:float, "The score that the student obtained", required: true)
1✔
513
          end
514
        end,
515

516
      # Schemas for payloads to modify data
517
      UnlockAssessmentPayload:
518
        swagger_schema do
1✔
519
          properties do
1✔
520
            password(:string, "Password", required: true)
1✔
521
          end
522
        end
523
    }
524
  end
525
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc