From 8f2652ba49de9350ce84df4b4d6682a9bbefb250 Mon Sep 17 00:00:00 2001 From: yoopie Date: Wed, 25 Sep 2024 15:14:07 +0800 Subject: [PATCH 01/24] feat(programming-audit-trail): modify db add parentId for: - programming auto grading - programming question --- .../20240924091355_add_parent_id_for_programming.rb | 13 +++++++++++++ db/schema.rb | 8 +++++++- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20240924091355_add_parent_id_for_programming.rb diff --git a/db/migrate/20240924091355_add_parent_id_for_programming.rb b/db/migrate/20240924091355_add_parent_id_for_programming.rb new file mode 100644 index 0000000000..c24c09d36d --- /dev/null +++ b/db/migrate/20240924091355_add_parent_id_for_programming.rb @@ -0,0 +1,13 @@ +class AddParentIdForProgramming < ActiveRecord::Migration[7.0] + def change + add_column :course_assessment_answer_programming_auto_gradings, :parent_id, :integer + # Manual definition of name is needed because default name is too long + add_index :course_assessment_answer_programming_auto_gradings, :parent_id, name: 'index_ca_answer_programming_auto_gradings_on_parent_id' + add_foreign_key :course_assessment_answer_programming_auto_gradings, :course_assessment_answer_programming_auto_gradings, column: :parent_id + + add_column :course_assessment_question_programming, :parent_id, :integer + # Manual definition of name is needed because default name is too long + add_index :course_assessment_question_programming, :parent_id, name: 'index_ca_question_programming_on_parent_id' + add_foreign_key :course_assessment_question_programming, :course_assessment_question_programming, column: :parent_id + end +end diff --git a/db/schema.rb b/db/schema.rb index e0b38d80da..ecfcc6eccb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_09_17_170847) do +ActiveRecord::Schema[7.2].define(version: 2024_09_24_091355) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "uuid-ossp" @@ -141,6 +141,8 @@ t.text "stdout" t.text "stderr" t.integer "exit_code" + t.integer "parent_id" + t.index ["parent_id"], name: "index_ca_answer_programming_auto_gradings_on_parent_id" end create_table "course_assessment_answer_programming_file_annotations", id: :serial, force: :cascade do |t| @@ -314,8 +316,10 @@ t.text "codaveri_message" t.boolean "live_feedback_enabled", default: false, null: false t.string "live_feedback_custom_prompt", default: "", null: false + t.integer "parent_id" t.index ["import_job_id"], name: "index_course_assessment_question_programming_on_import_job_id", unique: true t.index ["language_id"], name: "fk__course_assessment_question_programming_language_id" + t.index ["parent_id"], name: "index_ca_question_programming_on_parent_id" end create_table "course_assessment_question_programming_template_files", id: :serial, force: :cascade do |t| @@ -1504,6 +1508,7 @@ add_foreign_key "course_assessment_answer_multiple_response_options", "course_assessment_answer_multiple_responses", column: "answer_id", name: "fk_course_assessment_answer_multiple_response_options_answer_id" add_foreign_key "course_assessment_answer_multiple_response_options", "course_assessment_question_multiple_response_options", column: "option_id", name: "fk_course_assessment_answer_multiple_response_options_option_id" add_foreign_key "course_assessment_answer_programming", "jobs", column: "codaveri_feedback_job_id", on_delete: :nullify + add_foreign_key "course_assessment_answer_programming_auto_gradings", "course_assessment_answer_programming_auto_gradings", column: "parent_id" add_foreign_key "course_assessment_answer_programming_file_annotations", "course_assessment_answer_programming_files", column: "file_id", name: "fk_course_assessment_answer_ed21459e7a2a5034dcf43a14812cb17d" add_foreign_key "course_assessment_answer_programming_files", "course_assessment_answer_programming", column: "answer_id", name: "fk_course_assessment_answer_programming_files_answer_id" add_foreign_key "course_assessment_answer_programming_test_results", "course_assessment_answer_programming_auto_gradings", column: "auto_grading_id", name: "fk_course_assessment_answer_e3d785447112439bb306849be8690102" @@ -1530,6 +1535,7 @@ add_foreign_key "course_assessment_question_bundles", "course_assessment_question_groups", column: "group_id" add_foreign_key "course_assessment_question_groups", "course_assessments", column: "assessment_id" add_foreign_key "course_assessment_question_multiple_response_options", "course_assessment_question_multiple_responses", column: "question_id", name: "fk_course_assessment_question_multiple_response_options_questio" + add_foreign_key "course_assessment_question_programming", "course_assessment_question_programming", column: "parent_id" add_foreign_key "course_assessment_question_programming", "jobs", column: "import_job_id", name: "fk_course_assessment_question_programming_import_job_id", on_delete: :nullify add_foreign_key "course_assessment_question_programming", "polyglot_languages", column: "language_id", name: "fk_course_assessment_question_programming_language_id" add_foreign_key "course_assessment_question_programming_template_files", "course_assessment_question_programming", column: "question_id", name: "fk_course_assessment_questi_0788633b496294e558f55f2b41bc7c45" From 03b021a3ac24da355ca3ce76deb1f4d2ac04908e Mon Sep 17 00:00:00 2001 From: yoopie Date: Tue, 22 Oct 2024 15:15:53 +0800 Subject: [PATCH 02/24] feat(programming-audit-trail): preserve old questions and tests --- .../question/programming_controller.rb | 43 +++++++++++++++++++ .../assessment/answer/auto_grading_service.rb | 3 ++ 2 files changed, 46 insertions(+) diff --git a/app/controllers/course/assessment/question/programming_controller.rb b/app/controllers/course/assessment/question/programming_controller.rb index 00007588a7..55448955ae 100644 --- a/app/controllers/course/assessment/question/programming_controller.rb +++ b/app/controllers/course/assessment/question/programming_controller.rb @@ -45,6 +45,7 @@ def edit def update result = @programming_question.class.transaction do + duplicate_and_replace_programming_question @question_assessment.skill_ids = programming_question_params[:question_assessment]. try(:[], :skill_ids) @programming_question.assign_attributes(programming_question_params. @@ -147,6 +148,48 @@ def destroy private + def duplicate_and_replace_programming_question + duplicated_programming_question = duplicate_programming_question + duplicate_associations(duplicated_programming_question) + save_duplicated_question(duplicated_programming_question) + link_to_original_question(duplicated_programming_question) + @programming_question = duplicated_programming_question + end + + def duplicate_programming_question + duplicated_programming_question = @programming_question.dup + # Unique field that needs to be reset + duplicated_programming_question.import_job_id = nil + duplicated_programming_question.question = @programming_question.question + duplicated_programming_question + end + + def duplicate_associations(duplicated_programming_question) + duplicated_programming_question.template_files = @programming_question.template_files.map do |template_file| + duplicated_template_file = template_file.dup + duplicated_template_file.question = duplicated_programming_question + duplicated_template_file + end + + duplicated_programming_question.test_cases = @programming_question.test_cases.map do |test_case| + duplicated_test_case = test_case.dup + duplicated_test_case.question = duplicated_programming_question + duplicated_test_case + end + end + + def save_duplicated_question(duplicated_programming_question) + duplicated_programming_question.save!(validate: false) + duplicated_programming_question.template_files.each(&:save!) + duplicated_programming_question.test_cases.each(&:save!) + end + + def link_to_original_question(duplicated_programming_question) + @programming_question.question.actable = duplicated_programming_question + # Update the original programming question's parent_id to link to the new duplicated question + duplicated_programming_question.update!(parent_id: @programming_question.id) + end + def format_test_cases @public_test_cases = [] @private_test_cases = [] diff --git a/app/services/course/assessment/answer/auto_grading_service.rb b/app/services/course/assessment/answer/auto_grading_service.rb index ba497b8dd4..09ed29a7cf 100644 --- a/app/services/course/assessment/answer/auto_grading_service.rb +++ b/app/services/course/assessment/answer/auto_grading_service.rb @@ -6,12 +6,15 @@ class << self # # @param [Course::Assessment::Answer] answer The answer to be graded. def grade(answer) + old_auto_grading_actable = answer.auto_grading&.actable answer = if answer.question.auto_gradable? pick_grader(answer.question).grade(answer) else assign_maximum_grade(answer) end + answer.save! + answer.auto_grading.actable.update!(parent_id: old_auto_grading_actable.id) if old_auto_grading_actable end private From 90f4fc9a1fa732aec1329fc84e7dd826365a3bfc Mon Sep 17 00:00:00 2001 From: yoopie Date: Wed, 2 Oct 2024 16:11:41 +0800 Subject: [PATCH 03/24] feat(custom-slider): add custom slider --- .../LiveFeedbackHistoryPage.tsx | 14 +- .../course/StudentPerformanceTable.tsx | 5 +- .../components/extensions/CustomSlider.tsx | 150 +++++++++++++++++- 3 files changed, 149 insertions(+), 20 deletions(-) diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackHistoryPage.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackHistoryPage.tsx index 56ddb2535c..157f634efd 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackHistoryPage.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackHistoryPage.tsx @@ -34,14 +34,15 @@ const LiveFeedbackHistoryPage: FC = (props) => { const [displayedIndex, setDisplayedIndex] = useState( nonEmptyLiveFeedbackHistory.length - 1, ); - const sliderMarks = nonEmptyLiveFeedbackHistory.map( + const sliderPoints = nonEmptyLiveFeedbackHistory.map( (liveFeedbackHistory, idx) => { return { - value: idx + 1, + value: idx, label: idx === 0 || idx === nonEmptyLiveFeedbackHistory.length - 1 ? formatLongDateTime(liveFeedbackHistory.createdAt) : '', + tooltip: `${idx + 1}`, }; }, ); @@ -72,15 +73,10 @@ const LiveFeedbackHistoryPage: FC = (props) => {
{ - setDisplayedIndex( - Array.isArray(value) ? value[0] - 1 : value - 1, - ); + setDisplayedIndex(Array.isArray(value) ? value[0] : value); }} - step={null} + points={sliderPoints} valueLabelDisplay="auto" />
diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/course/StudentPerformanceTable.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/course/StudentPerformanceTable.tsx index 92fe022b52..9fe808efd7 100644 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/course/StudentPerformanceTable.tsx +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/course/StudentPerformanceTable.tsx @@ -1,11 +1,10 @@ import { FC, useState } from 'react'; import { defineMessages } from 'react-intl'; -import { Card, CardContent, Typography } from '@mui/material'; +import { Card, CardContent, Slider, Typography } from '@mui/material'; import { CourseStudent, GroupManager } from 'course/statistics/types'; import LinearProgressWithLabel from 'lib/components/core/LinearProgressWithLabel'; import Link from 'lib/components/core/Link'; -import CustomSlider from 'lib/components/extensions/CustomSlider'; import Table, { ColumnTemplate } from 'lib/components/table'; import { DEFAULT_MINI_TABLE_ROWS_PER_PAGE, @@ -366,7 +365,7 @@ const StudentPerformanceTable: FC = (props) => { : t(translations.correctness), })} - (({ theme }) => ({ +const StyledSlider = styled(Slider)(() => ({ height: 8, '& .MuiSlider-mark': { - // Makes marks bigger - height: 6, - width: 6, - borderRadius: '50%', // Make the marks rounded - backgroundColor: '#555', + height: 4, + width: 4, + borderRadius: '50%', + backgroundColor: '#FFF', }, '& .MuiSlider-thumb': { height: 20, width: 20, }, '& .MuiSlider-rail': { - height: 5, + height: 3, }, '& .MuiSlider-track': { - height: 5, + height: 3, }, + '& .MuiSlider-markLabel': { + top: '40px', + }, + '& .MuiSlider-valueLabel': { + backgroundColor: '#00BCD4', + borderRadius: '5px', + '&:before': { + display: 'none', + }, + '& *': { + background: 'transparent', + color: '#FFF', + }, + }, +})); + +interface SliderPoint { + value: number; + label?: string; + tooltip?: string; +} + +export type SliderElement = SliderPoint | SliderElement[]; + +interface Props extends Omit { + points: SliderElement[]; +} + +const Bubble = styled('div')<{ + left: string; + width: string; +}>(({ left, width }) => ({ + position: 'absolute', + left, + width, + top: '13px', + height: '8px', + backgroundColor: `rgba(0, 188, 212, 0.8)`, + borderRadius: '5px', + transition: 'transform 0.2s', + pointerEvents: 'none', })); +const CustomSlider: FC = ({ points, ...sliderProps }) => { + const flattenPoints = (elements: SliderElement[]): SliderPoint[] => { + const result: SliderPoint[] = []; + elements.forEach((element) => { + if (Array.isArray(element)) { + result.push(...flattenPoints(element)); + } else { + result.push(element); + } + }); + return result; + }; + + const flattenedPoints = flattenPoints(points); + const values = flattenedPoints.map((p) => p.value); + const marks = flattenedPoints.map((p) => ({ + value: p.value, + label: p.label, + })); + + const minValue = Math.min(...values); + const maxValue = Math.max(...values); + + const renderBackgroundBubbles = ( + elements: SliderElement[], + ): JSX.Element[] => { + const bubbles: JSX.Element[] = []; + elements.forEach((element, index) => { + if (Array.isArray(element)) { + const childPoints = flattenPoints(element); + const childValues = childPoints.map((p) => p.value); + const groupMin = Math.min(...childValues); + const groupMax = Math.max(...childValues); + const leftPercent = + ((groupMin - minValue) / (maxValue - minValue)) * 100; + const rightPercent = + ((groupMax - minValue) / (maxValue - minValue)) * 100; + bubbles.push( + , + ); + bubbles.push(...renderBackgroundBubbles(element)); + } else { + bubbles.push( + , + ); + } + }); + return bubbles; + }; + + // Makes tooltip above the slider show 'tooltip' instead of 'value' + const valueLabelFormat = ( + index: number, + _value: number, + ): string | React.ReactNode => { + const point = flattenedPoints[index]; + return point?.tooltip ||
; + }; + + return ( +
+ {renderBackgroundBubbles(points)} + +
+ ); +}; + export default CustomSlider; From f7757d981047fe4ae968dd2c23cf17e7d8f21fef Mon Sep 17 00:00:00 2001 From: yoopie Date: Thu, 3 Oct 2024 18:00:21 +0800 Subject: [PATCH 04/24] feat(accordion): expand accordion functionality - include an option to add elipses beside the title to better indicate that accordion can be opened --- .../lib/components/core/layouts/Accordion.tsx | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/client/app/lib/components/core/layouts/Accordion.tsx b/client/app/lib/components/core/layouts/Accordion.tsx index eb1f14eb18..446118ac13 100644 --- a/client/app/lib/components/core/layouts/Accordion.tsx +++ b/client/app/lib/components/core/layouts/Accordion.tsx @@ -1,4 +1,4 @@ -import { ComponentProps, ReactNode } from 'react'; +import { ComponentProps, ReactNode, useState } from 'react'; import { ExpandMore } from '@mui/icons-material'; import { Accordion as MuiAccordion, @@ -13,11 +13,20 @@ interface AccordionProps extends ComponentProps { subtitle?: string; disabled?: boolean; icon?: ReactNode; + displayDotIndicator?: boolean; } const Accordion = (props: AccordionProps): JSX.Element => { - const { title, children, subtitle, disabled, icon, ...accordionProps } = - props; + const { + title, + children, + subtitle, + disabled, + icon, + displayDotIndicator, + ...accordionProps + } = props; + const [isExpanded, setIsExpanded] = useState(props.defaultExpanded ?? false); return ( { variant="outlined" {...accordionProps} className={`overflow-clip rounded-lg ${props.className ?? ''}`} - TransitionProps={{ - className: 'overflow-clip', + onChange={(_, expanded) => setIsExpanded(expanded)} + slotProps={{ + transition: { + className: 'overflow-clip', + }, }} > { className="space-x-2 px-9 py-6 hover:bg-neutral-100" expandIcon={icon || } > - {title} +
+ {title} + {!isExpanded && displayDotIndicator && ( + ... + )} +
{subtitle && ( From fbc1a333ea28721408ee681de953a7593938bfff Mon Sep 17 00:00:00 2001 From: yoopie Date: Thu, 3 Oct 2024 20:07:28 +0800 Subject: [PATCH 05/24] refactor(all-attempts-display): update accordion - make new of new accordion ellipses feature --- .../AnswerDisplay/AllAttemptsDisplay.tsx | 2 ++ client/app/lib/components/core/layouts/Accordion.tsx | 7 +------ 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx index 83e9f06840..e1d09a9cb5 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx @@ -76,6 +76,8 @@ const AllAttemptsDisplay: FC = (props) => { { subtitle, disabled, icon, - displayDotIndicator, + displayDotIndicator = false, ...accordionProps } = props; const [isExpanded, setIsExpanded] = useState(props.defaultExpanded ?? false); @@ -37,11 +37,6 @@ const Accordion = (props: AccordionProps): JSX.Element => { {...accordionProps} className={`overflow-clip rounded-lg ${props.className ?? ''}`} onChange={(_, expanded) => setIsExpanded(expanded)} - slotProps={{ - transition: { - className: 'overflow-clip', - }, - }} > Date: Tue, 22 Oct 2024 14:58:49 +0800 Subject: [PATCH 06/24] feat(question-attempts): add title card - add card to attempts dialog & all attempts page to display information and hold buttons - removed unused translations from AllAttempts.tsx - user info added for all attempts page to properly display name in the new card - links for going to submission page and attempts page shifted to be inside the card --- .../answers/all_answers.json.jbuilder | 5 ++ .../AnswerDisplay/AllAttempts.tsx | 43 +---------- .../AnswerDisplay/AllAttemptsDisplay.tsx | 77 +++++++++++++++---- .../StudentAttemptCountTable.tsx | 2 +- .../pages/QuestionIndex/PastAttempts.tsx | 1 + .../course/statistics/assessmentStatistics.ts | 1 + 6 files changed, 74 insertions(+), 55 deletions(-) diff --git a/app/views/course/statistics/answers/all_answers.json.jbuilder b/app/views/course/statistics/answers/all_answers.json.jbuilder index f48c6704c5..760ed88d1c 100644 --- a/app/views/course/statistics/answers/all_answers.json.jbuilder +++ b/app/views/course/statistics/answers/all_answers.json.jbuilder @@ -3,6 +3,11 @@ is_displayed = @submission.graded? || @submission.published? json.isAnswersDisplayed is_displayed +json.user do + json.name @submission.creator.name + json.id @submission.creator.id +end + if is_displayed json.question do json.id @question.id diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttempts.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttempts.tsx index cde6b2033d..dafc223284 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttempts.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttempts.tsx @@ -1,58 +1,27 @@ import { FC } from 'react'; -import { defineMessages } from 'react-intl'; import { useParams } from 'react-router-dom'; -import { Typography } from '@mui/material'; import { QuestionType } from 'types/course/assessment/question'; import { QuestionAnswerDetails } from 'types/course/statistics/assessmentStatistics'; import { fetchQuestionAnswerDetails } from 'course/assessment/operations/statistics'; -import Link from 'lib/components/core/Link'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import Preload from 'lib/components/wrappers/Preload'; import { getEditSubmissionQuestionURL, getPastAnswersURL, } from 'lib/helpers/url-builders'; -import useTranslation from 'lib/hooks/useTranslation'; import AllAttemptsDisplay from './AllAttemptsDisplay'; import Comment from './Comment'; -const translations = defineMessages({ - questionTitle: { - id: 'course.assessment.statistics.questionTitle', - defaultMessage: 'Question {index}', - }, - gradeDisplay: { - id: 'course.assessment.statistics.gradeDisplay', - defaultMessage: 'Grade: {grade} / {maxGrade}', - }, - morePastAnswers: { - id: 'course.assessment.statistics.morePastAnswers', - defaultMessage: 'View All Past Answers', - }, - currentAnswer: { - id: 'course.assessment.statistics.currentAnswer', - defaultMessage: 'Most Recent Answer', - }, - pastAnswerTitle: { - id: 'course.assessment.statistics.pastAnswerTitle', - defaultMessage: 'Submitted At: {submittedAt}', - }, - submissionPage: { - id: 'course.assessment.statistics.submissionPage', - defaultMessage: 'Go to Answer Page', - }, -}); - interface Props { curAnswerId: number; index: number; + name: string; } const AllAttemptsIndex: FC = (props) => { - const { curAnswerId, index } = props; - const { t } = useTranslation(); + const { curAnswerId, index, name } = props; const { courseId, assessmentId } = useParams(); const fetchQuestionAndCurrentAnswerDetails = (): Promise< @@ -77,6 +46,8 @@ const AllAttemptsIndex: FC = (props) => { <> = (props) => { )} /> - - - {t(translations.morePastAnswers)} - - - {data.comments.length > 0 && } ); diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx index e1d09a9cb5..9aa8ecdcf0 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx @@ -1,6 +1,13 @@ import { FC, useState } from 'react'; import { defineMessages } from 'react-intl'; -import { Typography } from '@mui/material'; +import { History, OpenInNew } from '@mui/icons-material'; +import { + Card, + CardHeader, + IconButton, + Tooltip, + Typography, +} from '@mui/material'; import { QuestionType } from 'types/course/assessment/question'; import { AllAnswerDetails, @@ -20,6 +27,8 @@ interface Props { question: QuestionDetails; questionNumber: number; submissionEditUrl: string; + pastAnswersURL?: string; + name: string; } const translations = defineMessages({ @@ -35,10 +44,25 @@ const translations = defineMessages({ id: 'course.assessment.statistics.submissionPage', defaultMessage: 'Go to Answer Page', }, + submittedAt: { + id: 'course.assessment.statistics.submittedAt', + defaultMessage: 'Submitted At', + }, + pastAnswers: { + id: 'course.assessment.statistics.pastAnswers', + defaultMessage: 'See All Past Answers', + }, }); const AllAttemptsDisplay: FC = (props) => { - const { allAnswers, question, questionNumber, submissionEditUrl } = props; + const { + allAnswers, + question, + questionNumber, + submissionEditUrl, + name, + pastAnswersURL, + } = props; const { t } = useTranslation(); @@ -69,11 +93,42 @@ const AllAttemptsDisplay: FC = (props) => { return ( <> - - - {t(translations.submissionPage)} - - + + + {pastAnswersURL && ( + + + + + + )} + + + + + +
+ } + title={{name}} + /> + + = (props) => { )} - - {t(translations.pastAnswerTitle, { - submittedAt: formatLongDateTime( - sortedAnswers[displayedIndex ?? answerSubmittedTimes.length - 1] - .createdAt, - ), - })} - = (props) => { maxWidth="lg" onClose={(): void => setOpenPastAnswers(false)} open={openPastAnswers} - title={answerInfo.studentName} > diff --git a/client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAttempts.tsx b/client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAttempts.tsx index 63545ce2f1..c7308a53b6 100644 --- a/client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAttempts.tsx +++ b/client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAttempts.tsx @@ -31,6 +31,7 @@ const PastAnswers: FC = () => { <> { isAnswersDisplayed: boolean; + user: UserInfo; question: QuestionDetails; allAnswers: AllAnswerDetails[]; submissionId: number; From a1951eb4df467e6511a95aeeb8e5a26806d78ab4 Mon Sep 17 00:00:00 2001 From: yoopie Date: Thu, 3 Oct 2024 18:17:32 +0800 Subject: [PATCH 07/24] refactor(statistics): change naming of types - QuestionDetails -> Question - AllAnswerDetails -> Answer - previous naming was verbose and communicated the wrong idea of what information the type has - free up naming for use in details components --- .../AnswerDetails/AnswerDetails.tsx | 4 ++-- .../AnswerDisplay/AllAttemptsDisplay.tsx | 9 +++------ .../course/statistics/assessmentStatistics.ts | 14 +++++++------- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AnswerDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AnswerDetails.tsx index 5a8dd5cc15..bfaca4ed48 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AnswerDetails.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AnswerDetails.tsx @@ -2,7 +2,7 @@ import { defineMessages } from 'react-intl'; import { Card, CardContent } from '@mui/material'; import { QuestionType } from 'types/course/assessment/question'; import { AnswerDetailsMap } from 'types/course/statistics/answer'; -import { QuestionDetails } from 'types/course/statistics/assessmentStatistics'; +import { Question } from 'types/course/statistics/assessmentStatistics'; import useTranslation from 'lib/hooks/useTranslation'; @@ -22,7 +22,7 @@ const translations = defineMessages({ }); interface AnswerDetailsProps { - question: QuestionDetails; + question: Question; answer: AnswerDetailsMap[T]; } diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx index 9aa8ecdcf0..267c6ad79f 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx @@ -9,10 +9,7 @@ import { Typography, } from '@mui/material'; import { QuestionType } from 'types/course/assessment/question'; -import { - AllAnswerDetails, - QuestionDetails, -} from 'types/course/statistics/assessmentStatistics'; +import { Answer, Question } from 'types/course/statistics/assessmentStatistics'; import Accordion from 'lib/components/core/layouts/Accordion'; import Link from 'lib/components/core/Link'; @@ -23,8 +20,8 @@ import { formatLongDateTime } from 'lib/moment'; import AnswerDetails from '../AnswerDetails/AnswerDetails'; interface Props { - allAnswers: AllAnswerDetails[]; - question: QuestionDetails; + allAnswers: Answer[]; + question: Question; questionNumber: number; submissionEditUrl: string; pastAnswersURL?: string; diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index e0eb2a5f31..a1936352fc 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -104,10 +104,10 @@ export interface CommentItem { text: string; } -export type QuestionDetails = +export type Question = QuestionBasicDetails & SpecificQuestionDataMap[T]; -export type AllAnswerDetails = +export type Answer = AnswerDetailsMap[T] & { createdAt: Date; currentAnswer: boolean; @@ -115,9 +115,9 @@ export type AllAnswerDetails = }; export interface QuestionAnswerDetails { - question: QuestionDetails; + question: Question; answer: AnswerDetailsMap[T]; - allAnswers: AllAnswerDetails[]; + allAnswers: Answer[]; comments: CommentItem[]; submissionId: number; submissionQuestionId: number; @@ -126,7 +126,7 @@ export interface QuestionAnswerDetails { export interface QuestionAnswerDisplayDetails< T extends keyof typeof QuestionType, > { - question: QuestionDetails; + question: Question; answer: AnswerDetailsMap[T]; } @@ -135,8 +135,8 @@ export interface QuestionAllAnswerDisplayDetails< > { isAnswersDisplayed: boolean; user: UserInfo; - question: QuestionDetails; - allAnswers: AllAnswerDetails[]; + question: Question; + allAnswers: Answer[]; submissionId: number; comments: CommentItem[]; } From ce47b564f6f9272ebcd6759fe5c90aa39ba86c4f Mon Sep 17 00:00:00 2001 From: yoopie Date: Thu, 3 Oct 2024 20:07:00 +0800 Subject: [PATCH 08/24] refactor(all-attempts-display): use new slider --- .../AnswerDisplay/AllAttemptsDisplay.tsx | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx index 267c6ad79f..d6ee54cfb0 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx @@ -13,7 +13,7 @@ import { Answer, Question } from 'types/course/statistics/assessmentStatistics'; import Accordion from 'lib/components/core/layouts/Accordion'; import Link from 'lib/components/core/Link'; -import CustomSlider from 'lib/components/extensions/CustomSlider'; +import Slider from 'lib/components/extensions/CustomSlider'; import useTranslation from 'lib/hooks/useTranslation'; import { formatLongDateTime } from 'lib/moment'; @@ -83,7 +83,6 @@ const AllAttemptsDisplay: FC = (props) => { const currentAnswerMarker = answerSubmittedTimes[answerSubmittedTimes.length - 1]; - const earliestAnswerMarker = answerSubmittedTimes[0]; const [displayedIndex, setDisplayedIndex] = useState( currentAnswerMarker.value, ); @@ -125,6 +124,18 @@ const AllAttemptsDisplay: FC = (props) => { title={{name}} /> + {answerSubmittedTimes.length > 1 && ( +
+ { + setDisplayedIndex(Array.isArray(value) ? value[0] : value); + }} + points={answerSubmittedTimes} + valueLabelDisplay="auto" + /> +
+ )} = (props) => { /> - {answerSubmittedTimes.length > 1 && ( -
- { - setDisplayedIndex(Array.isArray(value) ? value[0] : value); - }} - step={null} - valueLabelDisplay="off" - /> -
- )} Date: Thu, 3 Oct 2024 20:10:14 +0800 Subject: [PATCH 09/24] refactor(all-attempts-display): change created_at to submitted_at - submitted_at is more indicative of the actual data --- app/controllers/course/statistics/answers_controller.rb | 4 ++-- app/views/course/statistics/answers/all_answers.json.jbuilder | 2 +- .../statistics/answers/question_answer_details.json.jbuilder | 2 +- .../AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx | 3 ++- client/app/types/course/statistics/assessmentStatistics.ts | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/controllers/course/statistics/answers_controller.rb b/app/controllers/course/statistics/answers_controller.rb index fd85fcdd6c..3f6cb90a1b 100644 --- a/app/controllers/course/statistics/answers_controller.rb +++ b/app/controllers/course/statistics/answers_controller.rb @@ -33,7 +33,7 @@ def all_answers @question_index = question_index(question_id) @all_answers = Course::Assessment::Answer. unscope(:order). - order(:created_at). + order(:submitted_at). where(submission_id: submission_id, question_id: question_id) end @@ -59,7 +59,7 @@ def question_index(question_id) def fetch_all_answers(submission_id, question_id) answers = Course::Assessment::Answer. unscope(:order). - order(created_at: :desc). + order(submitted_at: :desc). where(submission_id: submission_id, question_id: question_id) current_answer = answers.find(&:current_answer?) diff --git a/app/views/course/statistics/answers/all_answers.json.jbuilder b/app/views/course/statistics/answers/all_answers.json.jbuilder index 760ed88d1c..4d951e161c 100644 --- a/app/views/course/statistics/answers/all_answers.json.jbuilder +++ b/app/views/course/statistics/answers/all_answers.json.jbuilder @@ -22,7 +22,7 @@ if is_displayed json.allAnswers @all_answers do |answer| json.partial! 'answer', answer: answer, question: @question - json.createdAt answer.created_at&.iso8601 + json.submittedAt answer.submitted_at&.iso8601 json.currentAnswer answer.current_answer json.workflowState answer.workflow_state end diff --git a/app/views/course/statistics/answers/question_answer_details.json.jbuilder b/app/views/course/statistics/answers/question_answer_details.json.jbuilder index 4b19f721d0..2e3b932bd8 100644 --- a/app/views/course/statistics/answers/question_answer_details.json.jbuilder +++ b/app/views/course/statistics/answers/question_answer_details.json.jbuilder @@ -17,7 +17,7 @@ end json.allAnswers @all_answers do |answer| json.partial! 'answer', answer: answer, question: question - json.createdAt answer.created_at&.iso8601 + json.submittedAt answer.submitted_at&.iso8601 json.currentAnswer answer.current_answer json.workflowState answer.workflow_state end diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx index d6ee54cfb0..3506cd78cd 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx @@ -75,7 +75,7 @@ const AllAttemptsDisplay: FC = (props) => { value: idx, label: idx === 0 || idx === sortedAnswers.length - 1 - ? formatLongDateTime(answer.createdAt) + ? formatLongDateTime(answer.submittedAt) : '', }; }); @@ -124,6 +124,7 @@ const AllAttemptsDisplay: FC = (props) => { title={{name}} /> + {answerSubmittedTimes.length > 1 && (
= export type Answer = AnswerDetailsMap[T] & { - createdAt: Date; + submittedAt: Date; currentAnswer: boolean; workflowState: WorkflowState; }; From e5d1afa8900944f5a07719981c1a90dca441e3ec Mon Sep 17 00:00:00 2001 From: yoopie Date: Thu, 3 Oct 2024 20:12:45 +0800 Subject: [PATCH 10/24] perf(all-attempts): improve answer retrieval --- .../course/statistics/answers_controller.rb | 6 ++---- .../AnswerDisplay/AllAttemptsDisplay.tsx | 13 +++---------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/app/controllers/course/statistics/answers_controller.rb b/app/controllers/course/statistics/answers_controller.rb index 3f6cb90a1b..bbcda3c7c5 100644 --- a/app/controllers/course/statistics/answers_controller.rb +++ b/app/controllers/course/statistics/answers_controller.rb @@ -59,11 +59,9 @@ def question_index(question_id) def fetch_all_answers(submission_id, question_id) answers = Course::Assessment::Answer. unscope(:order). - order(submitted_at: :desc). + order(:submitted_at). where(submission_id: submission_id, question_id: question_id) - current_answer = answers.find(&:current_answer?) - @all_answers = answers.where(current_answer: false).limit(MAX_ANSWERS_COUNT - 1).to_a.reverse - @all_answers.unshift(current_answer) + @all_answers = answers.limit(MAX_ANSWERS_COUNT) end end diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx index 3506cd78cd..ea29a0de38 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx @@ -63,18 +63,13 @@ const AllAttemptsDisplay: FC = (props) => { const { t } = useTranslation(); - const currentAnswer = allAnswers.find((answer) => answer.currentAnswer); - const sortedAnswers = allAnswers.filter((answer) => !answer.currentAnswer); - - sortedAnswers.push(currentAnswer!); - // TODO: distance between points inside Slider to be reflective towards the time distance // (for example, the distance between 1:00PM to 1:01PM should not be equal to 1:00PM to 2:00PM) - const answerSubmittedTimes = sortedAnswers.map((answer, idx) => { + const answerSubmittedTimes = allAnswers.map((answer, idx) => { return { value: idx, label: - idx === 0 || idx === sortedAnswers.length - 1 + idx === 0 || idx === allAnswers.length - 1 ? formatLongDateTime(answer.submittedAt) : '', }; @@ -158,9 +153,7 @@ const AllAttemptsDisplay: FC = (props) => { From 76a2afd7a767c4763ce7a2ccadf98e3567488c67 Mon Sep 17 00:00:00 2001 From: yoopie Date: Tue, 15 Oct 2024 16:16:47 +0800 Subject: [PATCH 11/24] feat(all-attempts-display): add more programming question details --- .../programming/_programming.json.jbuilder | 12 ++- .../AnswerDisplay/AllAttemptsDisplay.tsx | 13 +++- .../ProgrammingQuestionDetails.tsx | 71 +++++++++++++++++ .../QuestionDetails/QuestionDetails.tsx | 76 +++++++++++++++++++ .../assessment/submission/question/types.ts | 5 +- 5 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/QuestionDetails/ProgrammingQuestionDetails.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/QuestionDetails/QuestionDetails.tsx diff --git a/app/views/course/assessment/question/programming/_programming.json.jbuilder b/app/views/course/assessment/question/programming/_programming.json.jbuilder index 94e76594af..2a20f85498 100644 --- a/app/views/course/assessment/question/programming/_programming.json.jbuilder +++ b/app/views/course/assessment/question/programming/_programming.json.jbuilder @@ -1,7 +1,13 @@ # frozen_string_literal: true json.language question.language.name -json.fileSubmission question.multiple_file_submission + +json.memoryLimit question.memory_limit if question.memory_limit +json.timeLimit question.time_limit if question.time_limit json.attemptLimit question.attempt_limit if question.attempt_limit + +json.fileSubmission question.multiple_file_submission? + json.autogradable question.auto_gradable? -json.isCodaveri question.is_codaveri -json.liveFeedbackEnabled question.live_feedback_enabled + +json.isCodaveri question.is_codaveri? +json.liveFeedbackEnabled question.live_feedback_enabled? diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx index ea29a0de38..cde960fabe 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx @@ -18,6 +18,7 @@ import useTranslation from 'lib/hooks/useTranslation'; import { formatLongDateTime } from 'lib/moment'; import AnswerDetails from '../AnswerDetails/AnswerDetails'; +import QuestionDetails from '../QuestionDetails/QuestionDetails'; interface Props { allAnswers: Answer[]; @@ -49,6 +50,10 @@ const translations = defineMessages({ id: 'course.assessment.statistics.pastAnswers', defaultMessage: 'See All Past Answers', }, + questionDetailsTitle: { + id: 'course.assessment.statistics.questionDetailsTitle', + defaultMessage: 'More Details', + }, }); const AllAttemptsDisplay: FC = (props) => { @@ -141,7 +146,7 @@ const AllAttemptsDisplay: FC = (props) => { index: questionNumber, })} > -
+
{question.title} = (props) => { variant="body2" />
+
+ + {t(translations.questionDetailsTitle)} + + +
; +} + +const ProgrammingQuestionDetails: FC = (props): JSX.Element => { + const { question } = props; + const { t } = useTranslation(); + + return ( + + + + Language + {question.language || '-'} + + + Memory Limit + + {question.memoryLimit + ? t(translations.memoryLimit, { + memoryLimit: question.memoryLimit, + }) + : '-'} + + + + Time Limit + + {question.timeLimit + ? t(translations.timeLimit, { timeLimit: question.timeLimit }) + : '-'} + + + + Attempt Limit + {question.attemptLimit || '-'} + + + Is Codaveri + {question.isCodaveri ? '✅' : '❌'} + + + Live Feedback Enabled + {question.liveFeedbackEnabled ? '✅' : '❌'} + + +
+ ); +}; + +export default ProgrammingQuestionDetails; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/QuestionDetails/QuestionDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/QuestionDetails/QuestionDetails.tsx new file mode 100644 index 0000000000..0260fb0a13 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/QuestionDetails/QuestionDetails.tsx @@ -0,0 +1,76 @@ +import { defineMessages } from 'react-intl'; +import { Card, CardContent } from '@mui/material'; +import { QuestionType } from 'types/course/assessment/question'; +import { Question } from 'types/course/statistics/assessmentStatistics'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import ProgrammingQuestionDetails from './ProgrammingQuestionDetails'; + +const translations = defineMessages({ + rendererNotImplemented: { + id: 'course.assessment.submission.Question.rendererNotImplemented', + defaultMessage: + 'The display for this question type has not been implemented yet.', + }, +}); + +interface QuestionDetailsProps { + question: Question; +} + +const QuestionNotImplemented = (): JSX.Element => { + const { t } = useTranslation(); + + return ( + + {t(translations.rendererNotImplemented)} + + ); +}; + +// TODO: define component for unimplemented parts +export const QuestionDetailsMapper = { + MultipleChoice: ( + _props: QuestionDetailsProps<'MultipleChoice'>, + ): JSX.Element => , + MultipleResponse: ( + _props: QuestionDetailsProps<'MultipleResponse'>, + ): JSX.Element => , + TextResponse: (_props: QuestionDetailsProps<'TextResponse'>): JSX.Element => ( + + ), + FileUpload: (_props: QuestionDetailsProps<'FileUpload'>): JSX.Element => ( + + ), + ForumPostResponse: ( + _props: QuestionDetailsProps<'ForumPostResponse'>, + ): JSX.Element => , + Programming: (props: QuestionDetailsProps<'Programming'>): JSX.Element => ( + + ), + VoiceResponse: ( + _props: QuestionDetailsProps<'VoiceResponse'>, + ): JSX.Element => , + Scribing: (_props: QuestionDetailsProps<'Scribing'>): JSX.Element => ( + + ), + Comprehension: ( + _props: QuestionDetailsProps<'Comprehension'>, + ): JSX.Element => , +}; + +const QuestionDetails = ( + props: QuestionDetailsProps, +): JSX.Element => { + const Component = QuestionDetailsMapper[props.question.type]; + + // "Any" type is used here as the props are dynamically generated + // depending on the different question type and typescript + // does not support union typing for the elements. + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return Component({ ...props } as any); +}; + +export default QuestionDetails; diff --git a/client/app/types/course/assessment/submission/question/types.ts b/client/app/types/course/assessment/submission/question/types.ts index 0c8b9f51fb..555c23e7d1 100644 --- a/client/app/types/course/assessment/submission/question/types.ts +++ b/client/app/types/course/assessment/submission/question/types.ts @@ -15,9 +15,12 @@ interface MultipleResponseQuestionData { interface ProgrammingQuestionData { language: string; + memoryLimit?: number; + timeLimit?: number; + attemptLimit?: number; fileSubmission: boolean; isCodaveri: boolean; - attemptLimit?: number; + liveFeedbackEnabled: boolean; } interface TextResponseParentQuestionData {} From c5da9865855a1fffa044fae99e103302fe92ed31 Mon Sep 17 00:00:00 2001 From: yoopie Date: Tue, 22 Oct 2024 15:11:44 +0800 Subject: [PATCH 12/24] refactor(attempts-stats): refactor backend --- .../course/statistics/answers_controller.rb | 51 +++++++++++-------- .../statistics/answers/_details.json.jbuilder | 16 ++++++ .../answers/_question.json.jbuilder | 12 +++++ .../answers/all_answers.json.jbuilder | 30 ++--------- .../question_answer_details.json.jbuilder | 28 +--------- 5 files changed, 62 insertions(+), 75 deletions(-) create mode 100644 app/views/course/statistics/answers/_details.json.jbuilder create mode 100644 app/views/course/statistics/answers/_question.json.jbuilder diff --git a/app/controllers/course/statistics/answers_controller.rb b/app/controllers/course/statistics/answers_controller.rb index bbcda3c7c5..85fcbdd712 100644 --- a/app/controllers/course/statistics/answers_controller.rb +++ b/app/controllers/course/statistics/answers_controller.rb @@ -5,36 +5,37 @@ class Course::Statistics::AnswersController < Course::Statistics::Controller MAX_ANSWERS_COUNT = 10 def question_answer_details - @answer = Course::Assessment::Answer.find(answer_params[:id]) - @submission = @answer.submission + answer = Course::Assessment::Answer.find(answer_params[:id]) + @submission = answer.submission + @question = answer.question @assessment = @submission.assessment + submission_id = answer.submission_id + question_id = answer.question_id + + @question_index = question_index(question_id) + @submission_question = Course::Assessment::SubmissionQuestion. - where(submission_id: @answer.submission_id, question_id: @answer.question_id). + where(submission_id: submission_id, question_id: question_id). includes(actable: { files: { annotations: { discussion_topic: { posts: :codaveri_feedback } } } }, discussion_topic: { posts: :codaveri_feedback }).first - fetch_all_answers(@answer.submission_id, @answer.question_id) + fetch_all_answers(submission_id, question_id, MAX_ANSWERS_COUNT) end def all_answers @submission_question = Course::Assessment::SubmissionQuestion.find(submission_question_params[:id]) - submission_id = @submission_question.submission_id - @submission = Course::Assessment::Submission.find(submission_id) + @submission = @submission_question.submission + @question = @submission_question.question + @assessment = @submission.assessment + submission_id = @submission_question.submission_id question_id = @submission_question.question_id - @question = Course::Assessment::Question.find(question_id) - @assessment = @submission.assessment - @submission_question = Course::Assessment::SubmissionQuestion. - where(submission_id: submission_id, question_id: question_id). - includes({ discussion_topic: { posts: :codaveri_feedback } }).first @question_index = question_index(question_id) - @all_answers = Course::Assessment::Answer. - unscope(:order). - order(:submitted_at). - where(submission_id: submission_id, question_id: question_id) + + fetch_all_answers(submission_id, question_id, -1) end private @@ -56,12 +57,18 @@ def question_index(question_id) question_ids.index(question_id) end - def fetch_all_answers(submission_id, question_id) - answers = Course::Assessment::Answer. - unscope(:order). - order(:submitted_at). - where(submission_id: submission_id, question_id: question_id) - - @all_answers = answers.limit(MAX_ANSWERS_COUNT) + def fetch_all_answers(submission_id, question_id, limit) + @all_answers = if limit == -1 + Course::Assessment::Answer. + unscope(:order). + order(:submitted_at). + where(submission_id: submission_id, question_id: question_id) + else + Course::Assessment::Answer. + unscope(:order). + order(:submitted_at). + where(submission_id: submission_id, question_id: question_id). + limit(limit) + end end end diff --git a/app/views/course/statistics/answers/_details.json.jbuilder b/app/views/course/statistics/answers/_details.json.jbuilder new file mode 100644 index 0000000000..739ad08f19 --- /dev/null +++ b/app/views/course/statistics/answers/_details.json.jbuilder @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +json.partial! 'question' + +json.allAnswers @all_answers do |answer| + json.partial! 'answer', answer: answer, question: @question + json.submittedAt answer.submitted_at&.iso8601 + json.currentAnswer answer.current_answer + json.workflowState answer.workflow_state +end + +posts = @submission_question.discussion_topic.posts + +json.comments posts do |post| + json.partial! post, post: post if post.published? +end diff --git a/app/views/course/statistics/answers/_question.json.jbuilder b/app/views/course/statistics/answers/_question.json.jbuilder new file mode 100644 index 0000000000..d54a309fe9 --- /dev/null +++ b/app/views/course/statistics/answers/_question.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +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.questionNumber @question_index + 1 + + json.partial! @question, question: @question.specific, can_grade: false, answer: @all_answers.first +end diff --git a/app/views/course/statistics/answers/all_answers.json.jbuilder b/app/views/course/statistics/answers/all_answers.json.jbuilder index 4d951e161c..fec715351f 100644 --- a/app/views/course/statistics/answers/all_answers.json.jbuilder +++ b/app/views/course/statistics/answers/all_answers.json.jbuilder @@ -3,35 +3,13 @@ is_displayed = @submission.graded? || @submission.published? json.isAnswersDisplayed is_displayed -json.user do - json.name @submission.creator.name - json.id @submission.creator.id -end - if is_displayed - 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.questionNumber @question_index + 1 - - json.partial! @question, question: @question.specific, can_grade: false, answer: @all_answers.first + json.user do + json.name @submission.creator.name + json.id @submission.creator.id end - json.allAnswers @all_answers do |answer| - json.partial! 'answer', answer: answer, question: @question - json.submittedAt answer.submitted_at&.iso8601 - json.currentAnswer answer.current_answer - json.workflowState answer.workflow_state - end + json.partial! 'details' json.submissionId @submission.id - - posts = @submission_question.discussion_topic.posts - - json.comments posts do |post| - json.partial! post, post: post if post.published? - end end diff --git a/app/views/course/statistics/answers/question_answer_details.json.jbuilder b/app/views/course/statistics/answers/question_answer_details.json.jbuilder index 2e3b932bd8..94403369c8 100644 --- a/app/views/course/statistics/answers/question_answer_details.json.jbuilder +++ b/app/views/course/statistics/answers/question_answer_details.json.jbuilder @@ -1,32 +1,6 @@ # 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 - -json.answer do - json.partial! 'answer', answer: @answer, question: question -end - -json.allAnswers @all_answers do |answer| - json.partial! 'answer', answer: answer, question: question - json.submittedAt answer.submitted_at&.iso8601 - json.currentAnswer answer.current_answer - json.workflowState answer.workflow_state -end - -posts = @submission_question.discussion_topic.posts - -json.comments posts do |post| - json.partial! post, post: post if post.published? -end +json.partial! 'details' json.submissionId @submission.id json.submissionQuestionId @submission_question.id From 9e408e812e19d515e5f680ba9f5fd52c950694bb Mon Sep 17 00:00:00 2001 From: yoopie Date: Tue, 22 Oct 2024 15:50:46 +0800 Subject: [PATCH 13/24] refactor(statistics): use seperate API for latest answer - changed naming for files and added specific typing for latest answer - fix bug where if a question is added after submission, the page crashes --- .../course/statistics/answers_controller.rb | 18 +++++++++++++++ .../answers/_question.json.jbuilder | 2 +- .../answers/latest_answer.json.jbuilder | 18 +++++++++++++++ .../api/course/Statistics/AnswerStatistics.ts | 11 ++++++++- .../assessment/operations/statistics.ts | 10 ++++++++ ...astAttempt.tsx => LatestAnswerDisplay.tsx} | 23 ++++++++----------- .../StudentMarksPerQuestionTable.tsx | 9 ++++---- .../course/statistics/assessmentStatistics.ts | 7 ++++++ config/routes.rb | 5 ++-- 9 files changed, 82 insertions(+), 21 deletions(-) create mode 100644 app/views/course/statistics/answers/latest_answer.json.jbuilder rename client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/{LastAttempt.tsx => LatestAnswerDisplay.tsx} (83%) diff --git a/app/controllers/course/statistics/answers_controller.rb b/app/controllers/course/statistics/answers_controller.rb index 85fcbdd712..7b9c3e87f2 100644 --- a/app/controllers/course/statistics/answers_controller.rb +++ b/app/controllers/course/statistics/answers_controller.rb @@ -4,6 +4,24 @@ class Course::Statistics::AnswersController < Course::Statistics::Controller MAX_ANSWERS_COUNT = 10 + def latest_answer + @answer = Course::Assessment::Answer.find(answer_params[:id]) + @submission = @answer.submission + @question = @answer.question + @assessment = @submission.assessment + + submission_id = @answer.submission_id + question_id = @answer.question_id + + @question_index = question_index(question_id) + + @submission_question = Course::Assessment::SubmissionQuestion. + where(submission_id: submission_id, question_id: question_id). + includes(actable: { files: { annotations: + { discussion_topic: { posts: :codaveri_feedback } } } }, + discussion_topic: { posts: :codaveri_feedback }).first + end + def question_answer_details answer = Course::Assessment::Answer.find(answer_params[:id]) @submission = answer.submission diff --git a/app/views/course/statistics/answers/_question.json.jbuilder b/app/views/course/statistics/answers/_question.json.jbuilder index d54a309fe9..b9f736450f 100644 --- a/app/views/course/statistics/answers/_question.json.jbuilder +++ b/app/views/course/statistics/answers/_question.json.jbuilder @@ -8,5 +8,5 @@ json.question do json.type @question.question_type json.questionNumber @question_index + 1 - json.partial! @question, question: @question.specific, can_grade: false, answer: @all_answers.first + json.partial! @question, question: @question.specific, can_grade: false, answer: @answer end diff --git a/app/views/course/statistics/answers/latest_answer.json.jbuilder b/app/views/course/statistics/answers/latest_answer.json.jbuilder new file mode 100644 index 0000000000..1747cb934c --- /dev/null +++ b/app/views/course/statistics/answers/latest_answer.json.jbuilder @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +json.partial! 'question' + +json.answer do + json.partial! 'answer', answer: @answer, question: @question + json.submittedAt @answer.submitted_at&.iso8601 + json.currentAnswer @answer.current_answer + json.workflowState @answer.workflow_state +end + +posts = @submission_question.discussion_topic.posts + +json.comments posts do |post| + json.partial! post, post: post if post.published? +end + +json.submissionId @submission.id diff --git a/client/app/api/course/Statistics/AnswerStatistics.ts b/client/app/api/course/Statistics/AnswerStatistics.ts index aed45b75b0..83ab2170a3 100644 --- a/client/app/api/course/Statistics/AnswerStatistics.ts +++ b/client/app/api/course/Statistics/AnswerStatistics.ts @@ -1,5 +1,8 @@ import { QuestionType } from 'types/course/assessment/question'; -import { QuestionAnswerDetails } from 'types/course/statistics/assessmentStatistics'; +import { + LatestAnswer, + QuestionAnswerDetails, +} from 'types/course/statistics/assessmentStatistics'; import { APIResponse } from 'api/types'; @@ -15,4 +18,10 @@ export default class AnswerStatisticsAPI extends BaseCourseAPI { ): APIResponse> { return this.client.get(`${this.#urlPrefix}/${answerId}`); } + + fetchLatestAnswer( + answerId: number, + ): APIResponse> { + return this.client.get(`${this.#urlPrefix}/${answerId}/latest_answer`); + } } diff --git a/client/app/bundles/course/assessment/operations/statistics.ts b/client/app/bundles/course/assessment/operations/statistics.ts index e1a0c5ee5a..89c106c23f 100644 --- a/client/app/bundles/course/assessment/operations/statistics.ts +++ b/client/app/bundles/course/assessment/operations/statistics.ts @@ -4,6 +4,7 @@ import { QuestionType } from 'types/course/assessment/question'; import { AncestorAssessmentStats, AssessmentLiveFeedbackStatistics, + LatestAnswer, QuestionAllAnswerDisplayDetails, QuestionAnswerDetails, } from 'types/course/statistics/assessmentStatistics'; @@ -42,6 +43,15 @@ export const fetchAncestorStatistics = async ( return response.data; }; +export const fetchLatestAnswer = async ( + answerId: number, +): Promise> => { + const response = + await CourseAPI.statistics.answer.fetchLatestAnswer(answerId); + + return response.data; +}; + export const fetchQuestionAnswerDetails = async ( answerId: number, ): Promise> => { diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/LastAttempt.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/LatestAnswerDisplay.tsx similarity index 83% rename from client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/LastAttempt.tsx rename to client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/LatestAnswerDisplay.tsx index 7481e9f95c..a4f00d26d5 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/LastAttempt.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/LatestAnswerDisplay.tsx @@ -3,9 +3,9 @@ import { defineMessages } from 'react-intl'; import { useParams } from 'react-router-dom'; import { Chip, Typography } from '@mui/material'; import { QuestionType } from 'types/course/assessment/question'; -import { QuestionAnswerDetails } from 'types/course/statistics/assessmentStatistics'; +import { LatestAnswer } from 'types/course/statistics/assessmentStatistics'; -import { fetchQuestionAnswerDetails } from 'course/assessment/operations/statistics'; +import { fetchLatestAnswer } from 'course/assessment/operations/statistics'; import Accordion from 'lib/components/core/layouts/Accordion'; import Link from 'lib/components/core/Link'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; @@ -38,26 +38,23 @@ const translations = defineMessages({ }); interface Props { - curAnswerId: number; + currAnswerId: number; index: number; } -const LastAttemptIndex: FC = (props) => { - const { curAnswerId, index } = props; +const LatestAnswerDisplay: FC = (props) => { + const { currAnswerId, index } = props; const { courseId, assessmentId } = useParams(); const { t } = useTranslation(); - const fetchQuestionAndCurrentAnswerDetails = (): Promise< - QuestionAnswerDetails + const fetchLatestAnswerDetails = (): Promise< + LatestAnswer > => { - return fetchQuestionAnswerDetails(curAnswerId); + return fetchLatestAnswer(currAnswerId); }; return ( - } - while={fetchQuestionAndCurrentAnswerDetails} - > + } while={fetchLatestAnswerDetails}> {(data): JSX.Element => { const gradeCellColor = getClassNameForMarkCell( data.answer.grade, @@ -109,4 +106,4 @@ const LastAttemptIndex: FC = (props) => { ); }; -export default LastAttemptIndex; +export default LatestAnswerDisplay; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index 2cb0e17e17..b1d19bfae0 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -15,7 +15,7 @@ import { getEditSubmissionURL } from 'lib/helpers/url-builders'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; -import LastAttemptIndex from './AnswerDisplay/LastAttempt'; +import LatestAnswerDisplay from './AnswerDisplay/LatestAnswerDisplay'; import { getClassNameForMarkCell } from './classNameUtils'; import { getAssessmentStatistics } from './selectors'; import translations from './translations'; @@ -107,7 +107,8 @@ const StudentMarksPerQuestionTable: FC = (props) => { }, title: t(translations.questionIndex, { index: index + 1 }), cell: (datum): ReactNode => { - return typeof datum.answers?.[index].grade === 'number' ? ( + return datum.answers?.[index] && + typeof datum.answers?.[index].grade === 'number' ? ( renderAnswerGradeClickableCell(index, datum) ) : (
@@ -265,8 +266,8 @@ const StudentMarksPerQuestionTable: FC = (props) => { open={openAnswer} title={answerDisplayInfo.studentName} > - diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index f41963ae6e..c3f5244425 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -114,6 +114,13 @@ export type Answer = workflowState: WorkflowState; }; +export interface LatestAnswer { + question: Question; + answer: Answer; + comments: CommentItem[]; + submissionId: number; +} + export interface QuestionAnswerDetails { question: Question; answer: AnswerDetailsMap[T]; diff --git a/config/routes.rb b/config/routes.rb index db2b504c7e..e83371465e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -438,6 +438,9 @@ namespace :statistics do get '/' => 'statistics#index' get 'answer/:id' => 'answers#question_answer_details' + get 'answer/:id/latest_answer' => 'answers#latest_answer' + get 'assessment/:id/main_statistics' => 'assessments#main_statistics' + get 'assessment/:id/ancestor_statistics' => 'assessments#ancestor_statistics' get 'assessments' => 'aggregate#all_assessments' get 'students' => 'aggregate#all_students' get 'staff' => 'aggregate#all_staff' @@ -445,8 +448,6 @@ get 'course/performance' => 'aggregate#course_performance' get 'submission_question/:id' => 'answers#all_answers' get 'user/:user_id/learning_rate_records' => 'users#learning_rate_records' - get 'assessment/:id/main_statistics' => 'assessments#main_statistics' - get 'assessment/:id/ancestor_statistics' => 'assessments#ancestor_statistics' get 'assessment/:id/live_feedback_statistics' => 'assessments#live_feedback_statistics' get 'assessment/:id/live_feedback_history' => 'assessments#live_feedback_history' end From a1b387f6fac12adf138c3a06083350ac3a7588b8 Mon Sep 17 00:00:00 2001 From: yoopie Date: Tue, 22 Oct 2024 16:01:54 +0800 Subject: [PATCH 14/24] feat(statistics-marks-table): add card to dialog --- .../AnswerDisplay/LatestAnswerDisplay.tsx | 82 +++++++++++++++---- .../StudentMarksPerQuestionTable.tsx | 2 +- 2 files changed, 68 insertions(+), 16 deletions(-) diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/LatestAnswerDisplay.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/LatestAnswerDisplay.tsx index a4f00d26d5..774d68d704 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/LatestAnswerDisplay.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/LatestAnswerDisplay.tsx @@ -1,7 +1,20 @@ import { FC } from 'react'; import { defineMessages } from 'react-intl'; import { useParams } from 'react-router-dom'; -import { Chip, Typography } from '@mui/material'; +import { OpenInNew } from '@mui/icons-material'; +import { + Card, + CardHeader, + Chip, + IconButton, + Table, + TableBody, + TableCell, + TableRow, + Tooltip, + Typography, +} from '@mui/material'; +import { green } from '@mui/material/colors'; import { QuestionType } from 'types/course/assessment/question'; import { LatestAnswer } from 'types/course/statistics/assessmentStatistics'; @@ -12,6 +25,7 @@ import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import Preload from 'lib/components/wrappers/Preload'; import { getEditSubmissionQuestionURL } from 'lib/helpers/url-builders'; import useTranslation from 'lib/hooks/useTranslation'; +import { formatLongDateTime } from 'lib/moment'; import AnswerDetails from '../AnswerDetails/AnswerDetails'; import { getClassNameForMarkCell } from '../classNameUtils'; @@ -35,15 +49,20 @@ const translations = defineMessages({ id: 'course.assessment.statistics.submissionPage', defaultMessage: 'Go to Answer Page', }, + submittedAt: { + id: 'course.assessment.statistics.submittedAt', + defaultMessage: 'Submitted At', + }, }); interface Props { currAnswerId: number; index: number; + name: string; } const LatestAnswerDisplay: FC = (props) => { - const { currAnswerId, index } = props; + const { currAnswerId, index, name } = props; const { courseId, assessmentId } = useParams(); const { t } = useTranslation(); @@ -60,23 +79,56 @@ const LatestAnswerDisplay: FC = (props) => { data.answer.grade, data.question.maximumGrade, ); + const submissionEditUrl = getEditSubmissionQuestionURL( + courseId, + assessmentId, + data.submissionId, + index, + ); return ( <> - - - {t(translations.submissionPage)} - - + + + + + + + +
+ } + style={{ backgroundColor: green[100] }} + title={{name}} + /> + + + + + + {t(translations.submittedAt)} + + + {formatLongDateTime(data.answer.submittedAt)} + + + +
+ +
diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index b1d19bfae0..b65637f66b 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -264,11 +264,11 @@ const StudentMarksPerQuestionTable: FC = (props) => { maxWidth="lg" onClose={(): void => setOpenAnswer(false)} open={openAnswer} - title={answerDisplayInfo.studentName} > From dd0c028fd46f206a78883c5d8c931b86923b4aac Mon Sep 17 00:00:00 2001 From: yoopie Date: Wed, 9 Oct 2024 15:47:13 +0800 Subject: [PATCH 15/24] refactor(statistics): change naming, add limit setting to FE - name change from answers -> attempts - change limit from a constant backend value to a constant FE value, add function for limiting dynamically --- .../course/statistics/answers_controller.rb | 10 ++++------ ...s.json.jbuilder => all_attempts.json.jbuilder} | 0 ...tails.json.jbuilder => attempts.json.jbuilder} | 0 .../api/course/Statistics/AllAnswerStatistics.ts | 2 +- .../app/api/course/Statistics/AnswerStatistics.ts | 15 +++++++++++++-- .../course/assessment/operations/statistics.ts | 13 ++++++++----- .../AnswerDisplay/AllAttempts.tsx | 6 ++++-- .../pages/QuestionIndex/PastAttempts.tsx | 2 +- config/routes.rb | 4 ++-- 9 files changed, 33 insertions(+), 19 deletions(-) rename app/views/course/statistics/answers/{all_answers.json.jbuilder => all_attempts.json.jbuilder} (100%) rename app/views/course/statistics/answers/{question_answer_details.json.jbuilder => attempts.json.jbuilder} (100%) diff --git a/app/controllers/course/statistics/answers_controller.rb b/app/controllers/course/statistics/answers_controller.rb index 7b9c3e87f2..9b2214f357 100644 --- a/app/controllers/course/statistics/answers_controller.rb +++ b/app/controllers/course/statistics/answers_controller.rb @@ -2,8 +2,6 @@ class Course::Statistics::AnswersController < Course::Statistics::Controller helper Course::Assessment::Submission::SubmissionsHelper.name.sub(/Helper$/, '') - MAX_ANSWERS_COUNT = 10 - def latest_answer @answer = Course::Assessment::Answer.find(answer_params[:id]) @submission = @answer.submission @@ -22,7 +20,7 @@ def latest_answer discussion_topic: { posts: :codaveri_feedback }).first end - def question_answer_details + def attempts answer = Course::Assessment::Answer.find(answer_params[:id]) @submission = answer.submission @question = answer.question @@ -39,10 +37,10 @@ def question_answer_details { discussion_topic: { posts: :codaveri_feedback } } } }, discussion_topic: { posts: :codaveri_feedback }).first - fetch_all_answers(submission_id, question_id, MAX_ANSWERS_COUNT) + fetch_all_answers(submission_id, question_id, answer_params[:limit].to_i) end - def all_answers + def all_attempts @submission_question = Course::Assessment::SubmissionQuestion.find(submission_question_params[:id]) @submission = @submission_question.submission @question = @submission_question.question @@ -59,7 +57,7 @@ def all_answers private def answer_params - params.permit(:id) + params.permit(:id, :limit) end def submission_question_params diff --git a/app/views/course/statistics/answers/all_answers.json.jbuilder b/app/views/course/statistics/answers/all_attempts.json.jbuilder similarity index 100% rename from app/views/course/statistics/answers/all_answers.json.jbuilder rename to app/views/course/statistics/answers/all_attempts.json.jbuilder diff --git a/app/views/course/statistics/answers/question_answer_details.json.jbuilder b/app/views/course/statistics/answers/attempts.json.jbuilder similarity index 100% rename from app/views/course/statistics/answers/question_answer_details.json.jbuilder rename to app/views/course/statistics/answers/attempts.json.jbuilder diff --git a/client/app/api/course/Statistics/AllAnswerStatistics.ts b/client/app/api/course/Statistics/AllAnswerStatistics.ts index 44ceb2c155..7c37f3413a 100644 --- a/client/app/api/course/Statistics/AllAnswerStatistics.ts +++ b/client/app/api/course/Statistics/AllAnswerStatistics.ts @@ -10,7 +10,7 @@ export default class AllAnswerStatisticsAPI extends BaseCourseAPI { return `/courses/${this.courseId}/statistics/submission_question`; } - fetchAllAnswers( + fetchAllAttempts( submissionQuestionId: number, ): APIResponse> { return this.client.get(`${this.#urlPrefix}/${submissionQuestionId}`); diff --git a/client/app/api/course/Statistics/AnswerStatistics.ts b/client/app/api/course/Statistics/AnswerStatistics.ts index 83ab2170a3..13b40801f2 100644 --- a/client/app/api/course/Statistics/AnswerStatistics.ts +++ b/client/app/api/course/Statistics/AnswerStatistics.ts @@ -13,10 +13,21 @@ export default class AnswerStatisticsAPI extends BaseCourseAPI { return `/courses/${this.courseId}/statistics/answer`; } - fetchQuestionAnswerDetails( + fetchAllAttempts( + submissionQuestionId: number, + ): APIResponse> { + return this.client.get(`${this.#urlPrefix}/${submissionQuestionId}`); + } + + fetchAttempts( answerId: number, + limit: number, ): APIResponse> { - return this.client.get(`${this.#urlPrefix}/${answerId}`); + return this.client.get(`${this.#urlPrefix}/${answerId}`, { + params: { + limit, + }, + }); } fetchLatestAnswer( diff --git a/client/app/bundles/course/assessment/operations/statistics.ts b/client/app/bundles/course/assessment/operations/statistics.ts index 89c106c23f..f3127c6b4e 100644 --- a/client/app/bundles/course/assessment/operations/statistics.ts +++ b/client/app/bundles/course/assessment/operations/statistics.ts @@ -52,20 +52,23 @@ export const fetchLatestAnswer = async ( return response.data; }; -export const fetchQuestionAnswerDetails = async ( +export const fetchAttempts = async ( answerId: number, + limit: number, ): Promise> => { - const response = - await CourseAPI.statistics.answer.fetchQuestionAnswerDetails(answerId); + const response = await CourseAPI.statistics.answer.fetchAttempts( + answerId, + limit, + ); return response.data; }; -export const fetchAllAnswers = async ( +export const fetchAllAttempts = async ( submissionQuestionId: number, ): Promise> => { const response = - await CourseAPI.statistics.allAnswer.fetchAllAnswers(submissionQuestionId); + await CourseAPI.statistics.allAnswer.fetchAllAttempts(submissionQuestionId); return response.data; }; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttempts.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttempts.tsx index dafc223284..43e662e398 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttempts.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttempts.tsx @@ -14,6 +14,8 @@ import { import AllAttemptsDisplay from './AllAttemptsDisplay'; import Comment from './Comment'; +const LIMIT = 10; + interface Props { curAnswerId: number; index: number; @@ -24,10 +26,10 @@ const AllAttemptsIndex: FC = (props) => { const { curAnswerId, index, name } = props; const { courseId, assessmentId } = useParams(); - const fetchQuestionAndCurrentAnswerDetails = (): Promise< + const fetchAttemptDetails = (): Promise< QuestionAnswerDetails > => { - return fetchQuestionAnswerDetails(curAnswerId); + return fetchAttempts(curAnswerId, LIMIT); }; return ( diff --git a/client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAttempts.tsx b/client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAttempts.tsx index c7308a53b6..a572dc3fba 100644 --- a/client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAttempts.tsx +++ b/client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAttempts.tsx @@ -21,7 +21,7 @@ const PastAnswers: FC = () => { const fetchAnswers = (): Promise< QuestionAllAnswerDisplayDetails > => { - return fetchAllAnswers(parsedSubmissionQuestionId); + return fetchAllAttempts(parsedSubmissionQuestionId); }; return ( diff --git a/config/routes.rb b/config/routes.rb index e83371465e..1d6ef1f1ed 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -437,8 +437,9 @@ namespace :statistics do get '/' => 'statistics#index' - get 'answer/:id' => 'answers#question_answer_details' get 'answer/:id/latest_answer' => 'answers#latest_answer' + get 'answer/:id' => 'answers#attempts' + get 'submission_question/:id' => 'answers#all_attempts' get 'assessment/:id/main_statistics' => 'assessments#main_statistics' get 'assessment/:id/ancestor_statistics' => 'assessments#ancestor_statistics' get 'assessments' => 'aggregate#all_assessments' @@ -446,7 +447,6 @@ get 'staff' => 'aggregate#all_staff' get 'course/progression' => 'aggregate#course_progression' get 'course/performance' => 'aggregate#course_performance' - get 'submission_question/:id' => 'answers#all_answers' get 'user/:user_id/learning_rate_records' => 'users#learning_rate_records' get 'assessment/:id/live_feedback_statistics' => 'assessments#live_feedback_statistics' get 'assessment/:id/live_feedback_history' => 'assessments#live_feedback_history' From 9d2fd4935e59ad85d52aa2aeaa51aa0e7a07ae73 Mon Sep 17 00:00:00 2001 From: yoopie Date: Wed, 9 Oct 2024 16:15:09 +0800 Subject: [PATCH 16/24] feat(statistics): add BE support for multiple tests / questions --- .../course/statistics/answers_controller.rb | 30 ++++++-- .../course/statistics/answers_helper.rb | 16 ++++ .../answer/programming_auto_grading.rb | 2 + .../course/assessment/question/programming.rb | 1 + .../answers/_all_questions.json.jbuilder | 12 +++ .../statistics/answers/_answer.json.jbuilder | 11 ++- .../statistics/answers/_details.json.jbuilder | 9 ++- .../answers/_programming_answer.json.jbuilder | 77 +++++++++++++++++++ .../answers/latest_answer.json.jbuilder | 3 - 9 files changed, 149 insertions(+), 12 deletions(-) create mode 100644 app/helpers/course/statistics/answers_helper.rb create mode 100644 app/views/course/statistics/answers/_all_questions.json.jbuilder create mode 100644 app/views/course/statistics/answers/_programming_answer.json.jbuilder diff --git a/app/controllers/course/statistics/answers_controller.rb b/app/controllers/course/statistics/answers_controller.rb index 9b2214f357..8d069656d4 100644 --- a/app/controllers/course/statistics/answers_controller.rb +++ b/app/controllers/course/statistics/answers_controller.rb @@ -21,13 +21,13 @@ def latest_answer end def attempts - answer = Course::Assessment::Answer.find(answer_params[:id]) - @submission = answer.submission - @question = answer.question + @answer = Course::Assessment::Answer.find(answer_params[:id]) + @submission = @answer.submission + @question = @answer.question @assessment = @submission.assessment - submission_id = answer.submission_id - question_id = answer.question_id + submission_id = @answer.submission_id + question_id = @answer.question_id @question_index = question_index(question_id) @@ -38,6 +38,7 @@ def attempts discussion_topic: { posts: :codaveri_feedback }).first fetch_all_answers(submission_id, question_id, answer_params[:limit].to_i) + fetch_all_actable_questions(@question) end def all_attempts @@ -52,6 +53,7 @@ def all_attempts @question_index = question_index(question_id) fetch_all_answers(submission_id, question_id, -1) + fetch_all_actable_questions(@question) end private @@ -87,4 +89,22 @@ def fetch_all_answers(submission_id, question_id, limit) limit(limit) end end + + def fetch_all_actable_questions(question) + unless versioned_question?(question) + @all_actable_questions = [question.actable] + return + end + + question = question.actable + @all_actable_questions = [question] + while question.parent + @all_actable_questions << question.parent + question = question.parent + end + end + + def versioned_question?(question) + question.actable.is_a?(Course::Assessment::Question::Programming) + end end diff --git a/app/helpers/course/statistics/answers_helper.rb b/app/helpers/course/statistics/answers_helper.rb new file mode 100644 index 0000000000..a0a1cc95bc --- /dev/null +++ b/app/helpers/course/statistics/answers_helper.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Course::Statistics::AnswersHelper + def get_historical_auto_gradings(programming_auto_grading) + historical = [] + current = programming_auto_grading + + while current&.parent_id + parent = Course::Assessment::Answer::ProgrammingAutoGrading.find_by(id: current.parent_id) + historical.unshift(parent) if parent + current = parent + end + + historical + end +end diff --git a/app/models/course/assessment/answer/programming_auto_grading.rb b/app/models/course/assessment/answer/programming_auto_grading.rb index 85b3d3b4e4..c39e320729 100644 --- a/app/models/course/assessment/answer/programming_auto_grading.rb +++ b/app/models/course/assessment/answer/programming_auto_grading.rb @@ -7,6 +7,8 @@ class Course::Assessment::Answer::ProgrammingAutoGrading < ApplicationRecord validates :exit_code, numericality: { only_integer: true }, allow_nil: true + belongs_to :parent, class_name: 'Course::Assessment::Answer::ProgrammingAutoGrading', optional: true + has_one :programming_answer, through: :answer, source: :actable, source_type: 'Course::Assessment::Answer::Programming' diff --git a/app/models/course/assessment/question/programming.rb b/app/models/course/assessment/question/programming.rb index 513c665489..1a94f6e289 100644 --- a/app/models/course/assessment/question/programming.rb +++ b/app/models/course/assessment/question/programming.rb @@ -40,6 +40,7 @@ class Course::Assessment::Question::Programming < ApplicationRecord # rubocop:di belongs_to :import_job, class_name: 'TrackableJob::Job', inverse_of: nil, optional: true belongs_to :language, class_name: 'Coursemology::Polyglot::Language', inverse_of: nil + belongs_to :parent, class_name: 'Course::Assessment::Question::Programming', optional: true has_one_attachment has_many :template_files, class_name: 'Course::Assessment::Question::ProgrammingTemplateFile', dependent: :destroy, foreign_key: :question_id, inverse_of: :question diff --git a/app/views/course/statistics/answers/_all_questions.json.jbuilder b/app/views/course/statistics/answers/_all_questions.json.jbuilder new file mode 100644 index 0000000000..87d3d48f46 --- /dev/null +++ b/app/views/course/statistics/answers/_all_questions.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.allQuestions @all_actable_questions do |question| + 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.questionNumber @question_index + 1 + + json.partial! question, question: question, can_grade: false, answer: @all_answers.first +end diff --git a/app/views/course/statistics/answers/_answer.json.jbuilder b/app/views/course/statistics/answers/_answer.json.jbuilder index b00e6a00ca..86402f6742 100644 --- a/app/views/course/statistics/answers/_answer.json.jbuilder +++ b/app/views/course/statistics/answers/_answer.json.jbuilder @@ -4,7 +4,12 @@ specific_answer = answer.specific json.id answer.id json.grade answer.grade json.questionType question.question_type -json.partial! specific_answer, answer: specific_answer, can_grade: false + +if answer.actable_type == Course::Assessment::Answer::Programming.name && question.auto_gradable? + json.partial! 'programming_answer', answer: specific_answer, can_grade: false +else + json.partial! specific_answer, answer: specific_answer, can_grade: false +end if answer.actable_type == Course::Assessment::Answer::Programming.name files = answer.specific.files @@ -16,3 +21,7 @@ if answer.actable_type == Course::Assessment::Answer::Programming.name json.partial! post, post: post if post.published? end end + +json.submittedAt answer.submitted_at&.iso8601 +json.currentAnswer answer.current_answer +json.workflowState answer.workflow_state diff --git a/app/views/course/statistics/answers/_details.json.jbuilder b/app/views/course/statistics/answers/_details.json.jbuilder index 739ad08f19..6a582d535e 100644 --- a/app/views/course/statistics/answers/_details.json.jbuilder +++ b/app/views/course/statistics/answers/_details.json.jbuilder @@ -2,11 +2,14 @@ json.partial! 'question' +json.partial! 'all_questions' + +json.answer do + json.partial! 'answer', answer: @answer, question: @question +end + json.allAnswers @all_answers do |answer| json.partial! 'answer', answer: answer, question: @question - json.submittedAt answer.submitted_at&.iso8601 - json.currentAnswer answer.current_answer - json.workflowState answer.workflow_state end posts = @submission_question.discussion_topic.posts diff --git a/app/views/course/statistics/answers/_programming_answer.json.jbuilder b/app/views/course/statistics/answers/_programming_answer.json.jbuilder new file mode 100644 index 0000000000..26cb7ff7f0 --- /dev/null +++ b/app/views/course/statistics/answers/_programming_answer.json.jbuilder @@ -0,0 +1,77 @@ +# frozen_string_literal: true +question = @question.specific + +# If a non current_answer is being loaded, use it instead of loading the last_attempt. +is_current_answer = answer.current_answer? +latest_answer = last_attempt(answer) +attempt = is_current_answer ? latest_answer : answer +auto_grading = attempt&.auto_grading&.specific + +json.fields do + json.questionId answer.question_id + json.id answer.acting_as.id + json.files_attributes answer.files do |file| + json.(file, :id, :filename) + json.content file.content + json.highlightedContent highlight_code_block(file.content, question.language) + end +end + +can_read_tests = can?(:read_tests, @submission) +show_private = can_read_tests || (@submission.published? && @assessment.show_private?) +show_evaluation = can_read_tests || (@submission.published? && @assessment.show_evaluation?) + +test_cases_by_type = question.test_cases_by_type +test_cases_and_results = get_test_cases_and_results(test_cases_by_type, auto_grading) + +show_stdout_and_stderr = (can_read_tests || current_course.show_stdout_and_stderr) && + auto_grading && auto_grading&.exit_code != 0 + +displayed_test_case_types = ['public_test'] +displayed_test_case_types << 'private_test' if show_private +displayed_test_case_types << 'evaluation_test' if show_evaluation + +json.testCases do + json.canReadTests can_read_tests + + # Get all historical auto gradings + historical_auto_gradings = get_historical_auto_gradings(auto_grading) + + # Include current and historical auto gradings + json.tests (historical_auto_gradings + [auto_grading]).each do |ag| + question = ag.test_results.first.test_case.question + test_cases_by_type = question.test_cases_by_type + test_cases_and_results = get_test_cases_and_results(test_cases_by_type, ag) + + json.questionId question.id + displayed_test_case_types.each do |test_case_type| + show_public = (test_case_type == 'public_test') && current_course.show_public_test_cases_output + show_testcase_outputs = can_read_tests || show_public + json.set! test_case_type do + if test_cases_and_results[test_case_type].present? + json.array! test_cases_and_results[test_case_type] do |test_case, test_result| + json.identifier test_case.identifier if can_read_tests + json.expression test_case.expression + json.expected test_case.expected + if test_result + json.output get_output(test_result) if show_testcase_outputs + json.passed test_result.passed? + end + end + + end + end + json.(ag, :stdout, :stderr) if show_stdout_and_stderr + end + end +end + +if answer.codaveri_feedback_job_id && question.is_codaveri + codaveri_job = answer.codaveri_feedback_job + json.codaveriFeedback do + json.jobId answer.codaveri_feedback_job_id + json.jobStatus codaveri_job.status + json.jobUrl job_path(codaveri_job) if codaveri_job.status == 'submitted' + json.errorMessage codaveri_job.error['message'] if codaveri_job.error + end +end diff --git a/app/views/course/statistics/answers/latest_answer.json.jbuilder b/app/views/course/statistics/answers/latest_answer.json.jbuilder index 1747cb934c..324f3663f3 100644 --- a/app/views/course/statistics/answers/latest_answer.json.jbuilder +++ b/app/views/course/statistics/answers/latest_answer.json.jbuilder @@ -4,9 +4,6 @@ json.partial! 'question' json.answer do json.partial! 'answer', answer: @answer, question: @question - json.submittedAt @answer.submitted_at&.iso8601 - json.currentAnswer @answer.current_answer - json.workflowState @answer.workflow_state end posts = @submission_question.discussion_topic.posts From e36e2479695b1310874184ad28daee3803b8c5f3 Mon Sep 17 00:00:00 2001 From: yoopie Date: Tue, 15 Oct 2024 14:31:47 +0800 Subject: [PATCH 17/24] feat(statistics): multiple regrading view for attempts - updated types for processed vs non processed data - removed unnecessary data from jbuilders - added parsing / processing of programming question data and linked to display --- .../statistics/answers/_answer.json.jbuilder | 2 +- .../statistics/answers/_details.json.jbuilder | 6 -- .../answers/_programming_answer.json.jbuilder | 46 +++++----- .../answers/latest_answer.json.jbuilder | 23 ++++- .../course/Statistics/AllAnswerStatistics.ts | 4 +- .../api/course/Statistics/AnswerStatistics.ts | 6 -- .../assessment/operations/statistics.ts | 4 +- .../AnswerDetails/AnswerDetails.tsx | 8 +- .../AnswerDisplay/AllAttempts.tsx | 9 +- .../AnswerDisplay/AllAttemptsDisplay.tsx | 54 +++++------ .../AnswerDisplay/utils.ts | 89 +++++++++++++++++++ .../pages/QuestionIndex/PastAttempts.tsx | 12 +-- .../assessment/submission/question/types.ts | 1 + client/app/types/course/statistics/answer.ts | 39 ++++---- .../course/statistics/assessmentStatistics.ts | 33 +++---- 15 files changed, 217 insertions(+), 119 deletions(-) create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/utils.ts diff --git a/app/views/course/statistics/answers/_answer.json.jbuilder b/app/views/course/statistics/answers/_answer.json.jbuilder index 86402f6742..19bca2ab05 100644 --- a/app/views/course/statistics/answers/_answer.json.jbuilder +++ b/app/views/course/statistics/answers/_answer.json.jbuilder @@ -5,7 +5,7 @@ json.id answer.id json.grade answer.grade json.questionType question.question_type -if answer.actable_type == Course::Assessment::Answer::Programming.name && question.auto_gradable? +if answer.actable_type == Course::Assessment::Answer::Programming.name json.partial! 'programming_answer', answer: specific_answer, can_grade: false else json.partial! specific_answer, answer: specific_answer, can_grade: false diff --git a/app/views/course/statistics/answers/_details.json.jbuilder b/app/views/course/statistics/answers/_details.json.jbuilder index 6a582d535e..08cdaaac08 100644 --- a/app/views/course/statistics/answers/_details.json.jbuilder +++ b/app/views/course/statistics/answers/_details.json.jbuilder @@ -1,13 +1,7 @@ # frozen_string_literal: true -json.partial! 'question' - json.partial! 'all_questions' -json.answer do - json.partial! 'answer', answer: @answer, question: @question -end - json.allAnswers @all_answers do |answer| json.partial! 'answer', answer: answer, question: @question end diff --git a/app/views/course/statistics/answers/_programming_answer.json.jbuilder b/app/views/course/statistics/answers/_programming_answer.json.jbuilder index 26cb7ff7f0..a78c989782 100644 --- a/app/views/course/statistics/answers/_programming_answer.json.jbuilder +++ b/app/views/course/statistics/answers/_programming_answer.json.jbuilder @@ -31,38 +31,34 @@ displayed_test_case_types = ['public_test'] displayed_test_case_types << 'private_test' if show_private displayed_test_case_types << 'evaluation_test' if show_evaluation -json.testCases do - json.canReadTests can_read_tests +historical_auto_gradings = get_historical_auto_gradings(auto_grading) - # Get all historical auto gradings - historical_auto_gradings = get_historical_auto_gradings(auto_grading) +json.testCases (historical_auto_gradings + [auto_grading]).each do |ag| + next if ag.nil? # To account for autogradings with no test results (programming with no autograding) - # Include current and historical auto gradings - json.tests (historical_auto_gradings + [auto_grading]).each do |ag| - question = ag.test_results.first.test_case.question - test_cases_by_type = question.test_cases_by_type - test_cases_and_results = get_test_cases_and_results(test_cases_by_type, ag) + question = ag.test_results.first.test_case.question + test_cases_by_type = question.test_cases_by_type + test_cases_and_results = get_test_cases_and_results(test_cases_by_type, ag) - json.questionId question.id - displayed_test_case_types.each do |test_case_type| - show_public = (test_case_type == 'public_test') && current_course.show_public_test_cases_output - show_testcase_outputs = can_read_tests || show_public - json.set! test_case_type do - if test_cases_and_results[test_case_type].present? - json.array! test_cases_and_results[test_case_type] do |test_case, test_result| - json.identifier test_case.identifier if can_read_tests - json.expression test_case.expression - json.expected test_case.expected - if test_result - json.output get_output(test_result) if show_testcase_outputs - json.passed test_result.passed? - end + json.questionId question.id + displayed_test_case_types.each do |test_case_type| + show_public = (test_case_type == 'public_test') && current_course.show_public_test_cases_output + show_testcase_outputs = can_read_tests || show_public + json.set! test_case_type do + if test_cases_and_results[test_case_type].present? + json.array! test_cases_and_results[test_case_type] do |test_case, test_result| + json.identifier test_case.identifier if can_read_tests + json.expression test_case.expression + json.expected test_case.expected + if test_result + json.output get_output(test_result) if show_testcase_outputs + json.passed test_result.passed? end - end + end - json.(ag, :stdout, :stderr) if show_stdout_and_stderr end + json.(ag, :stdout, :stderr) if show_stdout_and_stderr end end diff --git a/app/views/course/statistics/answers/latest_answer.json.jbuilder b/app/views/course/statistics/answers/latest_answer.json.jbuilder index 324f3663f3..332c990ea1 100644 --- a/app/views/course/statistics/answers/latest_answer.json.jbuilder +++ b/app/views/course/statistics/answers/latest_answer.json.jbuilder @@ -3,7 +3,28 @@ json.partial! 'question' json.answer do - json.partial! 'answer', answer: @answer, question: @question + specific_answer = @answer.specific + + json.id @answer.id + json.grade @answer.grade + json.questionType @question.question_type + + json.partial! specific_answer, answer: specific_answer, can_grade: false + + if @answer.actable_type == Course::Assessment::Answer::Programming.name + files = @answer.specific.files + json.partial! 'course/assessment/answer/programming/annotations', programming_files: files, + can_grade: false + posts = files.flat_map(&:annotations).map(&:discussion_topic).flat_map(&:posts) + + json.posts posts do |post| + json.partial! post, post: post if post.published? + end + end + + json.submittedAt @answer.submitted_at&.iso8601 + json.currentAnswer @answer.current_answer + json.workflowState @answer.workflow_state end posts = @submission_question.discussion_topic.posts diff --git a/client/app/api/course/Statistics/AllAnswerStatistics.ts b/client/app/api/course/Statistics/AllAnswerStatistics.ts index 7c37f3413a..c18983aaac 100644 --- a/client/app/api/course/Statistics/AllAnswerStatistics.ts +++ b/client/app/api/course/Statistics/AllAnswerStatistics.ts @@ -1,5 +1,5 @@ import { QuestionType } from 'types/course/assessment/question'; -import { QuestionAllAnswerDisplayDetails } from 'types/course/statistics/assessmentStatistics'; +import { QuestionAllAnswerDetails } from 'types/course/statistics/assessmentStatistics'; import { APIResponse } from 'api/types'; @@ -12,7 +12,7 @@ export default class AllAnswerStatisticsAPI extends BaseCourseAPI { fetchAllAttempts( submissionQuestionId: number, - ): APIResponse> { + ): APIResponse> { return this.client.get(`${this.#urlPrefix}/${submissionQuestionId}`); } } diff --git a/client/app/api/course/Statistics/AnswerStatistics.ts b/client/app/api/course/Statistics/AnswerStatistics.ts index 13b40801f2..132abb4d05 100644 --- a/client/app/api/course/Statistics/AnswerStatistics.ts +++ b/client/app/api/course/Statistics/AnswerStatistics.ts @@ -13,12 +13,6 @@ export default class AnswerStatisticsAPI extends BaseCourseAPI { return `/courses/${this.courseId}/statistics/answer`; } - fetchAllAttempts( - submissionQuestionId: number, - ): APIResponse> { - return this.client.get(`${this.#urlPrefix}/${submissionQuestionId}`); - } - fetchAttempts( answerId: number, limit: number, diff --git a/client/app/bundles/course/assessment/operations/statistics.ts b/client/app/bundles/course/assessment/operations/statistics.ts index f3127c6b4e..e7724e4fe3 100644 --- a/client/app/bundles/course/assessment/operations/statistics.ts +++ b/client/app/bundles/course/assessment/operations/statistics.ts @@ -5,7 +5,7 @@ import { AncestorAssessmentStats, AssessmentLiveFeedbackStatistics, LatestAnswer, - QuestionAllAnswerDisplayDetails, + QuestionAllAnswerDetails, QuestionAnswerDetails, } from 'types/course/statistics/assessmentStatistics'; @@ -66,7 +66,7 @@ export const fetchAttempts = async ( export const fetchAllAttempts = async ( submissionQuestionId: number, -): Promise> => { +): Promise> => { const response = await CourseAPI.statistics.allAnswer.fetchAllAttempts(submissionQuestionId); diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AnswerDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AnswerDetails.tsx index bfaca4ed48..869b27fb7d 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AnswerDetails.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AnswerDetails.tsx @@ -1,8 +1,10 @@ import { defineMessages } from 'react-intl'; import { Card, CardContent } from '@mui/material'; import { QuestionType } from 'types/course/assessment/question'; -import { AnswerDetailsMap } from 'types/course/statistics/answer'; -import { Question } from 'types/course/statistics/assessmentStatistics'; +import { + ProcessedAnswer, + Question, +} from 'types/course/statistics/assessmentStatistics'; import useTranslation from 'lib/hooks/useTranslation'; @@ -23,7 +25,7 @@ const translations = defineMessages({ interface AnswerDetailsProps { question: Question; - answer: AnswerDetailsMap[T]; + answer: ProcessedAnswer; } const AnswerNotImplemented = (): JSX.Element => { diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttempts.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttempts.tsx index 43e662e398..b831675d24 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttempts.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttempts.tsx @@ -3,7 +3,7 @@ import { useParams } from 'react-router-dom'; import { QuestionType } from 'types/course/assessment/question'; import { QuestionAnswerDetails } from 'types/course/statistics/assessmentStatistics'; -import { fetchQuestionAnswerDetails } from 'course/assessment/operations/statistics'; +import { fetchAttempts } from 'course/assessment/operations/statistics'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import Preload from 'lib/components/wrappers/Preload'; import { @@ -33,10 +33,7 @@ const AllAttemptsIndex: FC = (props) => { }; return ( - } - while={fetchQuestionAndCurrentAnswerDetails} - > + } while={fetchAttemptDetails}> {(data): JSX.Element => { const pastAnswersURL = getPastAnswersURL( courseId, @@ -48,9 +45,9 @@ const AllAttemptsIndex: FC = (props) => { <> []; - question: Question; + allQuestions: Question[]; questionNumber: number; submissionEditUrl: string; pastAnswersURL?: string; @@ -59,7 +60,7 @@ const translations = defineMessages({ const AllAttemptsDisplay: FC = (props) => { const { allAnswers, - question, + allQuestions, questionNumber, submissionEditUrl, name, @@ -68,24 +69,20 @@ const AllAttemptsDisplay: FC = (props) => { const { t } = useTranslation(); - // TODO: distance between points inside Slider to be reflective towards the time distance - // (for example, the distance between 1:00PM to 1:01PM should not be equal to 1:00PM to 2:00PM) - const answerSubmittedTimes = allAnswers.map((answer, idx) => { - return { - value: idx, - label: - idx === 0 || idx === allAnswers.length - 1 - ? formatLongDateTime(answer.submittedAt) - : '', - }; - }); + const { questionMap, allProcessedAnswers, sliderPoints, maxIndex } = + processAttempts(allQuestions, allAnswers); - const currentAnswerMarker = - answerSubmittedTimes[answerSubmittedTimes.length - 1]; + const [currAnswer, setCurrAnswer] = useState(allProcessedAnswers[maxIndex]); + const [currQuestion, setCurrQuestion] = useState< + Question + >(questionMap.get(maxIndex) ?? ({} as Question)); - const [displayedIndex, setDisplayedIndex] = useState( - currentAnswerMarker.value, - ); + const updateDisplayedIndex = (index: number): void => { + setCurrAnswer(allProcessedAnswers[index]); + setCurrQuestion( + questionMap.get(index) ?? ({} as Question), + ); + }; return ( <> @@ -125,14 +122,14 @@ const AllAttemptsDisplay: FC = (props) => { /> - {answerSubmittedTimes.length > 1 && ( + {maxIndex > 0 && (
{ - setDisplayedIndex(Array.isArray(value) ? value[0] : value); + updateDisplayedIndex(Array.isArray(value) ? value[0] : value); }} - points={answerSubmittedTimes} + points={sliderPoints} valueLabelDisplay="auto" />
@@ -147,10 +144,10 @@ const AllAttemptsDisplay: FC = (props) => { })} >
- {question.title} + {currQuestion.title} @@ -159,14 +156,11 @@ const AllAttemptsDisplay: FC = (props) => { {t(translations.questionDetailsTitle)} - +
- + ); }; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/utils.ts b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/utils.ts new file mode 100644 index 0000000000..43dc35791b --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/utils.ts @@ -0,0 +1,89 @@ +import { QuestionType } from 'types/course/assessment/question'; +import { + Answer, + ProcessedAnswer, + Question, +} from 'types/course/statistics/assessmentStatistics'; + +import { SliderElement } from 'lib/components/extensions/CustomSlider'; +import { formatLongDateTime } from 'lib/moment'; + +/** + * Processes attempts and generates a list of processed answers and their corresponding slider points. + * All these have their corresponding indexes, and the indexes are mapped to the questions. + */ +export const processAttempts = ( + allQuestions: Question[], + allAnswers: Answer[], +): { + questionMap: Map>; + allProcessedAnswers: ProcessedAnswer[]; + sliderPoints: SliderElement[]; + maxIndex: number; +} => { + const type = allQuestions[0].type; + const questionMap = new Map>(); + const allProcessedAnswers: ProcessedAnswer[] = []; + const sliderPoints: SliderElement[] = []; + + let currIndex = 0; + let baseIndex = 1; + + if (type === 'Programming') { + (allAnswers as Answer<'Programming'>[]).forEach((answer) => { + if (answer.testCases.length === 0) { + allProcessedAnswers.push({ + ...answer, + testCases: {}, + }); + questionMap.set(currIndex, allQuestions[0]); + sliderPoints.push({ + value: currIndex, + tooltip: `Attempt ${baseIndex} - ${formatLongDateTime(answer.submittedAt)}`, + }); + currIndex += 1; + baseIndex += 1; + } else { + const tempSliderPoints: SliderElement[] = []; + + answer.testCases.forEach((testCase) => { + allProcessedAnswers.push({ + ...answer, + testCases: testCase, + }); + const question = allQuestions.find( + (q) => q.id === testCase.questionId, + ); + if (question) { + questionMap.set(currIndex, question); + } + + tempSliderPoints.push({ + value: currIndex, + tooltip: `Attempt ${baseIndex} - ${formatLongDateTime(answer.submittedAt)}`, + }); + + currIndex += 1; + }); + baseIndex += 1; + sliderPoints.push(tempSliderPoints); + } + }); + } else { + ( + allAnswers as Answer>[] + ).forEach((answer) => { + allProcessedAnswers.push(answer); + questionMap.set(currIndex, allQuestions[0]); + sliderPoints.push({ value: currIndex, label: '' }); + currIndex += 1; + }); + } + + return { + questionMap, + allProcessedAnswers, + sliderPoints, + maxIndex: currIndex - 1, + }; +}; diff --git a/client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAttempts.tsx b/client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAttempts.tsx index a572dc3fba..bfd4aa36c0 100644 --- a/client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAttempts.tsx +++ b/client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAttempts.tsx @@ -1,9 +1,9 @@ import { FC } from 'react'; import { useParams } from 'react-router-dom'; import { QuestionType } from 'types/course/assessment/question'; -import { QuestionAllAnswerDisplayDetails } from 'types/course/statistics/assessmentStatistics'; +import { QuestionAllAnswerDetails } from 'types/course/statistics/assessmentStatistics'; -import { fetchAllAnswers } from 'course/assessment/operations/statistics'; +import { fetchAllAttempts } from 'course/assessment/operations/statistics'; import AllAttemptsDisplay from 'course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay'; import Comment from 'course/assessment/pages/AssessmentStatistics/AnswerDisplay/Comment'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; @@ -19,7 +19,7 @@ const PastAnswers: FC = () => { const parsedSubmissionQuestionId = parseInt(submissionQuestionId, 10); const fetchAnswers = (): Promise< - QuestionAllAnswerDisplayDetails + QuestionAllAnswerDetails > => { return fetchAllAttempts(parsedSubmissionQuestionId); }; @@ -31,14 +31,14 @@ const PastAnswers: FC = () => { <> {data.comments.length > 0 && } diff --git a/client/app/types/course/assessment/submission/question/types.ts b/client/app/types/course/assessment/submission/question/types.ts index 555c23e7d1..7064e046fc 100644 --- a/client/app/types/course/assessment/submission/question/types.ts +++ b/client/app/types/course/assessment/submission/question/types.ts @@ -19,6 +19,7 @@ interface ProgrammingQuestionData { timeLimit?: number; attemptLimit?: number; fileSubmission: boolean; + autoGradable: boolean; isCodaveri: boolean; liveFeedbackEnabled: boolean; } diff --git a/client/app/types/course/statistics/answer.ts b/client/app/types/course/statistics/answer.ts index 2d80989212..dffe2f8cc7 100644 --- a/client/app/types/course/statistics/answer.ts +++ b/client/app/types/course/statistics/answer.ts @@ -1,4 +1,4 @@ -import { JobStatus, JobStatusResponse } from 'types/jobs'; +import { JobStatus } from 'types/jobs'; import { UserBasicListData } from 'types/users'; import { QuestionType } from '../assessment/question'; @@ -10,7 +10,6 @@ import { import { ProgrammingFieldData, TestCaseResult, - TestCaseType, } from '../assessment/submission/answer/programming'; import { ScribingFieldData } from '../assessment/submission/answer/scribing'; import { @@ -70,7 +69,7 @@ export interface Post { } export interface TestCase { - canReadTests: boolean; + questionId?: number; public_test?: TestCaseResult[]; private_test?: TestCaseResult[]; evaluation_test?: TestCaseResult[]; @@ -88,20 +87,17 @@ export interface CodaveriFeedback { export interface ProgrammingAnswerDetails extends AnswerCommonDetails<'Programming'> { fields: ProgrammingFieldData; - explanation: { - correct?: boolean; - explanation: string[]; - failureType: TestCaseType; - }; + // Tests might not be present (e.g. no autograding) + testCases: TestCase[]; + codaveriFeedback?: CodaveriFeedback; + annotations: Annotation[]; + posts: Post[]; +} +export interface ProcessedProgrammingAnswerDetails + extends AnswerCommonDetails<'Programming'> { + fields: ProgrammingFieldData; testCases: TestCase; - attemptsLeft?: number; - autograding?: JobStatusResponse & { - path?: string; - }; codaveriFeedback?: CodaveriFeedback; - latestAnswer?: ProgrammingAnswerDetails & { - annotations: Annotation[]; - }; annotations: Annotation[]; posts: Post[]; } @@ -174,3 +170,16 @@ export interface AnswerDetailsMap { VoiceResponse: VoiceResponseAnswerDetails; ForumPostResponse: ForumPostResponseAnswerDetails; } + +// Currently, only ProgrammingAnswerDetails is processed +export interface ProcessedAnswerDetailsMap { + MultipleChoice: McqAnswerDetails; + MultipleResponse: MrqAnswerDetails; + Programming: ProcessedProgrammingAnswerDetails; + TextResponse: TextResponseAnswerDetails; + FileUpload: FileUploadAnswerDetails; + Comprehension: ComprehensionAnswerDetails; + Scribing: ScribingAnswerDetails; + VoiceResponse: VoiceResponseAnswerDetails; + ForumPostResponse: ForumPostResponseAnswerDetails; +} diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index c3f5244425..2d9dcebf18 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -3,7 +3,7 @@ import { SpecificQuestionDataMap } from '../assessment/submission/question/types import { WorkflowState } from '../assessment/submission/submission'; import { CourseUserBasicListData } from '../courseUsers'; -import { AnswerDetailsMap } from './answer'; +import { AnswerDetailsMap, ProcessedAnswerDetailsMap } from './answer'; interface AssessmentInfo { id: number; @@ -114,38 +114,39 @@ export type Answer = workflowState: WorkflowState; }; +export type ProcessedAnswer = + ProcessedAnswerDetailsMap[T] & { + submittedAt: Date; + currentAnswer: boolean; + workflowState: WorkflowState; + }; + export interface LatestAnswer { question: Question; - answer: Answer; + answer: ProcessedAnswer; comments: CommentItem[]; submissionId: number; } export interface QuestionAnswerDetails { - question: Question; - answer: AnswerDetailsMap[T]; + allQuestions: Question[]; allAnswers: Answer[]; comments: CommentItem[]; submissionId: number; - submissionQuestionId: number; + submissionQuestionId?: number; } -export interface QuestionAnswerDisplayDetails< - T extends keyof typeof QuestionType, -> { - question: Question; - answer: AnswerDetailsMap[T]; +export interface QuestionAllAnswerDetails + extends QuestionAnswerDetails { + isAnswersDisplayed: boolean; + user: UserInfo; } -export interface QuestionAllAnswerDisplayDetails< +export interface QuestionAnswerDisplayDetails< T extends keyof typeof QuestionType, > { - isAnswersDisplayed: boolean; - user: UserInfo; question: Question; - allAnswers: Answer[]; - submissionId: number; - comments: CommentItem[]; + answer: ProcessedAnswer; } export interface AssessmentLiveFeedbackStatistics { From 1e43e48cd84c70d952c1d2ac36209a8eeb90899f Mon Sep 17 00:00:00 2001 From: yoopie Date: Tue, 15 Oct 2024 16:10:37 +0800 Subject: [PATCH 18/24] feat(regrading): only regrade current_answer on question edit --- app/models/course/assessment/answer.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/models/course/assessment/answer.rb b/app/models/course/assessment/answer.rb index e613b8af51..1261a2d22f 100644 --- a/app/models/course/assessment/answer.rb +++ b/app/models/course/assessment/answer.rb @@ -80,6 +80,9 @@ class Course::Assessment::Answer < ApplicationRecord def auto_grade!(redirect_to_path: nil, reduce_priority: false) raise IllegalStateError if attempting? + # When evaluated or graded, only autograde most recent answer + return if !submitted? && !current_answer? + ensure_auto_grading! if grade_inline? Course::Assessment::Answer::AutoGradingService.grade(self) From 063868be60e73207926e38f49f7bf87aa3e061c5 Mon Sep 17 00:00:00 2001 From: yoopie Date: Wed, 16 Oct 2024 17:47:10 +0800 Subject: [PATCH 19/24] test(stats-answer-controller): rename function calls - lines removed is data that is no longer being rendered --- .../statistics/answers_controller_spec.rb | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/spec/controllers/course/statistics/answers_controller_spec.rb b/spec/controllers/course/statistics/answers_controller_spec.rb index 05f9f4dd79..1414352173 100644 --- a/spec/controllers/course/statistics/answers_controller_spec.rb +++ b/spec/controllers/course/statistics/answers_controller_spec.rb @@ -16,23 +16,23 @@ create(:submission_question, :with_post, submission_id: answer.submission_id, question_id: answer.question_id) end - describe '#question_answer_details' do + describe '#attempts' do render_views - subject { get :question_answer_details, format: :json, params: { course_id: course, id: answer.id } } + subject { get :attempts, format: :json, params: { course_id: course, id: answer.id, limit: -1 } } - context 'when the Normal User get the question answer details for the statistics' do + context 'when the Normal User get the attempts for the statistics' do let(:user) { create(:user) } before { controller_sign_in(controller, user) } it { expect { subject }.to raise_exception(CanCan::AccessDenied) } end - context 'when the Course Student get the question answer details for the statistics' do + context 'when the Course Student get the attempts for the statistics' do let(:user) { create(:course_student, course: course).user } before { controller_sign_in(controller, user) } it { expect { subject }.to raise_exception(CanCan::AccessDenied) } end - context 'when the Course Manager get the question answer details for the statistics' do + context 'when the Course Manager get the attempts for the statistics' do let(:user) { create(:course_manager, course: course).user } before { controller_sign_in(controller, user) } @@ -40,9 +40,6 @@ expect(subject).to have_http_status(:success) json_result = JSON.parse(response.body) - expect(json_result['question']['id']).to eq(answer.question.id) - expect(json_result['answer']['grade'].to_f).to eq(answer.grade) - # expect only one allAnswers expect(json_result['allAnswers'].count).to eq(1) @@ -59,9 +56,6 @@ expect(subject).to have_http_status(:success) json_result = JSON.parse(response.body) - expect(json_result['question']['id']).to eq(answer.question.id) - expect(json_result['answer']['grade'].to_f).to eq(answer.grade) - # expect only one allAnswers expect(json_result['allAnswers'].count).to eq(1) @@ -71,9 +65,9 @@ end end - describe '#all_answers' do + describe '#all_attempts' do render_views - subject { get :all_answers, format: :json, params: { course_id: course, id: submission_question.id } } + subject { get :all_attempts, format: :json, params: { course_id: course, id: submission_question.id } } context 'when the Normal User get the question answer details for the statistics' do let(:user) { create(:user) } From 9e7a7e42cd3a5286fd7b91081416ecf3dd93a12a Mon Sep 17 00:00:00 2001 From: yoopie Date: Fri, 18 Oct 2024 16:55:16 +0800 Subject: [PATCH 20/24] test(programming-controller): fix tests - use update column instead of update to prevent validation error when duplicating question with deprecated programming language --- .../question/programming_controller.rb | 2 +- .../question/programming_controller_spec.rb | 20 +++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/app/controllers/course/assessment/question/programming_controller.rb b/app/controllers/course/assessment/question/programming_controller.rb index 55448955ae..05eaa62f5b 100644 --- a/app/controllers/course/assessment/question/programming_controller.rb +++ b/app/controllers/course/assessment/question/programming_controller.rb @@ -187,7 +187,7 @@ def save_duplicated_question(duplicated_programming_question) def link_to_original_question(duplicated_programming_question) @programming_question.question.actable = duplicated_programming_question # Update the original programming question's parent_id to link to the new duplicated question - duplicated_programming_question.update!(parent_id: @programming_question.id) + duplicated_programming_question.update_column(:parent_id, @programming_question.id) end def format_test_cases diff --git a/spec/controllers/course/assessment/question/programming_controller_spec.rb b/spec/controllers/course/assessment/question/programming_controller_spec.rb index 3c2eb0c639..e441d14aa3 100644 --- a/spec/controllers/course/assessment/question/programming_controller_spec.rb +++ b/spec/controllers/course/assessment/question/programming_controller_spec.rb @@ -154,14 +154,18 @@ end end - context 'when the question cannot be saved' do - let(:programming_question) { immutable_programming_question } - - it 'returns bad request' do - subject - expect(response).to have_http_status(:bad_request) - end - end + # Test case commented out as it is not relevant + # Updating will duplicate the question. + # Any restrictions (such as an auto grading taking place) will not block execution + + # context 'when the question cannot be saved' do + # let(:programming_question) { immutable_programming_question } + + # it 'returns bad request' do + # subject + # expect(response).to have_http_status(:bad_request) + # end + # end context 'when attaching a template package' do include Rails.application.routes.url_helpers From ef6c7ce3753f54cc37020609c9e577c3e01fd91d Mon Sep 17 00:00:00 2001 From: yoopie Date: Mon, 21 Oct 2024 15:44:56 +0800 Subject: [PATCH 21/24] test(programming-management): fix tests - also add redicrect edit url to return when updating question, so that id can be used to revalidate question --- .../assessment/question/programming_controller.rb | 2 +- .../programming/EditProgrammingQuestionPage.tsx | 10 +++++++++- .../question/programming/ProgrammingForm.tsx | 4 ++++ .../assessment/question/programming_management_spec.rb | 5 +++-- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/app/controllers/course/assessment/question/programming_controller.rb b/app/controllers/course/assessment/question/programming_controller.rb index 05eaa62f5b..d8e303e954 100644 --- a/app/controllers/course/assessment/question/programming_controller.rb +++ b/app/controllers/course/assessment/question/programming_controller.rb @@ -65,7 +65,7 @@ def update end if result - render_success_json false + render_success_json true else render_failure_json end diff --git a/client/app/bundles/course/assessment/question/programming/EditProgrammingQuestionPage.tsx b/client/app/bundles/course/assessment/question/programming/EditProgrammingQuestionPage.tsx index d4f54aa702..a8444bf8d9 100644 --- a/client/app/bundles/course/assessment/question/programming/EditProgrammingQuestionPage.tsx +++ b/client/app/bundles/course/assessment/question/programming/EditProgrammingQuestionPage.tsx @@ -19,6 +19,14 @@ const EditProgrammingQuestionPage = (): JSX.Element => { const fetchData = (): Promise => fetchEdit(id); + const reFetchData = async ( + response: ProgrammingPostStatusData, + _rawData: ProgrammingFormData, + ): Promise => { + const newId = response.id ?? id; + return fetchEdit(newId); + }; + return ( } while={fetchData}> {(data): JSX.Element => ( @@ -26,7 +34,7 @@ const EditProgrammingQuestionPage = (): JSX.Element => { onSubmit={(rawData): Promise => update(id, buildFormData(rawData)) } - revalidate={fetchData} + revalidate={reFetchData} with={data} /> )} diff --git a/client/app/bundles/course/assessment/question/programming/ProgrammingForm.tsx b/client/app/bundles/course/assessment/question/programming/ProgrammingForm.tsx index a58136b86e..61f7cd5e08 100644 --- a/client/app/bundles/course/assessment/question/programming/ProgrammingForm.tsx +++ b/client/app/bundles/course/assessment/question/programming/ProgrammingForm.tsx @@ -82,6 +82,10 @@ const ProgrammingForm = (props: ProgrammingFormProps): JSX.Element => { if (debounced) return; debounced = true; + if (response.redirectEditUrl) { + navigate(response.redirectEditUrl, { replace: true }); + } + const newData = await props.revalidate?.(response, rawData); if (newData) { setData(newData); diff --git a/spec/features/course/assessment/question/programming_management_spec.rb b/spec/features/course/assessment/question/programming_management_spec.rb index 8cc4be9e52..4b046f61fb 100644 --- a/spec/features/course/assessment/question/programming_management_spec.rb +++ b/spec/features/course/assessment/question/programming_management_spec.rb @@ -166,10 +166,11 @@ end scenario 'I can edit a question without updating the programming package' do - question = create(:course_assessment_question_programming, assessment: assessment) + programming_question = create(:course_assessment_question_programming, assessment: assessment) + question = programming_question.acting_as visit course_assessment_path(course, assessment) - edit_path = edit_course_assessment_question_programming_path(course, assessment, question) + edit_path = edit_course_assessment_question_programming_path(course, assessment, programming_question) find_link(nil, href: edit_path).click maximum_grade = 999.9 From 230adbaae5aa1fec9dda059615ea798af5a7c90d Mon Sep 17 00:00:00 2001 From: yoopie Date: Mon, 21 Oct 2024 16:15:58 +0800 Subject: [PATCH 22/24] test(programming-management): fix template package spec --- .../question/programming_management_spec.rb | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/spec/features/course/assessment/question/programming_management_spec.rb b/spec/features/course/assessment/question/programming_management_spec.rb index 4b046f61fb..b5b42312d6 100644 --- a/spec/features/course/assessment/question/programming_management_spec.rb +++ b/spec/features/course/assessment/question/programming_management_spec.rb @@ -118,13 +118,15 @@ end scenario 'I can upload a template package' do - question = create(:course_assessment_question_programming, - assessment: assessment, template_file_count: 0, package_type: :zip_upload) + programming_question = create(:course_assessment_question_programming, + assessment: assessment, template_file_count: 0, package_type: :zip_upload) + + question = programming_question.acting_as empty_package = File.join(file_fixture_path, 'course/empty_programming_question_template.zip') valid_package = File.join(file_fixture_path, 'course/programming_question_template.zip') - visit edit_course_assessment_question_programming_path(course, assessment, question) + visit edit_course_assessment_question_programming_path(course, assessment, programming_question) find('span', text: 'Evaluate and test code').click attach_file(empty_package) do @@ -151,14 +153,16 @@ expect(page).to have_current_path(course_assessment_path(course, assessment)) - visit edit_course_assessment_question_programming_path(course, assessment, question) + programming_question = question.reload.actable + + visit edit_course_assessment_question_programming_path(course, assessment, programming_question) expect(page).to have_text('success') - question.template_files.reload.each do |template| + programming_question.template_files.reload.each do |template| expect(page).to have_text(template.filename) end - question.test_cases.reload.each do |test_case| + programming_question.test_cases.reload.each do |test_case| expect(page).to have_text(test_case[:expression]) expect(page).to have_text(test_case[:expected]) expect(page).to have_text(test_case[:hint]) From 3e31863cab81da9c86a5a1a2633d2397eaac33cb Mon Sep 17 00:00:00 2001 From: yoopie Date: Mon, 21 Oct 2024 16:44:52 +0800 Subject: [PATCH 23/24] fix(attempt-count-table): fix error when attempt does not exist - this situation can occur when question is added after submission --- .../pages/AssessmentStatistics/StudentAttemptCountTable.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx index 5676651cc2..09dfc65f0a 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx @@ -100,8 +100,8 @@ const StudentAttemptCountTable: FC = (props) => { }, title: t(translations.questionIndex, { index: index + 1 }), cell: (datum): ReactNode => { - return typeof datum.attemptStatus?.[index].attemptCount === - 'number' ? ( + return datum.attemptStatus?.[index] && + typeof datum.attemptStatus[index].attemptCount === 'number' ? ( renderAttemptCountClickableCell(index, datum) ) : (
From 2d65d340b995f295f2bfe1f3f7fc0d164d86b1b1 Mon Sep 17 00:00:00 2001 From: yoopie Date: Mon, 21 Oct 2024 17:46:26 +0800 Subject: [PATCH 24/24] test(test-case-view): fix FE test --- .../containers/TestCaseView/__test__/index.test.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/client/app/bundles/course/assessment/submission/containers/TestCaseView/__test__/index.test.js b/client/app/bundles/course/assessment/submission/containers/TestCaseView/__test__/index.test.js index a60d3cad12..ebc9e6bbc2 100644 --- a/client/app/bundles/course/assessment/submission/containers/TestCaseView/__test__/index.test.js +++ b/client/app/bundles/course/assessment/submission/containers/TestCaseView/__test__/index.test.js @@ -52,10 +52,9 @@ const defaultStaffViewProps = { }; const getWarning = (page, text) => - within(page.getByText(text).closest('div')).queryByText( - 'Only staff can see this.', - { exact: false }, - ); + within( + page.getByText(text).closest('.MuiAccordionSummary-content'), + ).queryByText('Only staff can see this.', { exact: false }); describe('TestCaseView', () => { describe('when viewing as staff', () => {