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

Show Answer Details in Assessment Statistics Page (Part 2: Last Graded Answer (Forum Post Response, Programming)) #7131

Open
wants to merge 45 commits into
base: bivan/teacher-stats-past-answer
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
a03ecf8
change SubmissionDoughnut to use BarChart
bivanalhar Jan 16, 2024
032071c
refactor AssessmentStatistics into tsx, add Tab
bivanalhar Jan 16, 2024
3e08697
design API to fetch mark per question statistics
bivanalhar Jan 18, 2024
00df536
table for question level student statistics
bivanalhar Jan 18, 2024
e918ec7
question statistics table can be downloaded into csv
bivanalhar Jan 18, 2024
2382024
resolve grader and tailwind issue, refactor controller
bivanalhar Jan 19, 2024
055e373
refactor controller and pages for stats
bivanalhar Jan 23, 2024
086b71d
remove redux usage from marks table
bivanalhar Jan 24, 2024
609b2b5
change SubmissionDoughnut to use BarChart
bivanalhar Jan 16, 2024
069d900
refactor charts and ancestors
bivanalhar Jan 19, 2024
997574c
refactor assessment statistics page
bivanalhar Jan 20, 2024
603163a
improve assessment statistics page UI
bivanalhar Jan 24, 2024
bdebbdd
refactor types
bivanalhar Jan 24, 2024
6f89042
add feature for phantom in statistics
bivanalhar Jan 25, 2024
2681bd6
add tests and fix behavior
bivanalhar Jan 26, 2024
99fdaa0
refactor assessment operations
bivanalhar Jan 29, 2024
b688fa4
maxGrade and title for assessment taken from redux
bivanalhar Jan 29, 2024
6ad03d4
refactor assessment statistics
bivanalhar Jan 29, 2024
6e89138
cleanup redundant variables
bivanalhar Jan 29, 2024
bb9a67b
simplify controller and API Call
bivanalhar Jan 30, 2024
6c39907
redefine types for assessment and statistics
bivanalhar Jan 30, 2024
1a30415
small modification for statisticsUtils function
bivanalhar Jan 30, 2024
a3ac662
componentise common chart for ancestor and main assessment
bivanalhar Jan 30, 2024
b8ad271
refactor ancestor assessment statistics chart
bivanalhar Jan 30, 2024
718d1d0
minor refactoring on redux store
bivanalhar Jan 31, 2024
04fd3c4
modify test cases for assessment statistics
bivanalhar Jan 31, 2024
b9b6afd
get attributes for attempt count from BE
bivanalhar Jan 31, 2024
3c6d13c
display Attempt Count Table
bivanalhar Jan 31, 2024
7883a8e
performance improvement and refactoring
bivanalhar Feb 2, 2024
98bca78
resolve n+1 issue in group names and refactor
bivanalhar Feb 2, 2024
cf96cb9
refactor(reference time): extract end_at from SQL
bivanalhar Feb 7, 2024
d304444
fix(statistics): show grade from last_attempt
bivanalhar Feb 16, 2024
45381c0
feat(statisticsTable): pass current answer ID to FE
bivanalhar Feb 8, 2024
c27ab2f
feat(statistics): render question details in each answer
bivanalhar Feb 13, 2024
39beed6
refactor(statistics): modify API to get answer
bivanalhar Feb 14, 2024
ff702a1
refactor(statistics): questionType -> type
bivanalhar Feb 14, 2024
0cfec9d
refactor(Answer): put only props that are necessary for question
bivanalhar Feb 14, 2024
a2fb2ff
refactor(statistics): minor refactoring in BE and FE
bivanalhar Feb 16, 2024
9aa1f9a
fix(statistics): pass id of last attempt answer to FE
bivanalhar Feb 16, 2024
f7caf48
refactor(type): expose all question types for reusing
bivanalhar Feb 19, 2024
755eb9b
feat(statistics): display answer for some question types
bivanalhar Feb 19, 2024
5829efd
refactor(statistics page): cancel -> close in answer details box
bivanalhar Feb 19, 2024
8419bdf
feat(answer_stats): implement forum answer view
bivanalhar Feb 20, 2024
90a3d48
feat(answer_stats): implement programming answer view
bivanalhar Feb 20, 2024
9e9c7a7
chore(answer_stats): remove unnecessary translations
bivanalhar Feb 20, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
personal_end_at = Course::PersonalTime.find_by_sql(<<-SQL.squish
WITH course_user_personal_end_at AS (
SELECT course_user_id, end_at
FROM course_personal_times cpt
JOIN (
SELECT course_lesson_plan_items.id
FROM course_lesson_plan_items
WHERE course_lesson_plan_items.actable_type = 'Course::Assessment'
AND course_lesson_plan_items.actable_id = #{assessment_params[:id]}
) clpi
ON cpt.lesson_plan_item_id = clpi.id
),

personal_times AS (
SELECT cu.id AS course_user_id, pt.end_at
FROM (
SELECT course_users.id
FROM course_users
WHERE course_users.course_id = #{@assessment.course.id}
) cu
LEFT JOIN (
SELECT course_user_id, end_at
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
FROM (
SELECT course_users.id, course_users.reference_timeline_id
FROM course_users
WHERE course_users.course_id = #{@assessment.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
FROM course_lesson_plan_items
WHERE course_lesson_plan_items.actable_type = 'Course::Assessment'
AND course_lesson_plan_items.actable_id = #{assessment_params[:id]}
) clpi
ON crt.lesson_plan_item_id = clpi.id
)

SELECT
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
SQL
)
personal_end_at.map { |pea| [pea.course_user_id, pea.end_at] }.to_h
end

def reference_times_hash
reference_times = Course::ReferenceTime.find_by_sql(<<-SQL.squish
SELECT clpi.actable_id AS lesson_plan_item_id, crt.end_at
FROM course_reference_times crt
JOIN (
SELECT id
FROM course_reference_timelines
WHERE course_id = #{@assessment.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 = #{assessment_params[:id]}
) clpi
ON crt.lesson_plan_item_id = clpi.id
SQL
)
reference_times.map { |rt| [rt.lesson_plan_item_id, rt.end_at] }.to_h
end
end
121 changes: 121 additions & 0 deletions app/controllers/concerns/course/statistics/submissions_concern.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# frozen_string_literal: true
module Course::Statistics::SubmissionsConcern
include Course::Statistics::ReferenceTimesConcern

private

def initialize_student_hash(students)
students.to_h { |student| [student, nil] }
end

def fetch_hash_for_main_assessment(submissions, students)
student_hash = initialize_student_hash(students)

populate_hash_including_answers(student_hash, submissions)
student_hash
end

def fetch_hash_for_ancestor_assessment(submissions, students)
student_hash = initialize_student_hash(students)

populate_hash_without_answers(student_hash, submissions)
student_hash
end

def answer_statistics_hash
submission_answer_statistics = Course::Assessment::Answer.find_by_sql(<<-SQL.squish
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.id, caa_ranked.grade, caa_ranked.correct, caa_ranked.workflow_state)) AS submission_info
FROM (
SELECT
caa_inner.id,
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
CASE WHEN jsonb_array_length(attempt_info.submission_info) = 1 OR attempt_info.submission_info->0->>3 != 'attempting'
THEN attempt_info.submission_info->0->>0 ELSE attempt_info.submission_info->1->>0
END AS last_attempt_answer_id,
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->>3 != 'attempting'
THEN attempt_info.submission_info->0->>1 ELSE attempt_info.submission_info->1->>1
END AS grade,
CASE WHEN jsonb_array_length(attempt_info.submission_info) = 1 OR attempt_info.submission_info->0->>3 != 'attempting'
THEN attempt_info.submission_info->0->>2 ELSE attempt_info.submission_info->1->>2
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).
transform_values do |grouped_answers|
grouped_answers.sort_by { |answer| @question_order_hash[answer.question_id] }
end
end

def populate_hash_including_answers(student_hash, submissions)
answers_hash = answer_statistics_hash
fetch_personal_and_reference_timeline_hash

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 = answers_hash[submission.id]
end_at = @personal_end_at_hash[submitter_course_user.id] || @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

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 = @personal_end_at_hash[submitter_course_user.id] || @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
@reference_times_hash = reference_times_hash
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
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
end
end
16 changes: 16 additions & 0 deletions app/controllers/course/statistics/answers_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class Course::Statistics::AnswersController < Course::Statistics::Controller
helper Course::Assessment::Submission::SubmissionsHelper.name.sub(/Helper$/, '')

def question_answer_details
@answer = Course::Assessment::Answer.find(answer_params[:id])
@submission = @answer.submission
@assessment = @submission.assessment
end

private

def answer_params
params.permit(:id)
end
end
68 changes: 47 additions & 21 deletions app/controllers/course/statistics/assessments_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
# frozen_string_literal: true
class Course::Statistics::AssessmentsController < Course::Statistics::Controller
def assessment
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(course: :course_users).first
submissions = Course::Assessment::Submission.where(assessment_id: assessment_params[:id]).
calculated(:grade, :grader_ids).
preload(creator: :course_users)
@course_users_hash = preload_course_users_hash(@assessment.course)

load_course_user_students_info
fetch_all_ancestor_assessments
create_question_related_hash

@assessment_autograded = @question_hash.any? { |_, (_, _, auto_gradable)| auto_gradable }
@student_submissions_hash = fetch_hash_for_main_assessment(submissions, @all_students)
end

def ancestor_statistics
@assessment = Course::Assessment.where(id: assessment_params[:id]).
calculated(:maximum_grade).
preload(lesson_plan_item: [:reference_times, personal_times: :course_user],
Expand All @@ -9,19 +30,9 @@ def assessment
submissions = Course::Assessment::Submission.preload(creator: :course_users).
where(assessment_id: assessment_params[:id]).
calculated(:grade)
@submission_records = compute_submission_records(submissions)
@all_students = @assessment.course.course_users.students
end

def ancestors
@assessment = Course::Assessment.preload(:duplication_traceable).find(assessment_params[:id])
@assessments = [@assessment]
while @assessment.duplication_traceable.present? && @assessment.duplication_traceable.source_id.present?
@assessment = @assessment.duplication_traceable.source
break unless can?(:read_ancestor, @assessment)

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

private
Expand All @@ -30,14 +41,29 @@ def assessment_params
params.permit(:id)
end

def compute_submission_records(submissions)
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?
def load_course_user_students_info
@all_students = current_course.course_users.students
@group_names_hash = group_names_hash
end

end_at = @assessment.lesson_plan_item.time_for(submitter_course_user).end_at
grade = submission.grade
[submitter_course_user, submission.submitted_at, end_at, grade]
end.compact
def fetch_all_ancestor_assessments
current_assessment = Course::Assessment.preload(:duplication_traceable).find(assessment_params[:id])
@ancestors = [current_assessment]
while current_assessment.duplication_traceable.present? &&
current_assessment.duplication_traceable.source_id.present?
current_assessment = current_assessment.duplication_traceable.source
break unless can?(:read_ancestor, current_assessment)

@ancestors.unshift(current_assessment)
end
end

def create_question_related_hash
@question_order_hash = @assessment.question_assessments.to_h do |q|
[q.question_id, q.weight]
end
@question_hash = @assessment.questions.to_h do |q|
[q.id, [q.maximum_grade, q.question_type, q.auto_gradable?]]
end
end
end
10 changes: 10 additions & 0 deletions app/models/course/assessment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,16 @@ class Course::Assessment < ApplicationRecord
)
end)

# @!attribute [r] question_count
# Gets the number of questions in this assessment.
# @return [Integer]
calculated :question_count, (lambda do
Course::QuestionAssessment.unscope(:order).
select('coalesce(count(DISTINCT cqa.question_id), 0)').
joins('INNER JOIN course_question_assessments cqa ON cqa.assessment_id = course_assessments.id').
group('course_assessments.id')
end)

# @!method self.ordered_by_date_and_title
# Orders the assessments by the starting date and title.
scope :ordered_by_date_and_title, (lambda do
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true
question = @answer.question

json.question do
json.id question.id
json.title question.title
json.maximumGrade question.maximum_grade
json.description format_ckeditor_rich_text(question.description)
json.type question.question_type

json.partial! question, question: question.specific, can_grade: false, answer: @answer
end

specific_answer = @answer.specific
json.answer do
json.id @answer.id
json.grade @answer.grade
json.questionType question.question_type
json.partial! specific_answer, answer: specific_answer, can_grade: false
end