Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Question Level Student Statistics Table (Part 2: Attempt Count) #7084

Merged
merged 9 commits into from
Apr 4, 2024
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# frozen_string_literal: true
module Course::Statistics::ReferenceTimesConcern
private

def personal_end_at_hash(assessment_id_array, course_id)
personal_end_at = Course::PersonalTime.find_by_sql(<<-SQL.squish

Check warning on line 6 in app/controllers/concerns/course/statistics/reference_times_concern.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/concerns/course/statistics/reference_times_concern.rb#L6

Added line #L6 was not covered by tests
WITH course_user_personal_end_at AS (
SELECT cpt.course_user_id, cpt.end_at, clpi.actable_id AS assessment_id
FROM course_personal_times cpt
JOIN (
SELECT course_lesson_plan_items.id, course_lesson_plan_items.actable_id
FROM course_lesson_plan_items
WHERE course_lesson_plan_items.actable_type = 'Course::Assessment'
AND course_lesson_plan_items.actable_id IN (#{assessment_id_array.join(', ')})
) clpi
ON cpt.lesson_plan_item_id = clpi.id
),
personal_times AS (
SELECT cu.id AS course_user_id, pt.end_at, pt.assessment_id
FROM (
SELECT course_users.id
FROM course_users
WHERE course_users.course_id = #{course_id}
) cu
LEFT JOIN (
SELECT course_user_id, end_at, assessment_id
FROM course_user_personal_end_at
) pt
ON cu.id = pt.course_user_id
),
personal_reference_times AS (
SELECT cu.id AS course_user_id, crt.end_at, clpi.assessment_id
FROM (
SELECT course_users.id, course_users.reference_timeline_id
FROM course_users
WHERE course_users.course_id = #{course_id} AND course_users.role = #{CourseUser.roles[:student]}
) cu
LEFT JOIN (
SELECT reference_timeline_id, lesson_plan_item_id, end_at
FROM course_reference_times
) crt
ON crt.reference_timeline_id = cu.reference_timeline_id
LEFT JOIN (
SELECT id, actable_id AS assessment_id
FROM course_lesson_plan_items
WHERE course_lesson_plan_items.actable_type = 'Course::Assessment'
AND course_lesson_plan_items.actable_id IN (#{assessment_id_array.join(', ')})
) clpi
ON crt.lesson_plan_item_id = clpi.id
)
SELECT
pt.assessment_id,
pt.course_user_id,
CASE WHEN pt.end_at IS NOT NULL THEN pt.end_at ELSE prt.end_at END AS end_at
FROM personal_times pt
LEFT JOIN personal_reference_times prt
ON
pt.course_user_id = prt.course_user_id
AND pt.assessment_id = prt.assessment_id
SQL
)
personal_end_at.map { |pea| [[pea.assessment_id, pea.course_user_id], pea.end_at] }.to_h

Check warning on line 62 in app/controllers/concerns/course/statistics/reference_times_concern.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/concerns/course/statistics/reference_times_concern.rb#L62

Added line #L62 was not covered by tests
end

def reference_times_hash(assessment_id_array, course_id)
reference_times = Course::ReferenceTime.find_by_sql(<<-SQL.squish

Check warning on line 66 in app/controllers/concerns/course/statistics/reference_times_concern.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/concerns/course/statistics/reference_times_concern.rb#L66

Added line #L66 was not covered by tests
SELECT clpi.actable_id AS assessment_id, crt.end_at
FROM course_reference_times crt
JOIN (
SELECT id
FROM course_reference_timelines
WHERE course_id = #{course_id} AND "default" = TRUE
) crtl
ON crt.reference_timeline_id = crtl.id
JOIN (
SELECT id, actable_id
FROM course_lesson_plan_items
WHERE course_lesson_plan_items.actable_type = 'Course::Assessment'
AND course_lesson_plan_items.actable_id IN (#{assessment_id_array.join(', ')})
) clpi
ON crt.lesson_plan_item_id = clpi.id
SQL
)
reference_times.map { |rt| [rt.assessment_id, rt.end_at] }.to_h

Check warning on line 84 in app/controllers/concerns/course/statistics/reference_times_concern.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/concerns/course/statistics/reference_times_concern.rb#L84

Added line #L84 was not covered by tests
end
end
80 changes: 75 additions & 5 deletions app/controllers/concerns/course/statistics/submissions_concern.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true
module Course::Statistics::SubmissionsConcern
include Course::Statistics::ReferenceTimesConcern

private

def initialize_student_hash(students)
Expand All @@ -20,28 +22,96 @@
student_hash
end

def answer_statistics_hash
submission_answer_statistics = Course::Assessment::Answer.find_by_sql(<<-SQL.squish

Check warning on line 26 in app/controllers/concerns/course/statistics/submissions_concern.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/concerns/course/statistics/submissions_concern.rb#L26

Added line #L26 was not covered by tests
WITH
attempt_count AS (
SELECT
caa.question_id,
caa.submission_id,
COUNT(*) AS attempt_count
FROM course_assessment_answers caa
JOIN course_assessment_submissions cas ON caa.submission_id = cas.id
WHERE cas.assessment_id = #{assessment_params[:id]}
GROUP BY caa.question_id, caa.submission_id
),
attempt_info AS (
SELECT
caa_ranked.question_id,
caa_ranked.submission_id,
jsonb_agg(jsonb_build_array(caa_ranked.grade, caa_ranked.correct, caa_ranked.workflow_state)) AS submission_info
FROM (
SELECT
caa_inner.question_id,
caa_inner.submission_id,
caa_inner.correct,
caa_inner.grade,
cas_inner.workflow_state,
ROW_NUMBER() OVER (PARTITION BY caa_inner.question_id, caa_inner.submission_id ORDER BY caa_inner.created_at DESC) AS row_num
FROM
course_assessment_answers caa_inner
JOIN
course_assessment_submissions cas_inner ON caa_inner.submission_id = cas_inner.id
WHERE
cas_inner.assessment_id = #{assessment_params[:id]}
) AS caa_ranked
WHERE caa_ranked.row_num <= 2
GROUP BY caa_ranked.question_id, caa_ranked.submission_id
)
SELECT
attempt_count.question_id,
attempt_count.submission_id,
attempt_count.attempt_count,
CASE WHEN jsonb_array_length(attempt_info.submission_info) = 1 OR attempt_info.submission_info->0->>2 != 'attempting'
THEN attempt_info.submission_info->0->>0 ELSE attempt_info.submission_info->1->>0
END AS grade,
CASE WHEN jsonb_array_length(attempt_info.submission_info) = 1 OR attempt_info.submission_info->0->>2 != 'attempting'
THEN attempt_info.submission_info->0->>1 ELSE attempt_info.submission_info->1->>1
END AS correct
FROM attempt_count
JOIN attempt_info
ON attempt_count.question_id = attempt_info.question_id AND attempt_count.submission_id = attempt_info.submission_id
SQL
)

submission_answer_statistics.group_by(&:submission_id).

Check warning on line 77 in app/controllers/concerns/course/statistics/submissions_concern.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/concerns/course/statistics/submissions_concern.rb#L77

Added line #L77 was not covered by tests
transform_values do |grouped_answers|
grouped_answers.sort_by { |answer| @question_order_hash[answer.question_id] }

Check warning on line 79 in app/controllers/concerns/course/statistics/submissions_concern.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/concerns/course/statistics/submissions_concern.rb#L79

Added line #L79 was not covered by tests
end
end

def populate_hash_including_answers(student_hash, submissions)
answers_hash = answer_statistics_hash
fetch_personal_and_reference_timeline_hash

Check warning on line 85 in app/controllers/concerns/course/statistics/submissions_concern.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/concerns/course/statistics/submissions_concern.rb#L84-L85

Added lines #L84 - L85 were not covered by tests

submissions.map do |submission|
submitter_course_user = submission.creator.course_users.select { |u| u.course_id == @assessment.course_id }.first
next unless submitter_course_user&.student?

answers = submission.answers.
select(&:current_answer).
sort_by { |answer| @question_order_hash[answer.question_id] }
end_at = @assessment.lesson_plan_item.time_for(submitter_course_user).end_at
answers = answers_hash[submission.id]
end_at = @personal_end_at_hash[[@assessment.id, submitter_course_user.id]] ||

Check warning on line 92 in app/controllers/concerns/course/statistics/submissions_concern.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/concerns/course/statistics/submissions_concern.rb#L91-L92

Added lines #L91 - L92 were not covered by tests
@reference_times_hash[@assessment.id]

student_hash[submitter_course_user] = [submission, answers, end_at]
end
end

def populate_hash_without_answers(student_hash, submissions)
fetch_personal_and_reference_timeline_hash

Check warning on line 100 in app/controllers/concerns/course/statistics/submissions_concern.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/concerns/course/statistics/submissions_concern.rb#L100

Added line #L100 was not covered by tests

submissions.map do |submission|
submitter_course_user = submission.creator.course_users.select { |u| u.course_id == @assessment.course_id }.first
next unless submitter_course_user&.student?

end_at = @assessment.lesson_plan_item.time_for(submitter_course_user).end_at
end_at = @personal_end_at_hash[[@assessment.id, submitter_course_user.id]] ||

Check warning on line 106 in app/controllers/concerns/course/statistics/submissions_concern.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/concerns/course/statistics/submissions_concern.rb#L106

Added line #L106 was not covered by tests
@reference_times_hash[@assessment.id]

student_hash[submitter_course_user] = [submission, end_at]
end
end

def fetch_personal_and_reference_timeline_hash
@personal_end_at_hash = personal_end_at_hash([@assessment.id], @assessment.course.id)
@reference_times_hash = reference_times_hash([@assessment.id], @assessment.course.id)

Check warning on line 115 in app/controllers/concerns/course/statistics/submissions_concern.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/concerns/course/statistics/submissions_concern.rb#L114-L115

Added lines #L114 - L115 were not covered by tests
end
end
38 changes: 38 additions & 0 deletions app/controllers/concerns/course/statistics/users_concern.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# frozen_string_literal: true
module Course::Statistics::UsersConcern
private

def group_names_hash
group_names = Course::Group.find_by_sql(<<-SQL.squish

Check warning on line 6 in app/controllers/concerns/course/statistics/users_concern.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/concerns/course/statistics/users_concern.rb#L6

Added line #L6 was not covered by tests
WITH course_students AS (
SELECT cgu.group_id, cgu.course_user_id
FROM course_group_users cgu
JOIN (
SELECT course_users.id
FROM course_users
WHERE course_users.role = #{CourseUser.roles[:student]}
AND course_users.course_id = #{current_course.id}
) cu
ON cgu.course_user_id = cu.id
),

course_group_names AS (
SELECT course_groups.id, course_groups.name
FROM course_groups
)

SELECT id, ARRAY_AGG(group_name) AS group_names
FROM (
SELECT
cs.course_user_id as id,
cgn.name as group_name
FROM course_students cs
JOIN course_group_names cgn
ON cs.group_id = cgn.id
) group_tables
GROUP BY group_tables.id
SQL
)
group_names.map { |course_user| [course_user.id, course_user.group_names] }.to_h

Check warning on line 36 in app/controllers/concerns/course/statistics/users_concern.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/concerns/course/statistics/users_concern.rb#L36

Added line #L36 was not covered by tests
end
end
25 changes: 15 additions & 10 deletions app/controllers/course/statistics/assessments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,22 @@
class Course::Statistics::AssessmentsController < Course::Statistics::Controller
include Course::UsersHelper
include Course::Statistics::SubmissionsConcern
include Course::Statistics::UsersConcern

def main_statistics
@assessment = Course::Assessment.where(id: assessment_params[:id]).
calculated(:maximum_grade, :question_count).
preload(lesson_plan_item: [:reference_times, personal_times: :course_user],
course: :course_users).first
preload(course: :course_users).first
submissions = Course::Assessment::Submission.where(assessment_id: assessment_params[:id]).
calculated(:grade, :grader_ids).
preload(:answers, creator: :course_users)
@course_users_hash = preload_course_users_hash(current_course)
preload(creator: :course_users)
@course_users_hash = preload_course_users_hash(@assessment.course)

Check warning on line 14 in app/controllers/course/statistics/assessments_controller.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/course/statistics/assessments_controller.rb#L14

Added line #L14 was not covered by tests
ekowidianto marked this conversation as resolved.
Show resolved Hide resolved

load_course_user_students
load_course_user_students_info

Check warning on line 16 in app/controllers/course/statistics/assessments_controller.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/course/statistics/assessments_controller.rb#L16

Added line #L16 was not covered by tests
fetch_all_ancestor_assessments
create_question_related_hash

@assessment_autograded = @question_auto_gradable_status_hash.any? { |_, value| value }

Check warning on line 20 in app/controllers/course/statistics/assessments_controller.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/course/statistics/assessments_controller.rb#L20

Added line #L20 was not covered by tests
@student_submissions_hash = fetch_hash_for_main_assessment(submissions, @all_students)
end

Expand All @@ -29,9 +31,8 @@
where(assessment_id: assessment_params[:id]).
calculated(:grade)

load_course_user_students

@student_submissions_hash = fetch_hash_for_ancestor_assessment(submissions, @all_students)
@all_students = @assessment.course.course_users.students
@student_submissions_hash = fetch_hash_for_ancestor_assessment(submissions, @all_students).compact

Check warning on line 35 in app/controllers/course/statistics/assessments_controller.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/course/statistics/assessments_controller.rb#L34-L35

Added lines #L34 - L35 were not covered by tests
end

private
Expand All @@ -40,8 +41,9 @@
params.permit(:id)
end

def load_course_user_students
@all_students = @assessment.course.course_users.students
def load_course_user_students_info
@all_students = current_course.course_users.students
@group_names_hash = group_names_hash

Check warning on line 46 in app/controllers/course/statistics/assessments_controller.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/course/statistics/assessments_controller.rb#L45-L46

Added lines #L45 - L46 were not covered by tests
end

def fetch_all_ancestor_assessments
Expand All @@ -63,5 +65,8 @@
@question_maximum_grade_hash = @assessment.questions.to_h do |q|
[q.id, q.maximum_grade]
end
@question_auto_gradable_status_hash = @assessment.questions.to_h do |q|
[q.id, q.auto_gradable?]

Check warning on line 69 in app/controllers/course/statistics/assessments_controller.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/course/statistics/assessments_controller.rb#L68-L69

Added lines #L68 - L69 were not covered by tests
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# frozen_string_literal: true
json.attemptStatus answers.each do |answer|
json.isAutograded @question_auto_gradable_status_hash[answer.question_id]
json.attemptCount answer.attempt_count
json.correct answer.correct
end
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
# frozen_string_literal: true
json.assessment do
json.partial! 'assessment', assessment: @assessment, course: current_course
json.isAutograded @assessment_autograded
json.questionCount @assessment.question_count
end

json.submissions @student_submissions_hash.each do |course_user, (submission, answers, end_at)|
json.partial! 'course_user', course_user: course_user
json.partial! 'submission', submission: submission, end_at: end_at

json.groups course_user.groups do |group|
json.name group.name
json.groups @group_names_hash[course_user.id] do |name|
json.name name
end

if !submission.nil? && submission.workflow_state == 'published' && submission.grader_ids
# the graders are all the same regardless of question, so we just pick the first one
json.partial! 'answer', grader: @course_users_hash[submission.grader_ids.first], answers: answers
json.partial! 'attempt_status', answers: answers
end
end

Expand Down
8 changes: 2 additions & 6 deletions client/app/bundles/course/assessment/operations/statistics.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
import { AxiosError } from 'axios';
import { dispatch } from 'store';
import {
AncestorAssessmentStats,
MainAssessmentStats,
} from 'types/course/statistics/assessmentStatistics';
import { AncestorAssessmentStats } from 'types/course/statistics/assessmentStatistics';

import CourseAPI from 'api/course';

import { statisticsActions as actions } from '../reducers/statistics';

export const fetchAssessmentStatistics = async (
assessmentId: number,
): Promise<MainAssessmentStats> => {
): Promise<void> => {
try {
dispatch(actions.reset());
const response =
Expand All @@ -24,7 +21,6 @@ export const fetchAssessmentStatistics = async (
ancestors: data.ancestors,
}),
);
return data;
} catch (error) {
if (error instanceof AxiosError) throw error.response?.data?.errors;
throw error;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import MainSubmissionChart from './SubmissionStatus/MainSubmissionChart';
import MainSubmissionTimeAndGradeStatistics from './SubmissionTimeAndGradeStatistics/MainSubmissionTimeAndGradeStatistics';
import DuplicationHistoryStatistics from './DuplicationHistoryStatistics';
import { getAssessmentStatistics } from './selectors';
import StudentAttemptCountTable from './StudentAttemptCountTable';
import StudentMarksPerQuestionTable from './StudentMarksPerQuestionTable';

const translations = defineMessages({
Expand Down Expand Up @@ -45,6 +46,10 @@ const translations = defineMessages({
id: 'course.assessment.statistics.marksPerQuestion',
defaultMessage: 'Marks Per Question',
},
attemptCount: {
id: 'course.assessment.statistics.attemptCount',
defaultMessage: 'Attempt Count',
},
gradeDistribution: {
id: 'course.assessment.statistics.gradeDistribution',
defaultMessage: 'Grade Distribution',
Expand All @@ -64,6 +69,7 @@ const tabMapping = (includePhantom: boolean): Record<string, JSX.Element> => {
marksPerQuestion: (
<StudentMarksPerQuestionTable includePhantom={includePhantom} />
),
attemptCount: <StudentAttemptCountTable includePhantom={includePhantom} />,
gradeDistribution: <MainGradesChart includePhantom={includePhantom} />,
submissionTimeAndGrade: (
<MainSubmissionTimeAndGradeStatistics includePhantom={includePhantom} />
Expand Down Expand Up @@ -124,6 +130,12 @@ const AssessmentStatisticsPage: FC = () => {
label={t(translations.marksPerQuestion)}
value="marksPerQuestion"
/>
<Tab
className="min-h-12"
id="attemptCount"
label={t(translations.attemptCount)}
value="attemptCount"
/>
<Tab
className="min-h-12"
id="gradeDistribution"
Expand Down
Loading