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

source-academy / backend / ad975fd01013a530bdf20eb6afc4fb564b4c7a99

01 Aug 2026 04:43AM UTC coverage: 87.459% (+0.01%) from 87.445%
ad975fd01013a530bdf20eb6afc4fb564b4c7a99

push

github

web-flow
Upgrade most dependencies and modernize codebase (#1363)

* Initial implementation for upgrading most dependencies

* Fix format

* Fix credo raw value pipe chain

* Fix more credo issues

* Fix dialyzer issues

* Fix tests

* Fix bugs as identified by Codex

* Fix credo issues

* Fix invalid migration

* Fix dialyzer

* Read CORS origins from config at compile time

Remove the dead `Code.ensure_loaded?(__MODULE__)` guard, which always
evaluated to false during the endpoint's own compilation and forced
`origins` to "*". Read the config directly with compile_env.

Addresses PR review comments.

* Use :day/:hour units in DateTime.add calls

DateTime.add/4 supports :day and :hour units directly (Elixir >= 1.14),
so drop the manual second multiplications for readability.

Addresses PR review comments.

* Remove unused variables and redundant computation

- teams.ex: drop the unused unique-id count (pure computation, no effect)
- ai_comments_helpers.ex: return the encrypted string directly
- generate_ai_comments.ex: drop the unused api-key parameter and its caller arg

Addresses PR review comments.

* Report failed autograder jobs as failures to Oban

handle_failure/4 returned :ok, which made Oban mark the job as completed
even though the Lambda invocation failed. Return {:error, message} so the
job is recorded as discarded (max_attempts: 1) and stays visible in Oban
telemetry. The failed result is still enqueued beforehand, so the answer
is updated as before.

Addresses PR review comments.

* Verify course ownership for contest score/XP endpoints

calculate_contest_score/2 and dispatch_contest_xp/2 looked up the voting
question by assessment id only, ignoring the course id in the path. A staff
member of one course could therefore trigger score calculation or XP
dispatch on another course's assessment. Guard both with the existing
is_same_course/2 check (returning 403 on mismatch), matching delete/2, and
extract the shared lookup into a helper.

Addresses ... (continued)

81 of 103 new or added lines in 17 files covered. (78.64%)

5 existing lines in 4 files now uncovered.

3996 of 4569 relevant lines covered (87.46%)

6891.59 hits per line

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

70.93
/lib/cadet_web/admin_controllers/admin_assessments_controller.ex
1
defmodule CadetWeb.AdminAssessmentsController do
2
  use CadetWeb, :controller
3

4
  use PhoenixSwagger
5

6
  import Ecto.Query, only: [where: 2]
7
  import Cadet.Updater.XMLParser, only: [parse_xml: 4]
8

9
  alias Cadet.Assessments.{Question, Assessment}
10
  alias Cadet.{Assessments, Repo}
11
  alias Cadet.Accounts.CourseRegistration
12

13
  def index(conn, %{"course_reg_id" => course_reg_id}) do
14
    course_reg = Repo.get(CourseRegistration, course_reg_id)
2✔
15
    {:ok, assessments} = Assessments.all_assessments(course_reg)
2✔
16
    assessments = Assessments.format_all_assessments(assessments)
2✔
17
    render(conn, "index.json", assessments: assessments)
2✔
18
  end
19

20
  def get_assessment(conn, %{"course_reg_id" => course_reg_id, "assessmentid" => assessment_id})
21
      when is_ecto_id(assessment_id) do
22
    course_reg = Repo.get(CourseRegistration, course_reg_id)
×
23

24
    case Assessments.assessment_with_questions_and_answers(assessment_id, course_reg) do
×
25
      {:ok, assessment} -> render(conn, "show.json", assessment: assessment)
×
26
      {:error, {status, message}} -> send_resp(conn, status, message)
×
27
    end
28
  end
29

30
  def create(conn, %{
31
        "course_id" => course_id,
32
        "assessment" => assessment,
33
        "forceUpdate" => force_update,
34
        "assessmentConfigId" => assessment_config_id
35
      }) do
36
    file =
2✔
37
      assessment["file"].path
2✔
38
      |> File.read!()
39

40
    result =
2✔
41
      case force_update do
42
        "true" -> parse_xml(file, course_id, assessment_config_id, true)
1✔
43
        "false" -> parse_xml(file, course_id, assessment_config_id, false)
1✔
44
      end
45

46
    case result do
2✔
47
      :ok ->
48
        if force_update == "true" do
1✔
49
          text(conn, "Force update OK")
×
50
        else
51
          text(conn, "OK")
1✔
52
        end
53

54
      {:ok, warning_message} ->
55
        text(conn, warning_message)
×
56

57
      {:error, {status, message}} ->
58
        conn
59
        |> put_status(status)
60
        |> text(message)
1✔
61
    end
62
  end
63

64
  def delete(conn, %{"course_id" => course_id, "assessmentid" => assessment_id}) do
65
    with {:same_course, true} <- {:same_course, is_same_course(course_id, assessment_id)},
2✔
66
         {:ok, _} <- Assessments.delete_assessment(assessment_id) do
1✔
67
      text(conn, "OK")
1✔
68
    else
69
      {:same_course, false} ->
70
        conn
71
        |> put_status(403)
72
        |> text("User not allow to delete assessments from another course")
1✔
73

74
      {:error, {status, message}} ->
75
        conn
76
        |> put_status(status)
77
        |> text(message)
×
78
    end
79
  end
80

81
  def update(conn, params = %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do
82
    open_at = params |> Map.get("openAt")
9✔
83
    close_at = params |> Map.get("closeAt")
9✔
84
    is_published = params |> Map.get("isPublished")
9✔
85
    max_team_size = params |> Map.get("maxTeamSize")
9✔
86
    has_token_counter = params |> Map.get("hasTokenCounter")
9✔
87
    has_voting_features = params |> Map.get("hasVotingFeatures")
9✔
88
    is_autosave_enabled = params |> Map.get("isAutosaveEnabled")
9✔
89
    assign_entries_for_voting = params |> Map.get("assignEntriesForVoting")
9✔
90

91
    updated_assessment =
9✔
92
      if is_nil(is_published) do
93
        %{}
7✔
94
      else
95
        %{:is_published => is_published}
2✔
96
      end
97

98
    updated_assessment =
9✔
99
      if is_nil(max_team_size) do
100
        updated_assessment
9✔
101
      else
102
        Map.put(updated_assessment, :max_team_size, max_team_size)
×
103
      end
104

105
    updated_assessment =
9✔
106
      if is_nil(has_token_counter) do
107
        updated_assessment
7✔
108
      else
109
        Map.put(updated_assessment, :has_token_counter, has_token_counter)
2✔
110
      end
111

112
    updated_assessment =
9✔
113
      if is_nil(has_voting_features) do
114
        updated_assessment
7✔
115
      else
116
        Map.put(updated_assessment, :has_voting_features, has_voting_features)
2✔
117
      end
118

119
    updated_assessment =
9✔
120
      if is_nil(is_autosave_enabled) do
121
        updated_assessment
9✔
122
      else
123
        Map.put(updated_assessment, :is_autosave_enabled, is_autosave_enabled)
×
124
      end
125

126
    is_reassigning_voting =
9✔
127
      if is_nil(assign_entries_for_voting) do
9✔
128
        false
129
      else
130
        assign_entries_for_voting
×
131
      end
132

133
    with {:ok, assessment} <- check_dates(open_at, close_at, updated_assessment),
9✔
134
         {:ok, _nil} <- Assessments.update_assessment(assessment_id, assessment),
8✔
135
         {:ok, _nil} <- Assessments.reassign_voting(assessment_id, is_reassigning_voting) do
8✔
136
      text(conn, "OK")
8✔
137
    else
138
      {:error, {status, message}} ->
139
        conn
140
        |> put_status(status)
141
        |> text(message)
1✔
142
    end
143
  end
144

145
  def calculate_contest_score(conn, %{"assessmentid" => assessment_id, "course_id" => course_id}) do
NEW
146
    with_contest_voting_question(conn, course_id, assessment_id, fn voting_question ->
×
NEW
147
      Assessments.compute_relative_score(voting_question.id)
×
UNCOV
148
      text(conn, "Contest scores calculated")
×
149
    end)
150
  end
151

152
  def dispatch_contest_xp(conn, %{"assessmentid" => assessment_id, "course_id" => course_id}) do
NEW
153
    with_contest_voting_question(conn, course_id, assessment_id, fn voting_question ->
×
NEW
154
      Assessments.assign_winning_contest_entries_xp(voting_question.id)
×
UNCOV
155
      text(conn, "XP Dispatched")
×
156
    end)
157
  end
158

159
  # Verifies the assessment belongs to `course_id` (guarding against cross-course
160
  # access) then looks up its voting question, invoking `on_voting_question` with
161
  # the question when one is found.
162
  defp with_contest_voting_question(conn, course_id, assessment_id, on_voting_question) do
NEW
163
    if is_same_course(course_id, assessment_id) do
×
NEW
164
      voting_question =
×
165
        Question
166
        |> where(type: :voting)
NEW
167
        |> where(assessment_id: ^assessment_id)
×
168
        |> Repo.one()
169

NEW
170
      if voting_question do
×
NEW
171
        on_voting_question.(voting_question)
×
172
      else
NEW
173
        text(conn, "No voting questions found for the given assessment")
×
174
      end
175
    else
176
      conn
177
      |> put_status(403)
NEW
178
      |> text("User not allowed to modify contest assessments from another course")
×
179
    end
180
  end
181

182
  defp check_dates(open_at, close_at, assessment) do
183
    if is_nil(open_at) and is_nil(close_at) do
9✔
184
      {:ok, assessment}
185
    else
186
      formatted_open_date = elem(DateTime.from_iso8601(open_at), 1)
5✔
187
      formatted_close_date = elem(DateTime.from_iso8601(close_at), 1)
5✔
188

189
      if DateTime.compare(formatted_close_date, formatted_open_date) == :lt do
5✔
190
        {:error, {:bad_request, "New end date should occur after new opening date"}}
191
      else
192
        assessment = Map.put(assessment, :open_at, formatted_open_date)
4✔
193
        assessment = Map.put(assessment, :close_at, formatted_close_date)
4✔
194
        {:ok, assessment}
195
      end
196
    end
197
  end
198

199
  defp is_same_course(course_id, assessment_id) do
200
    Assessment
201
    |> where(id: ^assessment_id)
202
    |> where(course_id: ^course_id)
2✔
203
    |> Repo.exists?()
2✔
204
  end
205

206
  swagger_path :index do
1✔
207
    get("/courses/{course_id}/admin/users/{courseRegId}/assessments")
208

209
    summary("Fetches assessment overviews of a user")
210

211
    security([%{JWT: []}])
212

213
    parameters do
214
      courseRegId(:path, :integer, "Course Reg ID", required: true)
215
    end
216

217
    response(200, "OK", Schema.array(:AssessmentsList))
218
    response(401, "Unauthorised")
219
    response(403, "Forbidden")
220
  end
221

222
  swagger_path :create do
1✔
223
    post("/courses/{course_id}/admin/assessments")
224

225
    summary("Creates a new assessment or updates an existing assessment")
226

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

229
    consumes("multipart/form-data")
230

231
    parameters do
232
      assessment(:formData, :file, "Assessment to create or update", required: true)
233
      forceUpdate(:formData, :boolean, "Force update", required: true)
234
    end
235

236
    response(200, "OK")
237
    response(400, "XML parse error")
238
    response(403, "Forbidden")
239
  end
240

241
  swagger_path :delete do
1✔
242
    PhoenixSwagger.Path.delete("/courses/{course_id}/admin/assessments/{assessmentId}")
243

244
    summary("Deletes an assessment")
245

246
    security([%{JWT: []}])
247

248
    parameters do
249
      assessmentId(:path, :integer, "Assessment ID", required: true)
250
    end
251

252
    response(200, "OK")
253
    response(403, "Forbidden")
254
  end
255

256
  swagger_path :update do
1✔
257
    post("/courses/{course_id}/admin/assessments/{assessmentId}")
258

259
    summary("Updates an assessment")
260

261
    security([%{JWT: []}])
262

263
    consumes("application/json")
264

265
    parameters do
266
      assessmentId(:path, :integer, "Assessment ID", required: true)
267

268
      assessment(:body, Schema.ref(:AdminUpdateAssessmentPayload), "Updated assessment details",
269
        required: true
270
      )
271
    end
272

273
    response(200, "OK")
274
    response(401, "Assessment is already opened")
275
    response(403, "Forbidden")
276
  end
277

278
  swagger_path :get_popular_leaderboard do
×
279
    get("/courses/{course_id}/admin/assessments/:assessmentid/popularVoteLeaderboard")
280

281
    summary("get the top 10 contest entries based on popularity")
282

283
    security([%{JWT: []}])
284

285
    parameters do
286
      assessmentId(:path, :integer, "Assessment ID", required: true)
287
    end
288

289
    response(200, "OK", Schema.array(:Leaderboard))
290
    response(401, "Unauthorised")
291
    response(403, "Forbidden")
292
  end
293

294
  swagger_path :get_score_leaderboard do
×
295
    get("/courses/{course_id}/admin/assessments/:assessmentid/scoreLeaderboard")
296

297
    summary("get the top X contest entries based on score")
298

299
    security([%{JWT: []}])
300

301
    parameters do
302
      assessmentId(:path, :integer, "Assessment ID", required: true)
303
    end
304

305
    response(200, "OK", Schema.array(:Leaderboard))
306
    response(401, "Unauthorised")
307
    response(403, "Forbidden")
308
  end
309

310
  def swagger_definitions do
311
    %{
1✔
312
      # Schemas for payloads to modify data
313
      AdminUpdateAssessmentPayload:
314
        swagger_schema do
1✔
315
          properties do
1✔
316
            closeAt(:string, "Open date", required: false)
317
            openAt(:string, "Close date", required: false)
318
            isPublished(:boolean, "Whether the assessment is published", required: false)
319
            maxTeamSize(:number, "Max team size of the assessment", required: false)
1✔
320
          end
321
        end
322
    }
323
  end
324
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc