Skip to content

Commit 3135f48

Browse files
Migrate filtering to the Backend (#1065)
* feat: implement paginated submissions query function * feat: implement /grading route accepting pagination query parameters * fix: fix paginated submissions with group filter * Changed group_filter query to work with the new paginated query * refactor: change page to offset for pagination * feat: Update admin grading controller to take variable amount of params * refactor: Refactor code from using raw sql to using ecto query * feat: Implement filtering by mission name * feat: Implement filtering by progress for submission * refactor: show all and show only grader's groups now use ecto query * feat: Implement filter by group id * fix: Fix limit and offset default for missing query param * refactor: Rename group filter to course registration filter * feat: Implement filter by name * feat: Implement filter by assessment type * Implement filter by is_manually_graded * feat: Include total count of submissions in response * refactor: Use one join query for assessment config queries * refactor: Use one join query for user queries * refactor: Move queries for assessment and assessment config to builder * fix: Update admin_grading_controller test to account for change in response * refactor: Rename function * docs: Include a description of the function * refactor: Format code * fix: Update filter by groupID to groupName * feat: Assessment title filter using LIKE * fix: Change Notification/Email tests to use the new response * refactor: Remove old code for submissions query * feat: Implement notFullyGraded feature * refactor: Remove TODO comment for what's now known as notFullyGraded * refactor: Change param name for notification worker to filter notFullyGraded * Remove commented code --------- Co-authored-by: Richard Dominick <[email protected]>
1 parent dd89ec9 commit 3135f48

File tree

7 files changed

+416
-232
lines changed

7 files changed

+416
-232
lines changed

lib/cadet/assessments/assessments.ex

+217-80
Original file line numberDiff line numberDiff line change
@@ -1230,92 +1230,229 @@ defmodule Cadet.Assessments do
12301230

12311231
@doc """
12321232
Function returning submissions under a grader. This function returns only the
1233-
fields that are exposed in the /grading endpoint. The reason we select only
1234-
those fields is to reduce the memory usage especially when the number of
1235-
submissions is large i.e. > 25000 submissions.
1233+
fields that are exposed in the /grading endpoint.
12361234
1237-
The input parameters are the user and group_only. group_only is used to check
1238-
whether only the groups under the grader should be returned. The parameter is
1239-
a boolean which is false by default.
1235+
The input parameters are the user and query parameters. Query parameters are
1236+
used to filter the submissions.
12401237
1241-
The return value is {:ok, submissions} if no errors, else it is {:error,
1242-
{:forbidden, "Forbidden."}}
1238+
The group parameter is used to check whether only the groups under the grader
1239+
should be returned. If pageSize and offset are not provided, the default
1240+
values are 10 and 0 respectively.
1241+
1242+
The return value is
1243+
{:ok, %{"count": count, "data": submissions}} if no errors,
1244+
else it is {:error, {:forbidden, "Forbidden."}}
12431245
"""
1244-
@spec all_submissions_by_grader_for_index(CourseRegistration.t()) ::
1245-
{:ok, %{:assessments => [any()], :submissions => [any()], :users => [any()]}}
1246-
def all_submissions_by_grader_for_index(
1246+
1247+
# We bypass Ecto here and use a raw query to generate JSON directly from
1248+
# PostgreSQL, because doing it in Elixir/Erlang is too inefficient.
1249+
@spec submissions_by_grader_for_index(CourseRegistration.t()) ::
1250+
{:ok,
1251+
%{
1252+
:count => integer,
1253+
:data => %{:assessments => [any()], :submissions => [any()], :users => [any()]}
1254+
}}
1255+
def submissions_by_grader_for_index(
12471256
grader = %CourseRegistration{course_id: course_id},
1248-
group_only \\ false,
1249-
_ungraded_only \\ false
1257+
params \\ %{
1258+
"group" => "false",
1259+
"notFullyGraded" => "true",
1260+
"pageSize" => "10",
1261+
"offset" => "0"
1262+
}
12501263
) do
1251-
show_all = not group_only
1252-
1253-
group_filter =
1254-
if show_all,
1255-
do: "",
1256-
else:
1257-
"AND s.student_id IN (SELECT cr.id FROM course_registrations AS cr INNER JOIN groups AS g ON cr.group_id = g.id WHERE g.leader_id = #{grader.id}) OR s.student_id = #{grader.id}"
1258-
1259-
# TODO: Restore ungraded filtering
1260-
# ... or more likely, decouple email logic from this function
1261-
# ungraded_where =
1262-
# if ungraded_only,
1263-
# do: "where s.\"gradedCount\" < assts.\"questionCount\"",
1264-
# else: ""
1265-
1266-
# We bypass Ecto here and use a raw query to generate JSON directly from
1267-
# PostgreSQL, because doing it in Elixir/Erlang is too inefficient.
1268-
1269-
submissions =
1270-
case Repo.query("""
1271-
SELECT
1272-
s.id,
1273-
s.status,
1274-
s.unsubmitted_at,
1275-
s.unsubmitted_by_id,
1276-
s_ans.xp,
1277-
s_ans.xp_adjustment,
1278-
s.xp_bonus,
1279-
s_ans.graded_count,
1280-
s.student_id,
1281-
s.assessment_id
1282-
FROM
1283-
submissions AS s
1284-
LEFT JOIN (
1285-
SELECT
1286-
ans.submission_id,
1287-
SUM(ans.xp) AS xp,
1288-
SUM(ans.xp_adjustment) AS xp_adjustment,
1289-
COUNT(ans.id) FILTER (
1290-
WHERE
1291-
ans.grader_id IS NOT NULL
1292-
) AS graded_count
1293-
FROM
1294-
answers AS ans
1295-
GROUP BY
1296-
ans.submission_id
1297-
) AS s_ans ON s_ans.submission_id = s.id
1298-
WHERE
1299-
s.assessment_id IN (
1300-
SELECT
1301-
id
1302-
FROM
1303-
assessments
1304-
WHERE
1305-
assessments.course_id = #{course_id}
1306-
) #{group_filter};
1307-
""") do
1308-
{:ok, %{columns: columns, rows: result}} ->
1309-
result
1310-
|> Enum.map(
1311-
&(columns
1312-
|> Enum.map(fn c -> String.to_atom(c) end)
1313-
|> Enum.zip(&1)
1314-
|> Enum.into(%{}))
1315-
)
1316-
end
1264+
submission_answers_query =
1265+
from(ans in Answer,
1266+
group_by: ans.submission_id,
1267+
select: %{
1268+
submission_id: ans.submission_id,
1269+
xp: sum(ans.xp),
1270+
xp_adjustment: sum(ans.xp_adjustment),
1271+
graded_count: filter(count(ans.id), not is_nil(ans.grader_id))
1272+
}
1273+
)
1274+
1275+
question_answers_query =
1276+
from(q in Question,
1277+
group_by: q.assessment_id,
1278+
join: a in Assessment,
1279+
on: q.assessment_id == a.id,
1280+
select: %{
1281+
assessment_id: q.assessment_id,
1282+
question_count: count(q.id)
1283+
}
1284+
)
1285+
1286+
query =
1287+
from(s in Submission,
1288+
left_join: ans in subquery(submission_answers_query),
1289+
on: ans.submission_id == s.id,
1290+
as: :ans,
1291+
left_join: asst in subquery(question_answers_query),
1292+
on: asst.assessment_id == s.assessment_id,
1293+
as: :asst,
1294+
where: ^build_user_filter(params),
1295+
where: s.assessment_id in subquery(build_assessment_filter(params, course_id)),
1296+
where: s.assessment_id in subquery(build_assessment_config_filter(params)),
1297+
where: ^build_submission_filter(params),
1298+
where: ^build_course_registration_filter(params, grader),
1299+
order_by: [desc: s.inserted_at],
1300+
limit: ^elem(Integer.parse(Map.get(params, "pageSize", "10")), 0),
1301+
offset: ^elem(Integer.parse(Map.get(params, "offset", "0")), 0),
1302+
select: %{
1303+
id: s.id,
1304+
status: s.status,
1305+
xp_bonus: s.xp_bonus,
1306+
unsubmitted_at: s.unsubmitted_at,
1307+
unsubmitted_by_id: s.unsubmitted_by_id,
1308+
student_id: s.student_id,
1309+
assessment_id: s.assessment_id,
1310+
xp: ans.xp,
1311+
xp_adjustment: ans.xp_adjustment,
1312+
graded_count: ans.graded_count,
1313+
question_count: asst.question_count
1314+
}
1315+
)
1316+
1317+
submissions = Repo.all(query)
1318+
1319+
count_query =
1320+
from(s in Submission,
1321+
left_join: ans in subquery(submission_answers_query),
1322+
on: ans.submission_id == s.id,
1323+
as: :ans,
1324+
left_join: asst in subquery(question_answers_query),
1325+
on: asst.assessment_id == s.assessment_id,
1326+
as: :asst,
1327+
where: s.assessment_id in subquery(build_assessment_filter(params, course_id)),
1328+
where: s.assessment_id in subquery(build_assessment_config_filter(params)),
1329+
where: ^build_user_filter(params),
1330+
where: ^build_submission_filter(params),
1331+
where: ^build_course_registration_filter(params, grader),
1332+
select: count(s.id)
1333+
)
1334+
1335+
count = Repo.one(count_query)
1336+
1337+
{:ok, %{count: count, data: generate_grading_summary_view_model(submissions, course_id)}}
1338+
end
13171339

1318-
{:ok, generate_grading_summary_view_model(submissions, course_id)}
1340+
defp build_assessment_filter(params, course_id) do
1341+
assessments_filters =
1342+
Enum.reduce(params, dynamic(true), fn
1343+
{"title", value}, dynamic ->
1344+
dynamic([assessment], ^dynamic and ilike(assessment.title, ^"%#{value}%"))
1345+
1346+
{_, _}, dynamic ->
1347+
dynamic
1348+
end)
1349+
1350+
from(a in Assessment,
1351+
where: a.course_id == ^course_id,
1352+
where: ^assessments_filters,
1353+
select: a.id
1354+
)
1355+
end
1356+
1357+
defp build_submission_filter(params) do
1358+
Enum.reduce(params, dynamic(true), fn
1359+
{"status", value}, dynamic ->
1360+
dynamic([submission], ^dynamic and submission.status == ^value)
1361+
1362+
{"notFullyGraded", "true"}, dynamic ->
1363+
dynamic([ans: ans, asst: asst], ^dynamic and asst.question_count > ans.graded_count)
1364+
1365+
{_, _}, dynamic ->
1366+
dynamic
1367+
end)
1368+
end
1369+
1370+
defp build_course_registration_filter(params, grader) do
1371+
Enum.reduce(params, dynamic(true), fn
1372+
{"group", "true"}, dynamic ->
1373+
dynamic(
1374+
[submission],
1375+
(^dynamic and
1376+
submission.student_id in subquery(
1377+
from(cr in CourseRegistration,
1378+
join: g in Group,
1379+
on: cr.group_id == g.id,
1380+
where: g.leader_id == ^grader.id,
1381+
select: cr.id
1382+
)
1383+
)) or submission.student_id == ^grader.id
1384+
)
1385+
1386+
{"groupName", value}, dynamic ->
1387+
dynamic(
1388+
[submission],
1389+
^dynamic and
1390+
submission.student_id in subquery(
1391+
from(cr in CourseRegistration,
1392+
join: g in Group,
1393+
on: cr.group_id == g.id,
1394+
where: g.name == ^value,
1395+
select: cr.id
1396+
)
1397+
)
1398+
)
1399+
1400+
{_, _}, dynamic ->
1401+
dynamic
1402+
end)
1403+
end
1404+
1405+
defp build_user_filter(params) do
1406+
Enum.reduce(params, dynamic(true), fn
1407+
{"name", value}, dynamic ->
1408+
dynamic(
1409+
[submission],
1410+
^dynamic and
1411+
submission.student_id in subquery(
1412+
from(user in User,
1413+
where: ilike(user.name, ^"%#{value}%"),
1414+
select: user.id
1415+
)
1416+
)
1417+
)
1418+
1419+
{"username", value}, dynamic ->
1420+
dynamic(
1421+
[submission],
1422+
^dynamic and
1423+
submission.student_id in subquery(
1424+
from(user in User,
1425+
where: ilike(user.username, ^"%#{value}%"),
1426+
select: user.id
1427+
)
1428+
)
1429+
)
1430+
1431+
{_, _}, dynamic ->
1432+
dynamic
1433+
end)
1434+
end
1435+
1436+
defp build_assessment_config_filter(params) do
1437+
assessment_config_filters =
1438+
Enum.reduce(params, dynamic(true), fn
1439+
{"type", value}, dynamic ->
1440+
dynamic([assessment_config: config], ^dynamic and config.type == ^value)
1441+
1442+
{"isManuallyGraded", value}, dynamic ->
1443+
dynamic([assessment_config: config], ^dynamic and config.is_manually_graded == ^value)
1444+
1445+
{_, _}, dynamic ->
1446+
dynamic
1447+
end)
1448+
1449+
from(a in Assessment,
1450+
inner_join: config in AssessmentConfig,
1451+
on: a.config_id == config.id,
1452+
as: :assessment_config,
1453+
where: ^assessment_config_filters,
1454+
select: a.id
1455+
)
13191456
end
13201457

13211458
defp generate_grading_summary_view_model(submissions, course_id) do

lib/cadet/workers/NotificationWorker.ex

+5-2
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,11 @@ defmodule Cadet.Workers.NotificationWorker do
7373
for avenger_cr <- avengers_crs do
7474
avenger = Cadet.Accounts.get_user(avenger_cr.user_id)
7575

76-
{:ok, %{submissions: ungraded_submissions}} =
77-
Cadet.Assessments.all_submissions_by_grader_for_index(avenger_cr, true, true)
76+
{:ok, %{data: %{submissions: ungraded_submissions}}} =
77+
Cadet.Assessments.submissions_by_grader_for_index(avenger_cr, %{
78+
"group" => "true",
79+
"notFullyGraded" => "true"
80+
})
7881

7982
if Enum.count(ungraded_submissions) < ungraded_threshold do
8083
IO.puts("[AVENGER_BACKLOG] below threshold!")

lib/cadet_web/admin_controllers/admin_grading_controller.ex

+3-4
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,11 @@ defmodule CadetWeb.AdminGradingController do
44

55
alias Cadet.Assessments
66

7-
def index(conn, %{"group" => group}) when group in ["true", "false"] do
7+
def index(conn, %{"group" => group} = params)
8+
when group in ["true", "false"] do
89
course_reg = conn.assigns[:course_reg]
910

10-
group = String.to_atom(group)
11-
12-
case Assessments.all_submissions_by_grader_for_index(course_reg, group) do
11+
case Assessments.submissions_by_grader_for_index(course_reg, params) do
1312
{:ok, view_model} ->
1413
conn
1514
|> put_status(:ok)

lib/cadet_web/admin_views/admin_grading_view.ex

+28-21
Original file line numberDiff line numberDiff line change
@@ -25,29 +25,36 @@ defmodule CadetWeb.AdminGradingView do
2525
end
2626

2727
def render("gradingsummaries.json", %{
28-
users: users,
29-
assessments: assessments,
30-
submissions: submissions
28+
count: count,
29+
data: %{
30+
users: users,
31+
assessments: assessments,
32+
submissions: submissions
33+
}
3134
}) do
32-
for submission <- submissions do
33-
user = users |> Enum.find(&(&1.id == submission.student_id))
34-
assessment = assessments |> Enum.find(&(&1.id == submission.assessment_id))
35+
%{
36+
count: count,
37+
data:
38+
for submission <- submissions do
39+
user = users |> Enum.find(&(&1.id == submission.student_id))
40+
assessment = assessments |> Enum.find(&(&1.id == submission.assessment_id))
3541

36-
render(
37-
CadetWeb.AdminGradingView,
38-
"gradingsummary.json",
39-
%{
40-
user: user,
41-
assessment: assessment,
42-
submission: submission,
43-
unsubmitter:
44-
case submission.unsubmitted_by_id do
45-
nil -> nil
46-
_ -> users |> Enum.find(&(&1.id == submission.unsubmitted_by_id))
47-
end
48-
}
49-
)
50-
end
42+
render(
43+
CadetWeb.AdminGradingView,
44+
"gradingsummary.json",
45+
%{
46+
user: user,
47+
assessment: assessment,
48+
submission: submission,
49+
unsubmitter:
50+
case submission.unsubmitted_by_id do
51+
nil -> nil
52+
_ -> users |> Enum.find(&(&1.id == submission.unsubmitted_by_id))
53+
end
54+
}
55+
)
56+
end
57+
}
5158
end
5259

5360
def render("gradingsummary.json", %{

0 commit comments

Comments
 (0)