From 47e4365b89b8884763d72226e7b2ae15ebfcc2c4 Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Tue, 19 Mar 2024 12:40:27 +0200 Subject: [PATCH 1/3] Fix raw html in the research form checkbox label (#1252) * Fix raw html in the research form checkbox label * Fixes --- .../ResearchConsentCheckBoxEditor.tsx | 4 ++++ .../moocfi/ResearchFormCheckBoxBlock.tsx | 1 + .../src/components/InputFields/CheckBox.tsx | 20 +++++++++++++++++-- shared-module/src/locales/en/cms.json | 1 + shared-module/src/locales/fi/cms.json | 1 + 5 files changed, 25 insertions(+), 2 deletions(-) diff --git a/services/cms/src/blocks/ResearchConsentCheckbox/ResearchConsentCheckBoxEditor.tsx b/services/cms/src/blocks/ResearchConsentCheckbox/ResearchConsentCheckBoxEditor.tsx index 9cbeb560e7ef..ad2693baeda1 100644 --- a/services/cms/src/blocks/ResearchConsentCheckbox/ResearchConsentCheckBoxEditor.tsx +++ b/services/cms/src/blocks/ResearchConsentCheckbox/ResearchConsentCheckBoxEditor.tsx @@ -4,6 +4,7 @@ import { BlockEditProps } from "@wordpress/blocks" import React from "react" import { useTranslation } from "react-i18next" +import ErrorBanner from "../../shared-module/components/ErrorBanner" import CheckBox from "../../shared-module/components/InputFields/CheckBox" import BlockPlaceholderWrapper from "../BlockPlaceholderWrapper" @@ -41,6 +42,9 @@ const ResearchConsentCheckBoxEditor: React.FC< onChange={(value: string) => setAttributes({ content: value })} /> + {(attributes.content ?? "").split(/\s+/).length < 3 && ( + + )} ) } diff --git a/services/course-material/src/components/ContentRenderer/moocfi/ResearchFormCheckBoxBlock.tsx b/services/course-material/src/components/ContentRenderer/moocfi/ResearchFormCheckBoxBlock.tsx index 10bc5098ab7c..fb5a926af362 100644 --- a/services/course-material/src/components/ContentRenderer/moocfi/ResearchFormCheckBoxBlock.tsx +++ b/services/course-material/src/components/ContentRenderer/moocfi/ResearchFormCheckBoxBlock.tsx @@ -30,6 +30,7 @@ const ResearchFormCheckBoxBlock: React.FC< <> handleChange(!questionIdsAndAnswers[props.data.clientId])} /> diff --git a/shared-module/src/components/InputFields/CheckBox.tsx b/shared-module/src/components/InputFields/CheckBox.tsx index 0869689ebbb6..924658e4627e 100644 --- a/shared-module/src/components/InputFields/CheckBox.tsx +++ b/shared-module/src/components/InputFields/CheckBox.tsx @@ -78,10 +78,21 @@ export interface CheckboxProps extends InputHTMLAttributes { error?: boolean checked?: boolean onChangeByValue?: (checked: boolean, name?: string) => void + labelIsRawHtml?: boolean } const CheckBox = forwardRef( - ({ onChangeByValue, onChange, className, checked, ...rest }: CheckboxProps, ref) => { + ( + { + onChangeByValue, + onChange, + className, + checked, + labelIsRawHtml = false, + ...rest + }: CheckboxProps, + ref, + ) => { const handleOnChange = (event: React.ChangeEvent) => { if (onChangeByValue) { const { @@ -112,7 +123,12 @@ const CheckBox = forwardRef( ref={ref} {...rest} /> - {rest.label} + {/* eslint-disable-next-line react/no-danger-with-children */} + {rest.error && ( Date: Tue, 19 Mar 2024 13:03:14 +0200 Subject: [PATCH 2/3] Testing for teachers when making exams (#1244) * Teachers can test exams * Form for editing exam * little fixes and tests for testing and editing exams * little fix * fix eslint errors * Added translations and little fixes * Added queryClient * Fix for tests * Added css * little fix * Little css changes * Added translation * Little improvements * Added query --------- Co-authored-by: Maija Y --- .../pages/[organizationSlug]/exams/[id].tsx | 2 +- .../exams/testexam/[id].tsx | 366 ++++++++++++++++++ .../course-material/src/services/backend.ts | 38 +- ...acher_testing_to_exam_enrollments.down.sql | 2 + ...teacher_testing_to_exam_enrollments.up.sql | 6 + ...82a323846b5aaadfc0c203ad6d8104c4f1393.json | 12 + ...724a116966367fb28fcb0c85eb1a04c4e8624.json | 28 -- ...8fda59688a331b16fd05eaef9e832a643a531.json | 12 + ...ce568dbd02458be84e5a8006d4917e8b8974d.json | 38 ++ ...b213bdd75361727872439d7fbb65117b4700.json} | 6 +- ...2c243a207450909a69db5a44ad6fcb72b2fb2.json | 12 - ...7eea7da8f3041f75d766c419eef5b99a8df65.json | 53 +++ ...537ed63a9eeed15b50fda492854f956cd1b3f.json | 12 + ...14aefcff45a0044d20e956b436e2401f94a79.json | 12 + services/headless-lms/models/src/exams.rs | 120 ++++-- .../models/src/exercise_slide_submissions.rs | 19 + .../src/controllers/course_material/exams.rs | 145 ++++++- .../controllers/course_material/exercises.rs | 17 + .../src/controllers/main_frontend/exams.rs | 27 +- .../main_frontend/organizations.rs | 21 + .../src/components/forms/EditExamForm.tsx | 117 ++++++ .../courses/id/exams/EditExamDialog.tsx | 87 +++++ .../src/pages/manage/exams/[id]/index.tsx | 53 ++- .../src/services/backend/exams.ts | 25 +- shared-module/src/bindings.guard.ts | 6 +- shared-module/src/bindings.ts | 2 + .../src/locales/en/course-material.json | 2 + .../src/locales/en/main-frontend.json | 3 + .../src/locales/fi/course-material.json | 2 + .../src/locales/fi/main-frontend.json | 3 + .../src/tests/exams/edit-exam.spec.ts | 30 ++ .../tests/exams/teacher-can-test-exam.spec.ts | 116 ++++++ 32 files changed, 1315 insertions(+), 79 deletions(-) create mode 100644 services/course-material/src/pages/[organizationSlug]/exams/testexam/[id].tsx create mode 100644 services/headless-lms/migrations/20240118095203_add_is_teacher_testing_to_exam_enrollments.down.sql create mode 100644 services/headless-lms/migrations/20240118095203_add_is_teacher_testing_to_exam_enrollments.up.sql create mode 100644 services/headless-lms/models/.sqlx/query-15a135aba7f3d617e8b2b6e0e3782a323846b5aaadfc0c203ad6d8104c4f1393.json delete mode 100644 services/headless-lms/models/.sqlx/query-3c54ad6a3cc0b88764f80e64680724a116966367fb28fcb0c85eb1a04c4e8624.json create mode 100644 services/headless-lms/models/.sqlx/query-42c1ad15a0ffb7e696bfacb50fe8fda59688a331b16fd05eaef9e832a643a531.json create mode 100644 services/headless-lms/models/.sqlx/query-527be2e3679b1f889452a1970d9ce568dbd02458be84e5a8006d4917e8b8974d.json rename services/headless-lms/models/.sqlx/{query-c5c02778029045a48b32567f114b8fbd441b01ac1e0908eca4e02a067327036e.json => query-8ea4b63e5770221ab693be4dc6f3b213bdd75361727872439d7fbb65117b4700.json} (55%) delete mode 100644 services/headless-lms/models/.sqlx/query-a0506edd480e7dec860a33702092c243a207450909a69db5a44ad6fcb72b2fb2.json create mode 100644 services/headless-lms/models/.sqlx/query-a64335b0b2edd67fd22c3bf56387eea7da8f3041f75d766c419eef5b99a8df65.json create mode 100644 services/headless-lms/models/.sqlx/query-c5086375e71ac768c634490a0bb537ed63a9eeed15b50fda492854f956cd1b3f.json create mode 100644 services/headless-lms/models/.sqlx/query-db3e9aa8e3116435c3b9a75823b14aefcff45a0044d20e956b436e2401f94a79.json create mode 100644 services/main-frontend/src/components/forms/EditExamForm.tsx create mode 100644 services/main-frontend/src/components/page-specific/manage/courses/id/exams/EditExamDialog.tsx create mode 100644 system-tests/src/tests/exams/edit-exam.spec.ts create mode 100644 system-tests/src/tests/exams/teacher-can-test-exam.spec.ts diff --git a/services/course-material/src/pages/[organizationSlug]/exams/[id].tsx b/services/course-material/src/pages/[organizationSlug]/exams/[id].tsx index 2f0b0819eb67..553f6f454c6a 100644 --- a/services/course-material/src/pages/[organizationSlug]/exams/[id].tsx +++ b/services/course-material/src/pages/[organizationSlug]/exams/[id].tsx @@ -191,7 +191,7 @@ const Exam: React.FC> = ({ query }) => {
{ - await enrollInExam(examId) + await enrollInExam(examId, false) exam.refetch() }} examEnrollmentData={exam.data.enrollment_data} diff --git a/services/course-material/src/pages/[organizationSlug]/exams/testexam/[id].tsx b/services/course-material/src/pages/[organizationSlug]/exams/testexam/[id].tsx new file mode 100644 index 000000000000..f6d229213350 --- /dev/null +++ b/services/course-material/src/pages/[organizationSlug]/exams/testexam/[id].tsx @@ -0,0 +1,366 @@ +import { css } from "@emotion/css" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { addMinutes, differenceInSeconds, isPast, min, parseISO } from "date-fns" +import React, { useCallback, useContext, useEffect, useReducer, useState } from "react" +import { useTranslation } from "react-i18next" + +import ContentRenderer from "../../../../components/ContentRenderer" +import Page from "../../../../components/Page" +import ExamStartBanner from "../../../../components/exams/ExamStartBanner" +import ExamTimer from "../../../../components/exams/ExamTimer" +import ExamTimeOverModal from "../../../../components/modals/ExamTimeOverModal" +import LayoutContext from "../../../../contexts/LayoutContext" +import PageContext, { + CoursePageDispatch, + getDefaultPageState, +} from "../../../../contexts/PageContext" +import useTime from "../../../../hooks/useTime" +import pageStateReducer from "../../../../reducers/pageStateReducer" +import { + Block, + enrollInExam, + fetchExamForTesting, + resetExamProgress, + updateShowExerciseAnswers, +} from "../../../../services/backend" +import Button from "../../../../shared-module/components/Button" +import BreakFromCentered from "../../../../shared-module/components/Centering/BreakFromCentered" +import ErrorBanner from "../../../../shared-module/components/ErrorBanner" +import CheckBox from "../../../../shared-module/components/InputFields/CheckBox" +import Spinner from "../../../../shared-module/components/Spinner" +import HideTextInSystemTests from "../../../../shared-module/components/system-tests/HideTextInSystemTests" +import { withSignedIn } from "../../../../shared-module/contexts/LoginStateContext" +import useToastMutation from "../../../../shared-module/hooks/useToastMutation" +import { baseTheme, fontWeights, headingFont } from "../../../../shared-module/styles" +import { respondToOrLarger } from "../../../../shared-module/styles/respond" +import dontRenderUntilQueryParametersReady, { + SimplifiedUrlQuery, +} from "../../../../shared-module/utils/dontRenderUntilQueryParametersReady" +import withErrorBoundary from "../../../../shared-module/utils/withErrorBoundary" + +interface ExamProps { + // "organizationSlug" + query: SimplifiedUrlQuery +} + +const Exam: React.FC> = ({ query }) => { + const { t, i18n } = useTranslation() + const examId = query.id + const [showExamAnswers, setShowExamAnswers] = useState(false) + const [pageState, pageStateDispatch] = useReducer( + pageStateReducer, + // We don't pass a refetch function here on purpose because refetching during an exam is risky because we don't want to accidentally lose unsubitted answers + getDefaultPageState(undefined), + ) + const now = useTime(5000) + + const queryClient = useQueryClient() + + const exam = useQuery({ + queryKey: [`exam-page-testexam-${examId}-fetch-exam-for-testing`], + queryFn: () => fetchExamForTesting(examId), + }) + + const showAnswersMutation = useToastMutation( + (showAnswers: boolean) => updateShowExerciseAnswers(examId, showAnswers), + { + notify: false, + }, + { + onSuccess: async () => { + await queryClient.refetchQueries() + }, + }, + ) + + const resetExamMutation = useToastMutation( + () => resetExamProgress(examId), + { + notify: false, + }, + { + onSuccess: async () => { + showAnswersMutation.mutate(false) + await queryClient.refetchQueries() + }, + }, + ) + + useEffect(() => { + if (exam.isError) { + // eslint-disable-next-line i18next/no-literal-string + pageStateDispatch({ type: "setError", payload: exam.error }) + } else if (exam.isSuccess && exam.data.enrollment_data.tag === "EnrolledAndStarted") { + pageStateDispatch({ + // eslint-disable-next-line i18next/no-literal-string + type: "setData", + payload: { + pageData: exam.data.enrollment_data.page, + instance: null, + settings: null, + exam: exam.data, + isTest: false, + }, + }) + setShowExamAnswers(exam.data.enrollment_data.enrollment.show_exercise_answers ?? false) + } else { + // eslint-disable-next-line i18next/no-literal-string + pageStateDispatch({ type: "setLoading" }) + } + }, [exam.isError, exam.isSuccess, exam.data, exam.error]) + + useEffect(() => { + if (!exam.data) { + return + } + if (i18n.language !== exam.data.language) { + i18n.changeLanguage(exam.data.language) + } + }) + + const layoutContext = useContext(LayoutContext) + useEffect(() => { + layoutContext.setOrganizationSlug(query.organizationSlug) + }, [layoutContext, query.organizationSlug]) + + const handleRefresh = useCallback(async () => { + await exam.refetch() + }, [exam]) + + const handleTimeOverModalClose = useCallback(async () => { + await handleRefresh() + }, [handleRefresh]) + + const handleResetProgress = useCallback(async () => { + resetExamMutation.mutate() + }, [resetExamMutation]) + + const handleShowAnswers = useCallback(async () => { + setShowExamAnswers(!showExamAnswers) + showAnswersMutation.mutate(!showExamAnswers) + }, [showAnswersMutation, showExamAnswers]) + + if (exam.isPending) { + return + } + + if (exam.isError) { + return + } + + const examInfo = ( + +
+
+ {exam.data.name} +
+
+ {(exam.data.enrollment_data.tag === "NotEnrolled" || + exam.data.enrollment_data.tag === "NotYetStarted") && ( + <> +
+ +
+
+ +
+
{t("exam-time-to-complete", { "time-minutes": exam.data.time_minutes })}
+ + )} +
+
+
+ ) + if ( + exam.data.enrollment_data.tag === "NotEnrolled" || + exam.data.enrollment_data.tag === "NotYetStarted" + ) { + return ( + <> + {examInfo} +
+ { + await enrollInExam(examId, true) + exam.refetch() + }} + examEnrollmentData={exam.data.enrollment_data} + examHasStarted={true} + examHasEnded={exam.data.ends_at ? isPast(exam.data.ends_at) : false} + timeMinutes={exam.data.time_minutes} + > +
+ >) ?? []} + editing={false} + selectedBlockId={null} + setEdits={(map) => map} + isExam={false} + /> +
+
+
+ + ) + } + + if (exam.data.enrollment_data.tag === "StudentTimeUp") { + return ( + <> + {examInfo} +
{t("exam-time-up", { "ends-at": exam.data.ends_at.toLocaleString() })}
+ + ) + } + + const endsAt = exam.data.ends_at + ? min([ + addMinutes(exam.data.enrollment_data.enrollment.started_at, exam.data.time_minutes), + exam.data.ends_at, + ]) + : addMinutes(exam.data.enrollment_data.enrollment.started_at, exam.data.time_minutes) + const secondsLeft = differenceInSeconds(endsAt, now) + return ( + <> + + + + {examInfo} + + {secondsLeft < 10 * 60 && ( +
+
{t("exam-time-running-out-soon-help-text")}
+
+ )} + +
+
+ <> + {exam.data?.enrollment_data.enrollment.is_teacher_testing && ( +
+ + { + handleShowAnswers() + }} + /> +
+ )} + + + ) +} + +export default withErrorBoundary(withSignedIn(dontRenderUntilQueryParametersReady(Exam))) diff --git a/services/course-material/src/services/backend.ts b/services/course-material/src/services/backend.ts index a0dc8ae32bba..7d1acb71f0bf 100644 --- a/services/course-material/src/services/backend.ts +++ b/services/course-material/src/services/backend.ts @@ -397,8 +397,14 @@ export const fetchExamEnrollment = async (examId: string): Promise => { - await courseMaterialClient.post(`/exams/${examId}/enroll`, { responseType: "json" }) +export const enrollInExam = async (examId: string, is_teacher_testing: boolean): Promise => { + await courseMaterialClient.post( + `/exams/${examId}/enroll`, + { is_teacher_testing }, + { + responseType: "json", + }, + ) } export const fetchExam = async (examId: string): Promise => { @@ -406,6 +412,34 @@ export const fetchExam = async (examId: string): Promise => { return validateResponse(response, isExamData) } +export const fetchExamForTesting = async (examId: string): Promise => { + const response = await courseMaterialClient.get( + `/exams/testexam/${examId}/fetch-exam-for-testing`, + { + responseType: "json", + }, + ) + return validateResponse(response, isExamData) +} + +export const resetExamProgress = async (examId: string): Promise => { + const response = await courseMaterialClient.post(`/exams/testexam/${examId}/reset-exam-progress`) + return response.data +} + +export const updateShowExerciseAnswers = async ( + examId: string, + showExerciseAnswers: boolean, +): Promise => { + await courseMaterialClient.post( + `/exams/testexam/${examId}/update-show-exercise-answers`, + { show_exercise_answers: showExerciseAnswers }, + { + responseType: "json", + }, + ) +} + export const saveExamAnswer = async ( examId: string, exerciseId: string, diff --git a/services/headless-lms/migrations/20240118095203_add_is_teacher_testing_to_exam_enrollments.down.sql b/services/headless-lms/migrations/20240118095203_add_is_teacher_testing_to_exam_enrollments.down.sql new file mode 100644 index 000000000000..ebccaa6e631d --- /dev/null +++ b/services/headless-lms/migrations/20240118095203_add_is_teacher_testing_to_exam_enrollments.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE exam_enrollments DROP COLUMN is_teacher_testing; +ALTER TABLE exam_enrollments DROP COLUMN show_exercise_answers; diff --git a/services/headless-lms/migrations/20240118095203_add_is_teacher_testing_to_exam_enrollments.up.sql b/services/headless-lms/migrations/20240118095203_add_is_teacher_testing_to_exam_enrollments.up.sql new file mode 100644 index 000000000000..a0f9e78a0a65 --- /dev/null +++ b/services/headless-lms/migrations/20240118095203_add_is_teacher_testing_to_exam_enrollments.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE exam_enrollments +ADD COLUMN is_teacher_testing BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE exam_enrollments +ADD COLUMN show_exercise_answers BOOLEAN NOT NULL DEFAULT FALSE; +COMMENT ON COLUMN exam_enrollments.is_teacher_testing IS 'Is the exam enrollment used for teacher previewing the exam'; +COMMENT ON COLUMN exam_enrollments.show_exercise_answers IS 'Used when teacher is testing exam to show the answers if true'; diff --git a/services/headless-lms/models/.sqlx/query-15a135aba7f3d617e8b2b6e0e3782a323846b5aaadfc0c203ad6d8104c4f1393.json b/services/headless-lms/models/.sqlx/query-15a135aba7f3d617e8b2b6e0e3782a323846b5aaadfc0c203ad6d8104c4f1393.json new file mode 100644 index 000000000000..081a55e27fa0 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-15a135aba7f3d617e8b2b6e0e3782a323846b5aaadfc0c203ad6d8104c4f1393.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "\nUPDATE exam_enrollments\nSET show_exercise_answers = $3\nWHERE exam_id = $1\n AND user_id = $2\n AND deleted_at IS NULL\n", + "describe": { + "columns": [], + "parameters": { + "Left": ["Uuid", "Uuid", "Bool"] + }, + "nullable": [] + }, + "hash": "15a135aba7f3d617e8b2b6e0e3782a323846b5aaadfc0c203ad6d8104c4f1393" +} diff --git a/services/headless-lms/models/.sqlx/query-3c54ad6a3cc0b88764f80e64680724a116966367fb28fcb0c85eb1a04c4e8624.json b/services/headless-lms/models/.sqlx/query-3c54ad6a3cc0b88764f80e64680724a116966367fb28fcb0c85eb1a04c4e8624.json deleted file mode 100644 index 2f7aab8cebc8..000000000000 --- a/services/headless-lms/models/.sqlx/query-3c54ad6a3cc0b88764f80e64680724a116966367fb28fcb0c85eb1a04c4e8624.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\nSELECT user_id,\n exam_id,\n started_at\nFROM exam_enrollments\nWHERE exam_id = $1\n AND user_id = $2\n AND deleted_at IS NULL\n", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "user_id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "exam_id", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "started_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": ["Uuid", "Uuid"] - }, - "nullable": [false, false, false] - }, - "hash": "3c54ad6a3cc0b88764f80e64680724a116966367fb28fcb0c85eb1a04c4e8624" -} diff --git a/services/headless-lms/models/.sqlx/query-42c1ad15a0ffb7e696bfacb50fe8fda59688a331b16fd05eaef9e832a643a531.json b/services/headless-lms/models/.sqlx/query-42c1ad15a0ffb7e696bfacb50fe8fda59688a331b16fd05eaef9e832a643a531.json new file mode 100644 index 000000000000..05f597fc1988 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-42c1ad15a0ffb7e696bfacb50fe8fda59688a331b16fd05eaef9e832a643a531.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "\nUPDATE exam_enrollments\nSET started_at = $3\nWHERE exam_id = $1\n AND user_id = $2\n AND deleted_at IS NULL\n", + "describe": { + "columns": [], + "parameters": { + "Left": ["Uuid", "Uuid", "Timestamptz"] + }, + "nullable": [] + }, + "hash": "42c1ad15a0ffb7e696bfacb50fe8fda59688a331b16fd05eaef9e832a643a531" +} diff --git a/services/headless-lms/models/.sqlx/query-527be2e3679b1f889452a1970d9ce568dbd02458be84e5a8006d4917e8b8974d.json b/services/headless-lms/models/.sqlx/query-527be2e3679b1f889452a1970d9ce568dbd02458be84e5a8006d4917e8b8974d.json new file mode 100644 index 000000000000..0c2e6b0feae8 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-527be2e3679b1f889452a1970d9ce568dbd02458be84e5a8006d4917e8b8974d.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT user_id,\n exam_id,\n started_at,\n is_teacher_testing,\n show_exercise_answers\nFROM exam_enrollments\nWHERE exam_id = $1\n AND user_id = $2\n AND deleted_at IS NULL\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "exam_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "started_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "is_teacher_testing", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "show_exercise_answers", + "type_info": "Bool" + } + ], + "parameters": { + "Left": ["Uuid", "Uuid"] + }, + "nullable": [false, false, false, false, false] + }, + "hash": "527be2e3679b1f889452a1970d9ce568dbd02458be84e5a8006d4917e8b8974d" +} diff --git a/services/headless-lms/models/.sqlx/query-c5c02778029045a48b32567f114b8fbd441b01ac1e0908eca4e02a067327036e.json b/services/headless-lms/models/.sqlx/query-8ea4b63e5770221ab693be4dc6f3b213bdd75361727872439d7fbb65117b4700.json similarity index 55% rename from services/headless-lms/models/.sqlx/query-c5c02778029045a48b32567f114b8fbd441b01ac1e0908eca4e02a067327036e.json rename to services/headless-lms/models/.sqlx/query-8ea4b63e5770221ab693be4dc6f3b213bdd75361727872439d7fbb65117b4700.json index 55befc793631..7ec14ac87413 100644 --- a/services/headless-lms/models/.sqlx/query-c5c02778029045a48b32567f114b8fbd441b01ac1e0908eca4e02a067327036e.json +++ b/services/headless-lms/models/.sqlx/query-8ea4b63e5770221ab693be4dc6f3b213bdd75361727872439d7fbb65117b4700.json @@ -1,12 +1,12 @@ { "db_name": "PostgreSQL", - "query": "\nUPDATE exams\nSET name = COALESCE($2, name),\n starts_at = $3,\n ends_at = $4,\n time_minutes = $5\nWHERE id = $1\n", + "query": "\nUPDATE exams\nSET name = COALESCE($2, name),\n starts_at = $3,\n ends_at = $4,\n time_minutes = $5,\n minimum_points_treshold = $6\nWHERE id = $1\n", "describe": { "columns": [], "parameters": { - "Left": ["Uuid", "Varchar", "Timestamptz", "Timestamptz", "Int4"] + "Left": ["Uuid", "Varchar", "Timestamptz", "Timestamptz", "Int4", "Int4"] }, "nullable": [] }, - "hash": "c5c02778029045a48b32567f114b8fbd441b01ac1e0908eca4e02a067327036e" + "hash": "8ea4b63e5770221ab693be4dc6f3b213bdd75361727872439d7fbb65117b4700" } diff --git a/services/headless-lms/models/.sqlx/query-a0506edd480e7dec860a33702092c243a207450909a69db5a44ad6fcb72b2fb2.json b/services/headless-lms/models/.sqlx/query-a0506edd480e7dec860a33702092c243a207450909a69db5a44ad6fcb72b2fb2.json deleted file mode 100644 index a59e76e1b6bd..000000000000 --- a/services/headless-lms/models/.sqlx/query-a0506edd480e7dec860a33702092c243a207450909a69db5a44ad6fcb72b2fb2.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\nINSERT INTO exam_enrollments (exam_id, user_id)\nVALUES ($1, $2)\n", - "describe": { - "columns": [], - "parameters": { - "Left": ["Uuid", "Uuid"] - }, - "nullable": [] - }, - "hash": "a0506edd480e7dec860a33702092c243a207450909a69db5a44ad6fcb72b2fb2" -} diff --git a/services/headless-lms/models/.sqlx/query-a64335b0b2edd67fd22c3bf56387eea7da8f3041f75d766c419eef5b99a8df65.json b/services/headless-lms/models/.sqlx/query-a64335b0b2edd67fd22c3bf56387eea7da8f3041f75d766c419eef5b99a8df65.json new file mode 100644 index 000000000000..398c2693d25b --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-a64335b0b2edd67fd22c3bf56387eea7da8f3041f75d766c419eef5b99a8df65.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT id,\n name,\n instructions,\n starts_at,\n ends_at,\n time_minutes,\n organization_id,\n minimum_points_treshold\nFROM exams\nWHERE exams.id = $1\n AND exams.deleted_at IS NULL\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "instructions", + "type_info": "Jsonb" + }, + { + "ordinal": 3, + "name": "starts_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "ends_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "time_minutes", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "organization_id", + "type_info": "Uuid" + }, + { + "ordinal": 7, + "name": "minimum_points_treshold", + "type_info": "Int4" + } + ], + "parameters": { + "Left": ["Uuid"] + }, + "nullable": [false, false, false, true, true, false, false, false] + }, + "hash": "a64335b0b2edd67fd22c3bf56387eea7da8f3041f75d766c419eef5b99a8df65" +} diff --git a/services/headless-lms/models/.sqlx/query-c5086375e71ac768c634490a0bb537ed63a9eeed15b50fda492854f956cd1b3f.json b/services/headless-lms/models/.sqlx/query-c5086375e71ac768c634490a0bb537ed63a9eeed15b50fda492854f956cd1b3f.json new file mode 100644 index 000000000000..f679ef1b508e --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-c5086375e71ac768c634490a0bb537ed63a9eeed15b50fda492854f956cd1b3f.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "\nINSERT INTO exam_enrollments (exam_id, user_id, is_teacher_testing)\nVALUES ($1, $2, $3)\n", + "describe": { + "columns": [], + "parameters": { + "Left": ["Uuid", "Uuid", "Bool"] + }, + "nullable": [] + }, + "hash": "c5086375e71ac768c634490a0bb537ed63a9eeed15b50fda492854f956cd1b3f" +} diff --git a/services/headless-lms/models/.sqlx/query-db3e9aa8e3116435c3b9a75823b14aefcff45a0044d20e956b436e2401f94a79.json b/services/headless-lms/models/.sqlx/query-db3e9aa8e3116435c3b9a75823b14aefcff45a0044d20e956b436e2401f94a79.json new file mode 100644 index 000000000000..0b01c6fa6152 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-db3e9aa8e3116435c3b9a75823b14aefcff45a0044d20e956b436e2401f94a79.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "\nUPDATE exercise_slide_submissions\nSET deleted_at = now()\nWHERE exam_id = $1 AND user_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": ["Uuid", "Uuid"] + }, + "nullable": [] + }, + "hash": "db3e9aa8e3116435c3b9a75823b14aefcff45a0044d20e956b436e2401f94a79" +} diff --git a/services/headless-lms/models/src/exams.rs b/services/headless-lms/models/src/exams.rs index 5af8251c3203..e59de004a185 100644 --- a/services/headless-lms/models/src/exams.rs +++ b/services/headless-lms/models/src/exams.rs @@ -184,35 +184,23 @@ RETURNING id Ok(res.id) } -pub async fn edit( - conn: &mut PgConnection, - id: Uuid, - name: Option<&str>, - starts_at: Option>, - ends_at: Option>, - time_minutes: Option, -) -> ModelResult<()> { - if time_minutes.map(|i| i > 0).unwrap_or_default() { - return Err(ModelError::new( - ModelErrorType::InvalidRequest, - "Exam duration has to be positive".to_string(), - None, - )); - } +pub async fn edit(conn: &mut PgConnection, id: Uuid, new_exam: NewExam) -> ModelResult<()> { sqlx::query!( " UPDATE exams SET name = COALESCE($2, name), starts_at = $3, ends_at = $4, - time_minutes = $5 + time_minutes = $5, + minimum_points_treshold = $6 WHERE id = $1 ", id, - name, - starts_at, - ends_at, - time_minutes, + new_exam.name, + new_exam.starts_at, + new_exam.ends_at, + new_exam.time_minutes, + new_exam.minimum_points_treshold, ) .execute(conn) .await?; @@ -245,6 +233,32 @@ WHERE exams.organization_id = $1 Ok(res) } +pub async fn get_organization_exam_with_exam_id( + conn: &mut PgConnection, + exam_id: Uuid, +) -> ModelResult { + let res = sqlx::query_as!( + OrgExam, + " +SELECT id, + name, + instructions, + starts_at, + ends_at, + time_minutes, + organization_id, + minimum_points_treshold +FROM exams +WHERE exams.id = $1 + AND exams.deleted_at IS NULL +", + exam_id + ) + .fetch_one(conn) + .await?; + Ok(res) +} + pub async fn get_course_exams_for_organization( conn: &mut PgConnection, organization: Uuid, @@ -294,14 +308,20 @@ FROM exams Ok(res) } -pub async fn enroll(conn: &mut PgConnection, exam_id: Uuid, user_id: Uuid) -> ModelResult<()> { +pub async fn enroll( + conn: &mut PgConnection, + exam_id: Uuid, + user_id: Uuid, + is_teacher_testing: bool, +) -> ModelResult<()> { sqlx::query!( " -INSERT INTO exam_enrollments (exam_id, user_id) -VALUES ($1, $2) +INSERT INTO exam_enrollments (exam_id, user_id, is_teacher_testing) +VALUES ($1, $2, $3) ", exam_id, - user_id + user_id, + is_teacher_testing ) .execute(conn) .await?; @@ -336,6 +356,8 @@ pub struct ExamEnrollment { pub user_id: Uuid, pub exam_id: Uuid, pub started_at: DateTime, + pub is_teacher_testing: bool, + pub show_exercise_answers: Option, } pub async fn get_enrollment( @@ -348,7 +370,9 @@ pub async fn get_enrollment( " SELECT user_id, exam_id, - started_at + started_at, + is_teacher_testing, + show_exercise_answers FROM exam_enrollments WHERE exam_id = $1 AND user_id = $2 @@ -362,6 +386,52 @@ WHERE exam_id = $1 Ok(res) } +pub async fn update_exam_start_time( + conn: &mut PgConnection, + exam_id: Uuid, + user_id: Uuid, + started_at: DateTime, +) -> ModelResult<()> { + sqlx::query!( + " +UPDATE exam_enrollments +SET started_at = $3 +WHERE exam_id = $1 + AND user_id = $2 + AND deleted_at IS NULL +", + exam_id, + user_id, + started_at + ) + .execute(conn) + .await?; + Ok(()) +} + +pub async fn update_show_exercise_answers( + conn: &mut PgConnection, + exam_id: Uuid, + user_id: Uuid, + show_exercise_answers: bool, +) -> ModelResult<()> { + sqlx::query!( + " +UPDATE exam_enrollments +SET show_exercise_answers = $3 +WHERE exam_id = $1 + AND user_id = $2 + AND deleted_at IS NULL +", + exam_id, + user_id, + show_exercise_answers + ) + .execute(conn) + .await?; + Ok(()) +} + pub async fn get_organization_id(conn: &mut PgConnection, exam_id: Uuid) -> ModelResult { let organization_id = sqlx::query!( " diff --git a/services/headless-lms/models/src/exercise_slide_submissions.rs b/services/headless-lms/models/src/exercise_slide_submissions.rs index 7fa769526755..3b8515bf74a6 100644 --- a/services/headless-lms/models/src/exercise_slide_submissions.rs +++ b/services/headless-lms/models/src/exercise_slide_submissions.rs @@ -754,3 +754,22 @@ pub async fn get_all_exercise_slide_submission_info( exercise_slide_submission, }) } + +pub async fn delete_exercise_submissions_with_exam_id_and_user_id( + conn: &mut PgConnection, + exam_id: Uuid, + user_id: Uuid, +) -> ModelResult<()> { + sqlx::query!( + " +UPDATE exercise_slide_submissions +SET deleted_at = now() +WHERE exam_id = $1 AND user_id = $2 + ", + exam_id, + user_id, + ) + .execute(&mut *conn) + .await?; + Ok(()) +} diff --git a/services/headless-lms/server/src/controllers/course_material/exams.rs b/services/headless-lms/server/src/controllers/course_material/exams.rs index 1402b6c675ad..7e9e7fafc9be 100644 --- a/services/headless-lms/server/src/controllers/course_material/exams.rs +++ b/services/headless-lms/server/src/controllers/course_material/exams.rs @@ -21,6 +21,11 @@ pub async fn enrollment( token.authorized_ok(web::Json(enrollment)) } +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct IsTeacherTesting { + pub is_teacher_testing: bool, +} /** POST /api/v0/course-material/exams/:id/enroll */ @@ -29,6 +34,7 @@ pub async fn enroll( pool: web::Data, exam_id: web::Path, user: AuthUser, + payload: web::Json, ) -> ControllerResult> { let mut conn = pool.acquire().await?; let exam = exams::get(&mut conn, *exam_id).await?; @@ -43,6 +49,12 @@ pub async fn enroll( )); } + // enroll if teacher is testing regardless of exams starting time + if payload.is_teacher_testing { + exams::enroll(&mut conn, *exam_id, user.id, payload.is_teacher_testing).await?; + let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?; + return token.authorized_ok(web::Json(())); + } if exam.started_at_or(now, false) { // This check should probably be handled in the authorize function but I'm not sure of // the proper action type. @@ -55,7 +67,7 @@ pub async fn enroll( None, )); } - exams::enroll(&mut conn, *exam_id, user.id).await?; + exams::enroll(&mut conn, *exam_id, user.id, payload.is_teacher_testing).await?; let token = skip_authorize(); return token.authorized_ok(web::Json(())); } @@ -209,6 +221,123 @@ pub async fn fetch_exam_for_user( })) } +/** +GET /api/v0/course-material/exams/:id/fetch-exam-for-testing + +Fetches an exam for testing. +*/ +#[instrument(skip(pool))] +pub async fn fetch_exam_for_testing( + pool: web::Data, + exam_id: web::Path, + user: AuthUser, +) -> ControllerResult> { + let mut conn = pool.acquire().await?; + let exam = exams::get(&mut conn, *exam_id).await?; + + let starts_at = Utc::now(); + let ends_at = if let Some(ends_at) = exam.ends_at { + ends_at + } else { + return Err(ControllerError::new( + ControllerErrorType::Forbidden, + "Cannot fetch exam that has no end time".to_string(), + None, + )); + }; + let ended = ends_at < Utc::now(); + + let enrollment = if let Some(enrollment) = + exams::get_enrollment(&mut conn, *exam_id, user.id).await? + { + enrollment + } else { + // user has not started the exam + let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?; + let can_enroll = + models::library::progressing::user_can_take_exam(&mut conn, *exam_id, user.id).await?; + return token.authorized_ok(web::Json(ExamData { + id: exam.id, + name: exam.name, + instructions: exam.instructions, + starts_at, + ends_at, + ended, + time_minutes: exam.time_minutes, + enrollment_data: ExamEnrollmentData::NotEnrolled { can_enroll }, + language: exam.language, + })); + }; + + let page = pages::get_page(&mut conn, exam.page_id).await?; + + let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?; + token.authorized_ok(web::Json(ExamData { + id: exam.id, + name: exam.name, + instructions: exam.instructions, + starts_at, + ends_at, + ended, + time_minutes: exam.time_minutes, + enrollment_data: ExamEnrollmentData::EnrolledAndStarted { + page_id: exam.page_id, + page: Box::new(page), + enrollment, + }, + language: exam.language, + })) +} + +/** +GET /api/v0/course-material/exams/:id/reset-exam-progress + +Used for testing an exam, resets exercise submissions and restarts the exam time. +*/ +#[instrument(skip(pool))] +pub async fn reset_exam_progress( + pool: web::Data, + exam_id: web::Path, + user: AuthUser, +) -> ControllerResult> { + let mut conn = pool.acquire().await?; + + let started_at = Utc::now(); + exams::update_exam_start_time(&mut conn, *exam_id, user.id, started_at).await?; + + models::exercise_slide_submissions::delete_exercise_submissions_with_exam_id_and_user_id( + &mut conn, *exam_id, user.id, + ) + .await?; + + let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?; + token.authorized_ok(web::Json(())) +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct ShowExerciseAnswers { + pub show_exercise_answers: bool, +} +/** +GET /api/v0/course-material/exams/:id/update-show-exercise-answers + +Used for testing an exam, updates wheter exercise answers are shown. +*/ +#[instrument(skip(pool))] +pub async fn update_show_exercise_answers( + pool: web::Data, + exam_id: web::Path, + user: AuthUser, + payload: web::Json, +) -> ControllerResult> { + let mut conn = pool.acquire().await?; + let show_answers = payload.show_exercise_answers; + exams::update_show_exercise_answers(&mut conn, *exam_id, user.id, show_answers).await?; + let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?; + token.authorized_ok(web::Json(())) +} + /** Add a route for each controller in this module. @@ -219,5 +348,17 @@ We add the routes by calling the route method instead of using the route annotat pub fn _add_routes(cfg: &mut ServiceConfig) { cfg.route("/{id}/enrollment", web::get().to(enrollment)) .route("/{id}/enroll", web::post().to(enroll)) - .route("/{id}", web::get().to(fetch_exam_for_user)); + .route("/{id}", web::get().to(fetch_exam_for_user)) + .route( + "/testexam/{id}/fetch-exam-for-testing", + web::get().to(fetch_exam_for_testing), + ) + .route( + "/testexam/{id}/update-show-exercise-answers", + web::post().to(update_show_exercise_answers), + ) + .route( + "/testexam/{id}/reset-exam-progress", + web::post().to(reset_exam_progress), + ); } diff --git a/services/headless-lms/server/src/controllers/course_material/exercises.rs b/services/headless-lms/server/src/controllers/course_material/exercises.rs index a3c31d5b4090..b81b0b51790b 100644 --- a/services/headless-lms/server/src/controllers/course_material/exercises.rs +++ b/services/headless-lms/server/src/controllers/course_material/exercises.rs @@ -46,8 +46,25 @@ async fn get_exercise( models_requests::fetch_service_info, ) .await?; + + let mut should_clear_grading_information = true; + // Check if teacher is testing an exam and wants to see the exercise answers + if let Some(exam_id) = course_material_exercise.exercise.exam_id { + let user_enrollment = + models::exams::get_enrollment(&mut conn, exam_id, user_id.unwrap()).await?; + + if let Some(enrollment) = user_enrollment { + if let Some(show_answers) = enrollment.show_exercise_answers { + if enrollment.is_teacher_testing && show_answers { + should_clear_grading_information = false; + } + } + } + } + if course_material_exercise.can_post_submission && course_material_exercise.exercise.exam_id.is_some() + && should_clear_grading_information { // Explicitely clear grading information from ongoing exam submissions. course_material_exercise.clear_grading_information(); diff --git a/services/headless-lms/server/src/controllers/main_frontend/exams.rs b/services/headless-lms/server/src/controllers/main_frontend/exams.rs index 2a16e1ab3bdc..c895988005a1 100644 --- a/services/headless-lms/server/src/controllers/main_frontend/exams.rs +++ b/services/headless-lms/server/src/controllers/main_frontend/exams.rs @@ -134,7 +134,7 @@ async fn duplicate_exam( exam_id: web::Path, new_exam: web::Json, user: AuthUser, -) -> ControllerResult> { +) -> ControllerResult> { let mut conn = pool.acquire().await?; let organization_id = models::exams::get_organization_id(&mut conn, *exam_id).await?; let token = authorize( @@ -157,9 +157,31 @@ async fn duplicate_exam( .await?; tx.commit().await?; - token.authorized_ok(web::Json(())) + token.authorized_ok(web::Json(true)) } +/** +POST `/api/v0/main-frontend/organizations/{organization_id}/edit-exam` - edits an exam. +*/ +#[instrument(skip(pool))] +async fn edit_exam( + pool: web::Data, + exam_id: web::Path, + payload: web::Json, + user: AuthUser, +) -> ControllerResult> { + let mut conn = pool.acquire().await?; + let mut tx = conn.begin().await?; + + let exam = payload.0; + let token = authorize(&mut tx, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?; + + models::exams::edit(&mut tx, *exam_id, exam).await?; + + tx.commit().await?; + + token.authorized_ok(web::Json(())) +} /** Add a route for each controller in this module. @@ -176,5 +198,6 @@ pub fn _add_routes(cfg: &mut ServiceConfig) { "/{id}/export-submissions", web::get().to(export_submissions), ) + .route("/{id}/edit-exam", web::post().to(edit_exam)) .route("/{id}/duplicate", web::post().to(duplicate_exam)); } diff --git a/services/headless-lms/server/src/controllers/main_frontend/organizations.rs b/services/headless-lms/server/src/controllers/main_frontend/organizations.rs index e468b334211e..9020d2c0ee58 100644 --- a/services/headless-lms/server/src/controllers/main_frontend/organizations.rs +++ b/services/headless-lms/server/src/controllers/main_frontend/organizations.rs @@ -285,6 +285,23 @@ async fn get_org_exams( token.authorized_ok(web::Json(exams)) } +/** +GET `/api/v0/main-frontend/organizations/{exam_id}/fetch_org_exam +*/ +#[instrument(skip(pool))] +pub async fn get_org_exam_with_exam_id( + pool: web::Data, + exam_id: web::Path, + user: AuthUser, +) -> ControllerResult> { + let mut conn = pool.acquire().await?; + let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?; + + let exam = models::exams::get_organization_exam_with_exam_id(&mut conn, *exam_id).await?; + + token.authorized_ok(web::Json(exam)) +} + /** POST `/api/v0/main-frontend/organizations/{organization_id}/exams` - Creates new exam for the organization. */ @@ -379,5 +396,9 @@ pub fn _add_routes(cfg: &mut ServiceConfig) { web::get().to(get_course_exams), ) .route("/{organization_id}/org_exams", web::get().to(get_org_exams)) + .route( + "/{organization_id}/fetch_org_exam", + web::get().to(get_org_exam_with_exam_id), + ) .route("/{organization_id}/exams", web::post().to(create_exam)); } diff --git a/services/main-frontend/src/components/forms/EditExamForm.tsx b/services/main-frontend/src/components/forms/EditExamForm.tsx new file mode 100644 index 000000000000..db573e9fceca --- /dev/null +++ b/services/main-frontend/src/components/forms/EditExamForm.tsx @@ -0,0 +1,117 @@ +import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" + +import { Exam, NewExam } from "../../shared-module/bindings" +import Button from "../../shared-module/components/Button" +import CheckBox from "../../shared-module/components/InputFields/CheckBox" +import DateTimeLocal from "../../shared-module/components/InputFields/DateTimeLocal" +import TextField from "../../shared-module/components/InputFields/TextField" +import { dateToDateTimeLocalString } from "../../shared-module/utils/time" + +interface EditExamFormProps { + initialData: Exam + organizationId: string + onEditExam: (form: NewExam) => void + onCancel: () => void +} + +interface EditExamFields { + id: string + name: string + startsAt: Date + endsAt: Date + timeMinutes: number + parentId: string | null + automaticCompletionEnabled: boolean + minimumPointsTreshold: number +} + +const EditExamForm: React.FC> = ({ + initialData, + onEditExam, + onCancel, + organizationId, +}) => { + const { t } = useTranslation() + + const { + register, + handleSubmit, + formState: { errors }, + watch, + } = useForm() + + const onEditExamWrapper = handleSubmit((data) => { + onEditExam({ + name: data.name, + starts_at: new Date(data.startsAt).toISOString(), + ends_at: new Date(data.endsAt).toISOString(), + time_minutes: Number(data.timeMinutes), + minimum_points_treshold: data.automaticCompletionEnabled + ? Number(data.minimumPointsTreshold) + : 0, + organization_id: organizationId, + }) + }) + + const automaticEnabled = watch("automaticCompletionEnabled") + + return ( +
+
+ + + + + + {automaticEnabled && ( + + )} +
+ + + +
+ ) +} + +export default EditExamForm diff --git a/services/main-frontend/src/components/page-specific/manage/courses/id/exams/EditExamDialog.tsx b/services/main-frontend/src/components/page-specific/manage/courses/id/exams/EditExamDialog.tsx new file mode 100644 index 000000000000..fa9eecd0cd78 --- /dev/null +++ b/services/main-frontend/src/components/page-specific/manage/courses/id/exams/EditExamDialog.tsx @@ -0,0 +1,87 @@ +import { css } from "@emotion/css" +import React from "react" +import { useTranslation } from "react-i18next" + +import { EditExam } from "../../../../../../services/backend/exams" +import { Exam, NewExam } from "../../../../../../shared-module/bindings" +import Dialog from "../../../../../../shared-module/components/Dialog" +import ErrorBanner from "../../../../../../shared-module/components/ErrorBanner" +import useToastMutation from "../../../../../../shared-module/hooks/useToastMutation" +import EditExamForm from "../../../../../forms/EditExamForm" + +interface ExamDialogProps { + initialData: Exam + examId: string + organizationId: string + open: boolean + close: () => void +} + +const EditExamDialog: React.FC> = ({ + examId, + open, + close, + initialData, + organizationId, +}) => { + const { t } = useTranslation() + const createExamMutation = useToastMutation( + (exam: NewExam) => EditExam(examId, exam), + { + notify: true, + successMessage: t("exam-edited-successfully"), + method: "POST", + }, + { + onSuccess: async () => { + close() + }, + }, + ) + + const onClose = () => { + createExamMutation.reset() + close() + } + + return ( +
+ +
+
+

+ {t("edit-exam")} +

+ {createExamMutation.isError && ( + + )} + createExamMutation.mutate(exam)} + /> +
+
+
+
+ ) +} + +export default EditExamDialog diff --git a/services/main-frontend/src/pages/manage/exams/[id]/index.tsx b/services/main-frontend/src/pages/manage/exams/[id]/index.tsx index eab9c2b7a238..fbfb137f53cc 100644 --- a/services/main-frontend/src/pages/manage/exams/[id]/index.tsx +++ b/services/main-frontend/src/pages/manage/exams/[id]/index.tsx @@ -3,7 +3,14 @@ import { useQuery } from "@tanstack/react-query" import React, { useState } from "react" import { useTranslation } from "react-i18next" -import { fetchExam, setCourse, unsetCourse } from "../../../../services/backend/exams" +import EditExamDialog from "../../../../components/page-specific/manage/courses/id/exams/EditExamDialog" +import { + fetchExam, + fetchOrganization, + fetchOrgExam, + setCourse, + unsetCourse, +} from "../../../../services/backend/exams" import Button from "../../../../shared-module/components/Button" import ErrorBanner from "../../../../shared-module/components/ErrorBanner" import TextField from "../../../../shared-module/components/InputFields/TextField" @@ -22,6 +29,18 @@ interface OrganizationPageProps { const Organization: React.FC> = ({ query }) => { const { t } = useTranslation() const getExam = useQuery({ queryKey: [`exam-${query.id}`], queryFn: () => fetchExam(query.id) }) + const organizationId = useQuery({ + queryKey: [`organizations-${query.id}`], + queryFn: () => fetchOrgExam(query.id), + }).data?.organization_id + + const organizationSlug = useQuery({ + queryKey: [`organizations-${organizationId}`], + queryFn: () => fetchOrganization(organizationId ?? ""), + enabled: !!organizationId, + }).data?.slug + + const [editExamFormOpen, setEditExamFormOpen] = useState(false) const [newCourse, setNewCourse] = useState("") const setCourseMutation = useToastMutation( ({ examId, courseId }: { examId: string; courseId: string }) => { @@ -37,6 +56,7 @@ const Organization: React.FC> = ( }, }, ) + const unsetCourseMutation = useToastMutation( ({ examId, courseId }: { examId: string; courseId: string }) => { return unsetCourse(examId, courseId) @@ -51,7 +71,6 @@ const Organization: React.FC> = ( }, }, ) - return (
> = ( {t("link-export-submissions")} +
  • + + {t("link-test-exam")} + +
  • +
  • +
    + { + setEditExamFormOpen(!setEditExamFormOpen) + getExam.refetch() + }} + /> +
    + +
  • {t("courses")}

    {getExam.data.courses.map((c) => ( diff --git a/services/main-frontend/src/services/backend/exams.ts b/services/main-frontend/src/services/backend/exams.ts index db3a25ca5280..039204ba480b 100644 --- a/services/main-frontend/src/services/backend/exams.ts +++ b/services/main-frontend/src/services/backend/exams.ts @@ -1,10 +1,25 @@ -import { CourseExam, Exam, ExamCourseInfo, NewExam, OrgExam } from "../../shared-module/bindings" +import { + CourseExam, + Exam, + ExamCourseInfo, + NewExam, + Organization, + OrgExam, +} from "../../shared-module/bindings" +import { isOrganization } from "../../shared-module/bindings.guard" +import { validateResponse } from "../../shared-module/utils/fetching" import { mainFrontendClient } from "../mainFrontendClient" export const createExam = async (organizationId: string, data: NewExam) => { await mainFrontendClient.post(`/organizations/${organizationId}/exams`, data) } +export const EditExam = async (examId: string, data: NewExam) => { + await mainFrontendClient.post(`/exams/${examId}/edit-exam`, data, { + responseType: "json", + }) +} + export const createExamDuplicate = async (examId: string, newExam: NewExam) => { return (await mainFrontendClient.post(`/exams/${examId}/duplicate`, newExam)).data } @@ -14,6 +29,10 @@ export const fetchExam = async (id: string): Promise => { return response.data } +export const fetchOrgExam = async (examId: string): Promise => { + const response = await mainFrontendClient.get(`/organizations/${examId}/fetch_org_exam`, {}) + return response.data +} export const fetchCourseExams = async (organizationId: string): Promise> => { const response = await mainFrontendClient.get(`/organizations/${organizationId}/course_exams`) return response.data @@ -23,6 +42,10 @@ export const fetchOrganizationExams = async (organizationId: string): Promise => { + const response = await mainFrontendClient.get(`/organizations/${organizationId}`) + return validateResponse(response, isOrganization) +} export const setCourse = async (examId: string, courseId: string): Promise => { const data: ExamCourseInfo = { course_id: courseId } await mainFrontendClient.post(`/exams/${examId}/set`, data) diff --git a/shared-module/src/bindings.guard.ts b/shared-module/src/bindings.guard.ts index 71c71caeaf3e..a3b1bf366636 100644 --- a/shared-module/src/bindings.guard.ts +++ b/shared-module/src/bindings.guard.ts @@ -1048,7 +1048,11 @@ export function isExamEnrollment(obj: unknown): obj is ExamEnrollment { ((typedObj !== null && typeof typedObj === "object") || typeof typedObj === "function") && typeof typedObj["user_id"] === "string" && typeof typedObj["exam_id"] === "string" && - typeof typedObj["started_at"] === "string" + typeof typedObj["started_at"] === "string" && + typeof typedObj["is_teacher_testing"] === "boolean" && + (typedObj["show_exercise_answers"] === null || + typedObj["show_exercise_answers"] === false || + typedObj["show_exercise_answers"] === true) ) } diff --git a/shared-module/src/bindings.ts b/shared-module/src/bindings.ts index 0d90b14ae1cf..b5854a5bffe2 100644 --- a/shared-module/src/bindings.ts +++ b/shared-module/src/bindings.ts @@ -504,6 +504,8 @@ export interface ExamEnrollment { user_id: string exam_id: string started_at: string + is_teacher_testing: boolean + show_exercise_answers: boolean | null } export interface ExamInstructions { diff --git a/shared-module/src/locales/en/course-material.json b/shared-module/src/locales/en/course-material.json index 2a56c0b930b6..0170b543d33d 100644 --- a/shared-module/src/locales/en/course-material.json +++ b/shared-module/src/locales/en/course-material.json @@ -17,6 +17,7 @@ "button-text-give-extra-peer-review": "Give extra peer review", "button-text-manage-course": "Manage course", "button-text-refresh": "Refresh", + "button-text-reset-exam-progress": "Reset exam progress", "can-comment-on-portions-of-material-by-highlightig": "You can comment on specific portions of the material by highlighting it.", "cannot-render-exercise-task-missing-url": "Cannot render exercise task, missing url.", "chapter-chapter-number-chapter-name": "Chapter {{chapterNumber}}: {{chapterName}}", @@ -145,6 +146,7 @@ "select-course-version-to-see-your-progress": "Select course version to see your progress.", "send": "Send", "settings": "Settings", + "show-answers": "Show answers", "start-peer-review": "Start peer review", "start-studying": "Start studying...", "start-the-exam": "Start the exam!", diff --git a/shared-module/src/locales/en/main-frontend.json b/shared-module/src/locales/en/main-frontend.json index 2c86db64f983..49b2216b95ec 100644 --- a/shared-module/src/locales/en/main-frontend.json +++ b/shared-module/src/locales/en/main-frontend.json @@ -142,6 +142,7 @@ "ects-credits": "ECTS credits", "edit": "Edit", "edit-and-accept": "Edit and accept", + "edit-exam": "Edit exam", "edit-module": "Edit module", "edit-reference": "Edit reference", "edit-role": "Edit role", @@ -181,6 +182,7 @@ "estimated-number-of-ects-credits-warning": "Warning! This relies on the ECTS credits field in the course module configuration. The estimate is calculated by taking this number and multiplying it by the number of people marked as having registered their completions to the study registry. The estimate can be incorrect, for example, if the ECTS credits have been inputted wrong to the course module, or if the ECTS credits amount changes during the course, or if the ECTS credits are not registered using this system. If you want accurate statistics on this, you'll need to use Oodikone.", "exam-created-succesfully": "Exam created succesfully", "exam-duplicated-succesfully": "Exam duplicated succesfully", + "exam-edited-successfully": "Exam edited successfully", "exam-list": "Exams", "exercise": "Exercise", "exercise-repositories-add": "Add exercise repository", @@ -372,6 +374,7 @@ "link-pages": "Pages", "link-permissions": "Permissions", "link-stats": "Stats", + "link-test-exam": "Test exam", "link-text-all-organizations": "All organizations", "link-text-find-more-courses": "Find more courses", "link-text-global-stats": "Global stats", diff --git a/shared-module/src/locales/fi/course-material.json b/shared-module/src/locales/fi/course-material.json index 5fd54ae7816e..a42ecd4d74cd 100644 --- a/shared-module/src/locales/fi/course-material.json +++ b/shared-module/src/locales/fi/course-material.json @@ -17,6 +17,7 @@ "button-text-give-extra-peer-review": "Anna ylimääräinen vertaisarvio", "button-text-manage-course": "Hallinnoi kurssia", "button-text-refresh": "Päivitä", + "button-text-reset-exam-progress": "Nollaa kokeen edistyminen", "can-comment-on-portions-of-material-by-highlightig": "Voit kommentoida tiettyjä kohtia materiaalista valitsemalla sen", "cannot-render-exercise-task-missing-url": "Tehtävänantoa ei voida näyttää, osoite puuttuu.", "chapter": "Luku", @@ -148,6 +149,7 @@ "select-course-version-to-see-your-progress": "Nähdäksesi edistymisesi, valitse kurssiversio", "send": "Lähetä", "settings": "Asetukset", + "show-answers": "Näytä vastaukset", "start-peer-review": "Aloita vertaisarviointi", "start-studying": "Aloita opiskelu...", "start-the-exam": "Aloita tentti!", diff --git a/shared-module/src/locales/fi/main-frontend.json b/shared-module/src/locales/fi/main-frontend.json index 637314d5a1b8..7d50ecbe65d1 100644 --- a/shared-module/src/locales/fi/main-frontend.json +++ b/shared-module/src/locales/fi/main-frontend.json @@ -144,6 +144,7 @@ "ects-credits": "ECTS credits", "edit": "Muokkaa", "edit-and-accept": "Muokkaa ja hyväksy", + "edit-exam": "Muokkaa koetta", "edit-module": "Muokkaa moduulia", "edit-reference": "Muokkaa lähdeviitettä", "edit-role": "Muuta roolia", @@ -183,6 +184,7 @@ "estimated-number-of-ects-credits-warning": "Varoitus! Tämä perustuu kurssimoduulin asetuksissa olevaan opintopisteet kenttään. Arvio lasketaan ottamalla tämä luku ja kertomalla se niiden henkilöiden lukumäärällä, jotka ovat merkitty järjestelmässä rekisteröineen suorituksensa opintorekisteriin. Arvio voi olla esimerkiksi virheellinen, jos opintopistemäärä on syötetty kurssimoduuliin väärin tai jos opintopistemäärä muuttuu kurssin aikana tai jos opintopisteitä rekisteröidään käyttämättä tätä järjestelmää. Jos haluat tarkkoja tilastoja sinun täytyy käyttää Oodikonetta.", "exam-created-succesfully": "Koe luotu", "exam-duplicated-succesfully": "Koe monistettu", + "exam-edited-successfully": "Koe muokattu", "exam-list": "Kokeet", "exercise": "Tehtävä", "exercise-repositories-add": "Add exercise repository", @@ -376,6 +378,7 @@ "link-pages": "Sivut", "link-permissions": "Oikeudet", "link-stats": "Tilastot", + "link-test-exam": "Testaa koetta", "link-text-all-organizations": "Kaikki organisaatiot", "link-text-find-more-courses": "Löydä lisää kursseja", "link-text-global-stats": "Järjestelmänlaajuiset tilastot", diff --git a/system-tests/src/tests/exams/edit-exam.spec.ts b/system-tests/src/tests/exams/edit-exam.spec.ts new file mode 100644 index 000000000000..febf3031152e --- /dev/null +++ b/system-tests/src/tests/exams/edit-exam.spec.ts @@ -0,0 +1,30 @@ +import { expect, test } from "@playwright/test" + +test.use({ + storageState: "src/states/teacher@example.com.json", +}) + +test("Editing exam works", async ({ page }) => { + await page.goto("http://project-331.local/organizations") + await page.getByLabel("University of Helsinki, Department of Computer Science").click() + //Create exam + await page.getByRole("button", { name: "Create" }).nth(1).click() + await page.getByLabel("Name", { exact: true }).fill("Test exam") + await page.getByLabel("Starts at").fill("1990-12-03T12:00") + await page.getByLabel("Ends at").fill("2052-03-09T09:08:01") + await page.getByLabel("Time in minutes", { exact: true }).fill("60") + await page.getByRole("button", { name: "Submit" }).click() + + await page.locator("li").filter({ hasText: "Test examManage" }).getByRole("link").nth(1).click() + + //Edit exam + await page.getByRole("button", { name: "Edit exam" }).click() + await page.getByLabel("Name", { exact: true }).fill("New name") + await page.getByLabel("Time in minutes", { exact: true }).fill("120") + await page.getByText("Related courses can be").click() + await page.getByLabel("Minimum points to pass", { exact: true }).fill("20") + await page.getByRole("button", { name: "Submit" }).click() + await page.getByText("Exam edited successfully").waitFor() + + await expect(page.getByRole("heading", { name: "New name" })).toBeVisible() +}) diff --git a/system-tests/src/tests/exams/teacher-can-test-exam.spec.ts b/system-tests/src/tests/exams/teacher-can-test-exam.spec.ts new file mode 100644 index 000000000000..d8bb96110cd0 --- /dev/null +++ b/system-tests/src/tests/exams/teacher-can-test-exam.spec.ts @@ -0,0 +1,116 @@ +import { expect, test } from "@playwright/test" + +test.use({ + storageState: "src/states/teacher@example.com.json", +}) + +test("Testing exam works", async ({ page }) => { + await page.goto("http://project-331.local/organizations") + await page.getByLabel("University of Helsinki, Department of Computer Science").click() + //Create exam + await page.getByRole("button", { name: "Create" }).nth(1).click() + await page.getByLabel("Name", { exact: true }).fill("Exam for testing") + await page.getByLabel("Starts at").fill("1990-12-03T12:00") + await page.getByLabel("Ends at").fill("2052-03-09T09:08:01") + await page.getByLabel("Time in minutes", { exact: true }).fill("60") + await page.getByRole("button", { name: "Submit" }).click() + + await page + .locator("li") + .filter({ hasText: "Exam for testingManage" }) + .getByRole("link") + .nth(1) + .click() + + //Add exercise to exam + await page.getByRole("link", { name: "Manage page" }).click() + + await page.getByLabel("Toggle view").selectOption("block-menu") + await page.getByRole("option", { name: "Exercise", exact: true }).click() + await page.getByPlaceholder("Exercise name").fill("Exercise name") + + await page.getByRole("button", { name: "Add slide" }).click() + await page.getByRole("button", { name: "Add task" }).click() + await page.getByLabel("Edit").click() + await page.getByRole("button", { name: "Quizzes" }).click() + await page + .frameLocator('iframe[title="IFRAME EDITOR"]') + .getByRole("button", { name: "Multiple choice Choose" }) + .click() + await page + .frameLocator('iframe[title="IFRAME EDITOR"]') + .getByLabel("Title", { exact: true }) + .fill("Multiple choice") + await page + .frameLocator('iframe[title="IFRAME EDITOR"]') + .getByLabel("Option title", { exact: true }) + .fill("Correct answer") + await page.frameLocator('iframe[title="IFRAME EDITOR"]').getByLabel("Correct").check() + await page + .frameLocator('iframe[title="IFRAME EDITOR"]') + .getByRole("button", { name: "Add option" }) + .click() + await page + .frameLocator('iframe[title="IFRAME EDITOR"]') + .getByLabel("Option title", { exact: true }) + .fill("Wrong answer") + await page + .frameLocator('iframe[title="IFRAME EDITOR"]') + .getByRole("button", { name: "Add option" }) + .click() + await page.getByRole("button", { name: "Save", exact: true }).click() + await page.getByText("Success", { exact: true }).click() + + await page.goto("http://project-331.local/organizations") + await page.getByLabel("University of Helsinki, Department of Computer Science").click() + await page + .locator("li") + .filter({ hasText: "Exam for testingManage" }) + .getByRole("link") + .nth(1) + .click() + + //Test exam + await page.getByRole("link", { name: "Test exam", exact: true }).click() + page.on("dialog", (dialog) => dialog.accept()) + await page.locator(`button:text("Start the exam!")`).click() + + await page + .frameLocator('iframe[title="Exercise 1\\, task 1 content"]') + .getByRole("button", { name: "Correct answer" }) + .click() + await page.getByRole("button", { name: "Submit" }).click() + + //Show exercise answers + await page.getByLabel("show answers").check() + await expect( + page + .frameLocator('iframe[title="Exercise 1\\, task 1 content"]') + .getByText("Your answer was correct."), + ).toBeVisible() + + //Hide exercise answers + await page.getByLabel("show answers").uncheck() + await expect( + page + .frameLocator('iframe[title="Exercise 1\\, task 1 content"]') + .getByText("Your answer was correct."), + ).toBeHidden() + + //Reset exam progress + await page.getByRole("button", { name: "Reset" }).click() + await page.getByRole("button", { name: "Submit" }).isDisabled() + + await page + .frameLocator('iframe[title="Exercise 1\\, task 1 content"]') + .getByRole("button", { name: "Correct answer" }) + .click() + await page.getByRole("button", { name: "Submit" }).click() + await page.getByText("Your submission has been").isVisible() + await page.getByText("Show answers").click() + await expect( + page + .frameLocator('iframe[title="Exercise 1\\, task 1 content"]') + .getByText("Your answer was correct."), + ).toBeVisible() +}) From df49a6d7eb5dcd6fe0620b21e2fb11b5639dbef6 Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Wed, 20 Mar 2024 20:42:06 +0200 Subject: [PATCH 3/3] Longer questions in research consents & regradings by exercise id (#1254) * Research consent question fixes * Allow creating a regrading by exercise id * System test fixes * System test fixes --- .../blocks/ResearchConsentCheckbox/index.tsx | 28 ---------- .../ResearchConsentQuestionEditor.tsx} | 17 +++--- .../ResearchConsentQuestionSave.tsx} | 0 .../blocks/ResearchConsentQuestion/index.tsx | 29 +++++++++++ services/cms/src/blocks/index.tsx | 4 +- .../editors/ResearchConsentFormEditor.tsx | 4 +- .../pages/courses/[id]/research-form-edit.tsx | 52 ++++++++++--------- services/cms/src/services/backend/courses.ts | 6 +-- .../src/components/ContentRenderer/index.tsx | 4 +- ...k.tsx => ResearchConsentQuestionBlock.tsx} | 0 ...fic_research_form_question_length.down.sql | 2 + ...cific_research_form_question_length.up.sql | 3 ++ ...4a4b18c74e3e78795123913f5cd1b01cd048.json} | 4 +- ...c40b7ade11132d8f6fb378dc7f8409ed46938.json | 18 +++++++ ...7ded62bbba437fa27458ea54021a84c805d5f.json | 18 +++++++ .../models/src/exercise_task_submissions.rs | 45 ++++++++++++++++ .../models/src/library/regrading.rs | 25 +++++++++ .../headless-lms/models/src/regradings.rs | 42 +++++++++++++-- .../headless-lms/models/src/research_forms.rs | 40 +++++++++----- .../server/src/controllers/cms/courses.rs | 16 +++--- .../server/src/ts_binding_generator.rs | 1 + services/main-frontend/src/pages/index.tsx | 15 ++++++ .../src/pages/manage/regradings/[id].tsx | 2 +- .../src/pages/manage/regradings/index.tsx | 47 +++++++++++++---- shared-module/src/bindings.guard.ts | 11 +++- shared-module/src/bindings.ts | 5 +- shared-module/src/locales/en/cms.json | 3 +- .../src/locales/en/main-frontend.json | 5 +- shared-module/src/locales/fi/cms.json | 3 +- .../src/locales/fi/main-frontend.json | 5 +- shared-module/src/utils/routes.ts | 4 ++ system-tests/src/tests/add-regrading.spec.ts | 14 +++-- system-tests/src/tests/research-form.spec.ts | 8 ++- 33 files changed, 353 insertions(+), 127 deletions(-) delete mode 100644 services/cms/src/blocks/ResearchConsentCheckbox/index.tsx rename services/cms/src/blocks/{ResearchConsentCheckbox/ResearchConsentCheckBoxEditor.tsx => ResearchConsentQuestion/ResearchConsentQuestionEditor.tsx} (79%) rename services/cms/src/blocks/{ResearchConsentCheckbox/ResearchConsentCheckBoxSave.tsx => ResearchConsentQuestion/ResearchConsentQuestionSave.tsx} (100%) create mode 100644 services/cms/src/blocks/ResearchConsentQuestion/index.tsx rename services/course-material/src/components/ContentRenderer/moocfi/{ResearchFormCheckBoxBlock.tsx => ResearchConsentQuestionBlock.tsx} (100%) create mode 100644 services/headless-lms/migrations/20240319134752_increase_max_course_specific_research_form_question_length.down.sql create mode 100644 services/headless-lms/migrations/20240319134752_increase_max_course_specific_research_form_question_length.up.sql rename services/headless-lms/models/.sqlx/{query-7f0db66e0632c16cf13fa9f5596374c60a150cb186968575fc86c49bbbb9b7c8.json => query-63e5fee9909f9038369573568c5d4a4b18c74e3e78795123913f5cd1b01cd048.json} (89%) create mode 100644 services/headless-lms/models/.sqlx/query-8864aab5f3f4bfa9ac2f7cbc430c40b7ade11132d8f6fb378dc7f8409ed46938.json create mode 100644 services/headless-lms/models/.sqlx/query-f53d2cf07fdfb576614f65779cc7ded62bbba437fa27458ea54021a84c805d5f.json diff --git a/services/cms/src/blocks/ResearchConsentCheckbox/index.tsx b/services/cms/src/blocks/ResearchConsentCheckbox/index.tsx deleted file mode 100644 index 1658c5186414..000000000000 --- a/services/cms/src/blocks/ResearchConsentCheckbox/index.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/* eslint-disable i18next/no-literal-string */ -import { BlockConfiguration } from "@wordpress/blocks" -import { formatLtr } from "@wordpress/icons" - -import ResearchConsentCheckBoxEditor from "./ResearchConsentCheckBoxEditor" -import ResearchConsentCheckBoxSave from "./ResearchConsentCheckBoxSave" - -export interface CheckBoxAttributes { - content: string -} - -const CheckBoxConfiguration: BlockConfiguration = { - title: "CheckBox", - description: "Checkbox block, only used for the teacher editable research questions", - category: "text", - attributes: { - content: { - type: "string", - source: "html", - selector: "span", - }, - }, - icon: formatLtr, - edit: ResearchConsentCheckBoxEditor, - save: ResearchConsentCheckBoxSave, -} - -export default CheckBoxConfiguration diff --git a/services/cms/src/blocks/ResearchConsentCheckbox/ResearchConsentCheckBoxEditor.tsx b/services/cms/src/blocks/ResearchConsentQuestion/ResearchConsentQuestionEditor.tsx similarity index 79% rename from services/cms/src/blocks/ResearchConsentCheckbox/ResearchConsentCheckBoxEditor.tsx rename to services/cms/src/blocks/ResearchConsentQuestion/ResearchConsentQuestionEditor.tsx index ad2693baeda1..3865f8d544f9 100644 --- a/services/cms/src/blocks/ResearchConsentCheckbox/ResearchConsentCheckBoxEditor.tsx +++ b/services/cms/src/blocks/ResearchConsentQuestion/ResearchConsentQuestionEditor.tsx @@ -8,10 +8,10 @@ import ErrorBanner from "../../shared-module/components/ErrorBanner" import CheckBox from "../../shared-module/components/InputFields/CheckBox" import BlockPlaceholderWrapper from "../BlockPlaceholderWrapper" -import { CheckBoxAttributes } from "." +import { ResearchConsentQuestionAttributes } from "." const ResearchConsentCheckBoxEditor: React.FC< - React.PropsWithChildren> + React.PropsWithChildren> > = ({ clientId, attributes, isSelected, setAttributes }) => { const { content } = attributes const { t } = useTranslation() @@ -19,25 +19,24 @@ const ResearchConsentCheckBoxEditor: React.FC< return (
    - - + {t("label-question")}: setAttributes({ content: value })} /> diff --git a/services/cms/src/blocks/ResearchConsentCheckbox/ResearchConsentCheckBoxSave.tsx b/services/cms/src/blocks/ResearchConsentQuestion/ResearchConsentQuestionSave.tsx similarity index 100% rename from services/cms/src/blocks/ResearchConsentCheckbox/ResearchConsentCheckBoxSave.tsx rename to services/cms/src/blocks/ResearchConsentQuestion/ResearchConsentQuestionSave.tsx diff --git a/services/cms/src/blocks/ResearchConsentQuestion/index.tsx b/services/cms/src/blocks/ResearchConsentQuestion/index.tsx new file mode 100644 index 000000000000..041d0d67c6a1 --- /dev/null +++ b/services/cms/src/blocks/ResearchConsentQuestion/index.tsx @@ -0,0 +1,29 @@ +/* eslint-disable i18next/no-literal-string */ +import { BlockConfiguration } from "@wordpress/blocks" +import { formatLtr } from "@wordpress/icons" + +import ResearchConsentCheckBoxEditor from "./ResearchConsentQuestionEditor" +import ResearchConsentCheckBoxSave from "./ResearchConsentQuestionSave" + +export interface ResearchConsentQuestionAttributes { + content: string +} + +const ResearchConsentQuestionConfiguration: BlockConfiguration = + { + title: "Research Form Question", + description: "Used to add a new question to the research consent form", + category: "text", + attributes: { + content: { + type: "string", + source: "html", + selector: "span", + }, + }, + icon: formatLtr, + edit: ResearchConsentCheckBoxEditor, + save: ResearchConsentCheckBoxSave, + } + +export default ResearchConsentQuestionConfiguration diff --git a/services/cms/src/blocks/index.tsx b/services/cms/src/blocks/index.tsx index fb31c3407b81..eed851369d4e 100644 --- a/services/cms/src/blocks/index.tsx +++ b/services/cms/src/blocks/index.tsx @@ -28,7 +28,7 @@ import LearningObjectives from "./LearningObjectives" import Map from "./Map" import PagesInChapter from "./PagesInChapter" import PartnersBlock from "./Partners" -import ResearchConsentCheckBox from "./ResearchConsentCheckbox" +import ResearchFormQuestion from "./ResearchConsentQuestion" import TableBox from "./TableBox" import TopLevelPage from "./TopLevelPage" import UnsupportedBlock from "./UnsupportedBlock" @@ -91,7 +91,7 @@ export const blockTypeMapForTopLevelPages = [ ] as Array<[string, BlockConfiguration>]> export const blockTypeMapForResearchConsentForm = [ - ["moocfi/research-consent-checkbox", ResearchConsentCheckBox], + ["moocfi/research-consent-question", ResearchFormQuestion], ] as Array< // eslint-disable-next-line @typescript-eslint/no-explicit-any [string, BlockConfiguration>] diff --git a/services/cms/src/components/editors/ResearchConsentFormEditor.tsx b/services/cms/src/components/editors/ResearchConsentFormEditor.tsx index c62a57c37b7c..8c3461ace72c 100644 --- a/services/cms/src/components/editors/ResearchConsentFormEditor.tsx +++ b/services/cms/src/components/editors/ResearchConsentFormEditor.tsx @@ -43,7 +43,7 @@ const ResearchFormEditor: React.FC> = ({ q await getResearchForm.refetch() } const mutate = useToastMutation( - (form: NewResearchForm) => { - return upsertResearchForm(assertNotNullOrUndefined(courseId), form) + async (form: NewResearchForm) => { + if (!isBlockInstanceArray(form.content)) { + throw new Error("content is not block instance") + } + const researchForm = await upsertResearchForm(assertNotNullOrUndefined(courseId), form) + const questions: NewResearchFormQuestion[] = [] + form.content.forEach((block) => { + if (isMoocfiCheckbox(block)) { + const newResearchQuestion: NewResearchFormQuestion = { + question_id: block.clientId, + course_id: researchForm.course_id, + research_consent_form_id: researchForm.id, + question: block.attributes.content, + } + questions.push(newResearchQuestion) + } + upsertResearchFormQuestions(researchForm.id, questions) + }) }, { notify: true, @@ -91,25 +107,9 @@ const ResearchForms: React.FC> = ({ q }, ) const handleSave = async (form: NewResearchForm): Promise => { - const researchForm = await mutate.mutateAsync(form) - - if (!isBlockInstanceArray(form.content)) { - throw new Error("content is not block instance") - } - form.content.forEach((block) => { - if (isMoocfiCheckbox(block)) { - const newResearchQuestion: NewResearchFormQuestion = { - question_id: block.clientId, - course_id: researchForm.course_id, - research_consent_form_id: researchForm.id, - question: block.attributes.content, - } - upsertResearchFormQuestion(researchForm.id, newResearchQuestion) - } - }) - - await getResearchForm.refetch() - return researchForm + await mutate.mutateAsync(form) + const newData = await getResearchForm.refetch() + return newData.data as ResearchForm } return ( @@ -150,8 +150,10 @@ function isBlockInstanceArray(obj: unknown): obj is BlockInstance[] { return true } -function isMoocfiCheckbox(obj: BlockInstance): obj is BlockInstance { - return obj.name === "moocfi/research-consent-checkbox" +function isMoocfiCheckbox( + obj: BlockInstance, +): obj is BlockInstance { + return obj.name === "moocfi/research-consent-question" } const exported = withErrorBoundary(withSignedIn(dontRenderUntilQueryParametersReady(ResearchForms))) diff --git a/services/cms/src/services/backend/courses.ts b/services/cms/src/services/backend/courses.ts index 7acc4ba90773..7bfc241d4237 100644 --- a/services/cms/src/services/backend/courses.ts +++ b/services/cms/src/services/backend/courses.ts @@ -57,12 +57,12 @@ export const upsertResearchForm = async ( return validateResponse(response, isResearchForm) } -export const upsertResearchFormQuestion = async ( +export const upsertResearchFormQuestions = async ( courseId: string, - data: NewResearchFormQuestion, + data: NewResearchFormQuestion[], ): Promise => { const response = await cmsClient.put( - `/courses/${courseId}/research-consent-form-question`, + `/courses/${courseId}/research-consent-form-questions`, data, { responseType: "json", diff --git a/services/course-material/src/components/ContentRenderer/index.tsx b/services/course-material/src/components/ContentRenderer/index.tsx index 1c45c8f36037..539d90c75a65 100644 --- a/services/course-material/src/components/ContentRenderer/index.tsx +++ b/services/course-material/src/components/ContentRenderer/index.tsx @@ -62,7 +62,7 @@ import LearningObjectiveBlock from "./moocfi/LearningObjectiveBlock" import Map from "./moocfi/Map" import PagesInChapterBlock from "./moocfi/PagesInChapterBlock" import PartnersBlock from "./moocfi/PartnersBlock" -import ResearchFormCheckBoxBlock from "./moocfi/ResearchFormCheckBoxBlock" +import ResearchConsentQuestionBlock from "./moocfi/ResearchConsentQuestionBlock" import TableBox from "./moocfi/TableBox" import TopLevelPageBlock from "./moocfi/TopLevelPagesBlock/index" @@ -154,7 +154,7 @@ export const blockToRendererMap: { [blockName: string]: any } = { "moocfi/map": Map, "moocfi/author": AuthorBlock, "moocfi/author-inner-block": AuthorInnerBlock, - "moocfi/research-consent-checkbox": ResearchFormCheckBoxBlock, + "moocfi/research-consent-question": ResearchConsentQuestionBlock, "moocfi/exercise-custom-view-block": ExerciseCustomViewBlock, } diff --git a/services/course-material/src/components/ContentRenderer/moocfi/ResearchFormCheckBoxBlock.tsx b/services/course-material/src/components/ContentRenderer/moocfi/ResearchConsentQuestionBlock.tsx similarity index 100% rename from services/course-material/src/components/ContentRenderer/moocfi/ResearchFormCheckBoxBlock.tsx rename to services/course-material/src/components/ContentRenderer/moocfi/ResearchConsentQuestionBlock.tsx diff --git a/services/headless-lms/migrations/20240319134752_increase_max_course_specific_research_form_question_length.down.sql b/services/headless-lms/migrations/20240319134752_increase_max_course_specific_research_form_question_length.down.sql new file mode 100644 index 000000000000..0e2d9b4a5e29 --- /dev/null +++ b/services/headless-lms/migrations/20240319134752_increase_max_course_specific_research_form_question_length.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE course_specific_consent_form_questions ALTER COLUMN question TYPE VARCHAR(255); +UPDATE course_specific_research_consent_forms SET content = REPLACE(content::text, 'moocfi/research-consent-question', 'moocfi/research-consent-checkbox')::jsonb; diff --git a/services/headless-lms/migrations/20240319134752_increase_max_course_specific_research_form_question_length.up.sql b/services/headless-lms/migrations/20240319134752_increase_max_course_specific_research_form_question_length.up.sql new file mode 100644 index 000000000000..2b6e7c74fef3 --- /dev/null +++ b/services/headless-lms/migrations/20240319134752_increase_max_course_specific_research_form_question_length.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE course_specific_consent_form_questions ALTER COLUMN question TYPE VARCHAR(8192); +UPDATE course_specific_research_consent_forms SET content = REPLACE(content::text, 'moocfi/research-consent-checkbox', 'moocfi/research-consent-question')::jsonb; + diff --git a/services/headless-lms/models/.sqlx/query-7f0db66e0632c16cf13fa9f5596374c60a150cb186968575fc86c49bbbb9b7c8.json b/services/headless-lms/models/.sqlx/query-63e5fee9909f9038369573568c5d4a4b18c74e3e78795123913f5cd1b01cd048.json similarity index 89% rename from services/headless-lms/models/.sqlx/query-7f0db66e0632c16cf13fa9f5596374c60a150cb186968575fc86c49bbbb9b7c8.json rename to services/headless-lms/models/.sqlx/query-63e5fee9909f9038369573568c5d4a4b18c74e3e78795123913f5cd1b01cd048.json index df74ccd6a36a..6ff6c7ed4204 100644 --- a/services/headless-lms/models/.sqlx/query-7f0db66e0632c16cf13fa9f5596374c60a150cb186968575fc86c49bbbb9b7c8.json +++ b/services/headless-lms/models/.sqlx/query-63e5fee9909f9038369573568c5d4a4b18c74e3e78795123913f5cd1b01cd048.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nINSERT INTO course_specific_consent_form_questions (\n id,\n course_id,\n research_consent_form_id,\n question\n )\nVALUES ($1, $2, $3, $4) ON CONFLICT (id)\nDO UPDATE SET question = $4\nRETURNING *\n", + "query": "\nINSERT INTO course_specific_consent_form_questions (\n id,\n course_id,\n research_consent_form_id,\n question\n )\nVALUES ($1, $2, $3, $4) ON CONFLICT (id)\nDO UPDATE SET question = $4,\ndeleted_at = NULL\nRETURNING *\n", "describe": { "columns": [ { @@ -44,5 +44,5 @@ }, "nullable": [false, false, false, false, false, false, true] }, - "hash": "7f0db66e0632c16cf13fa9f5596374c60a150cb186968575fc86c49bbbb9b7c8" + "hash": "63e5fee9909f9038369573568c5d4a4b18c74e3e78795123913f5cd1b01cd048" } diff --git a/services/headless-lms/models/.sqlx/query-8864aab5f3f4bfa9ac2f7cbc430c40b7ade11132d8f6fb378dc7f8409ed46938.json b/services/headless-lms/models/.sqlx/query-8864aab5f3f4bfa9ac2f7cbc430c40b7ade11132d8f6fb378dc7f8409ed46938.json new file mode 100644 index 000000000000..46030e94fc93 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-8864aab5f3f4bfa9ac2f7cbc430c40b7ade11132d8f6fb378dc7f8409ed46938.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT id\nFROM exercise_task_submissions\nWHERE exercise_slide_submission_id IN (SELECT id\n FROM (SELECT DISTINCT ON (user_id, exercise_id) *\n FROM exercise_slide_submissions\n WHERE exercise_id = $1\n AND deleted_at IS NULL\n ORDER BY user_id, exercise_id, created_at DESC) a )\n AND deleted_at IS NULL\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": ["Uuid"] + }, + "nullable": [false] + }, + "hash": "8864aab5f3f4bfa9ac2f7cbc430c40b7ade11132d8f6fb378dc7f8409ed46938" +} diff --git a/services/headless-lms/models/.sqlx/query-f53d2cf07fdfb576614f65779cc7ded62bbba437fa27458ea54021a84c805d5f.json b/services/headless-lms/models/.sqlx/query-f53d2cf07fdfb576614f65779cc7ded62bbba437fa27458ea54021a84c805d5f.json new file mode 100644 index 000000000000..c05d3f2f18f2 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-f53d2cf07fdfb576614f65779cc7ded62bbba437fa27458ea54021a84c805d5f.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT id\nFROM exercise_task_submissions\nWHERE exercise_slide_submission_id IN (\n SELECT id\n FROM exercise_slide_submissions\n WHERE exercise_id = $1\n)\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": ["Uuid"] + }, + "nullable": [false] + }, + "hash": "f53d2cf07fdfb576614f65779cc7ded62bbba437fa27458ea54021a84c805d5f" +} diff --git a/services/headless-lms/models/src/exercise_task_submissions.rs b/services/headless-lms/models/src/exercise_task_submissions.rs index 34cae736cef2..c96089963666 100644 --- a/services/headless-lms/models/src/exercise_task_submissions.rs +++ b/services/headless-lms/models/src/exercise_task_submissions.rs @@ -550,3 +550,48 @@ AND g.exercise_task_id IN ( .await?; Ok(res) } + +pub async fn get_ids_by_exercise_id( + conn: &mut PgConnection, + exercise_id: Uuid, +) -> ModelResult> { + let res = sqlx::query!( + " +SELECT id +FROM exercise_task_submissions +WHERE exercise_slide_submission_id IN ( + SELECT id + FROM exercise_slide_submissions + WHERE exercise_id = $1 +) +", + &exercise_id + ) + .fetch_all(conn) + .await?; + Ok(res.iter().map(|x| x.id).collect()) +} + +/// Similar to get_ids_by_exercise_id but returns the record with the highest created_at for a user_id +pub async fn get_latest_submission_ids_by_exercise_id( + conn: &mut PgConnection, + exercise_id: Uuid, +) -> ModelResult> { + let res = sqlx::query!( + " +SELECT id +FROM exercise_task_submissions +WHERE exercise_slide_submission_id IN (SELECT id + FROM (SELECT DISTINCT ON (user_id, exercise_id) * + FROM exercise_slide_submissions + WHERE exercise_id = $1 + AND deleted_at IS NULL + ORDER BY user_id, exercise_id, created_at DESC) a ) + AND deleted_at IS NULL +", + &exercise_id + ) + .fetch_all(conn) + .await?; + Ok(res.iter().map(|x| x.id).collect()) +} diff --git a/services/headless-lms/models/src/library/regrading.rs b/services/headless-lms/models/src/library/regrading.rs index 21e033ad100b..1261f332a8a9 100644 --- a/services/headless-lms/models/src/library/regrading.rs +++ b/services/headless-lms/models/src/library/regrading.rs @@ -201,6 +201,31 @@ async fn do_single_regrading( models::exercise_slides::get_exercise_slide(&mut *conn, submission.exercise_slide_id) .await?; let exercise = models::exercises::get_by_id(&mut *conn, exercise_slide.exercise_id).await?; + if exercise.exam_id.is_some() { + info!("Submission being regraded is from an exam, making sure we only give points from the last submission."); + let exercise_slide_submission = models::exercise_slide_submissions::get_by_id( + &mut *conn, + submission.exercise_slide_submission_id, + ) + .await?; + let latest_submission = + models::exercise_slide_submissions::get_users_latest_exercise_slide_submission( + &mut *conn, + submission.exercise_slide_id, + exercise_slide_submission.user_id, + ) + .await?; + if exercise_slide_submission.id != latest_submission.id { + info!("Exam submission being regraded is not the latest submission, refusing to grade it."); + models::exercise_task_gradings::set_grading_progress( + &mut *conn, + regrading_submission.id, + GradingProgress::Failed, + ) + .await?; + continue; + } + } let not_ready_grading = models::exercise_task_gradings::new_grading(&mut *conn, &exercise, &submission).await?; models::exercise_task_regrading_submissions::set_grading_after_regrading( diff --git a/services/headless-lms/models/src/regradings.rs b/services/headless-lms/models/src/regradings.rs index 88c8bd0e12b0..8640d5bf8ac5 100644 --- a/services/headless-lms/models/src/regradings.rs +++ b/services/headless-lms/models/src/regradings.rs @@ -22,7 +22,15 @@ pub struct Regrading { #[cfg_attr(feature = "ts_rs", derive(TS))] pub struct NewRegrading { user_points_update_strategy: UserPointsUpdateStrategy, - exercise_task_submission_ids: Vec, + ids: Vec, + id_type: NewRegradingIdType, +} + +#[derive(Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub enum NewRegradingIdType { + ExerciseTaskSubmissionId, + ExerciseId, } #[derive(Debug, Deserialize, Serialize)] @@ -76,12 +84,36 @@ RETURNING id ) .fetch_one(&mut *tx) .await?; + + let exercise_task_submission_ids = match new_regrading.id_type { + NewRegradingIdType::ExerciseTaskSubmissionId => new_regrading.ids, + NewRegradingIdType::ExerciseId => { + let mut ids = Vec::new(); + for id in new_regrading.ids { + let exercise = crate::exercises::get_by_id(&mut tx, id).await?; + let submission_ids = if exercise.exam_id.is_some() { + // On exams only the last submission is considered. + // That's why we will only regrade those. + exercise_task_submissions::get_latest_submission_ids_by_exercise_id( + &mut tx, + exercise.id, + ) + .await? + } else { + exercise_task_submissions::get_ids_by_exercise_id(&mut tx, exercise.id).await? + }; + ids.extend(submission_ids); + } + ids + } + }; + info!( "Adding {:?} exercise task submissions to the regrading.", - new_regrading.exercise_task_submission_ids.len() + exercise_task_submission_ids.len() ); - for id in new_regrading.exercise_task_submission_ids { - let exercise_task_submission = exercise_task_submissions::get_by_id(&mut tx, id).await?; + for id in &exercise_task_submission_ids { + let exercise_task_submission = exercise_task_submissions::get_by_id(&mut tx, *id).await?; let grading_before_regrading_id = exercise_task_submission .exercise_task_grading_id .ok_or_else(|| { @@ -95,7 +127,7 @@ RETURNING id &mut tx, PKeyPolicy::Generate, res.id, - id, + *id, grading_before_regrading_id, ) .await?; diff --git a/services/headless-lms/models/src/research_forms.rs b/services/headless-lms/models/src/research_forms.rs index d16ddbf043bc..8a2038a5537c 100644 --- a/services/headless-lms/models/src/research_forms.rs +++ b/services/headless-lms/models/src/research_forms.rs @@ -122,11 +122,16 @@ AND deleted_at IS NULL pub async fn upsert_research_form_questions( conn: &mut PgConnection, - question: &NewResearchFormQuestion, -) -> ModelResult { - let form_res = sqlx::query_as!( - ResearchFormQuestion, - " + questions: &[NewResearchFormQuestion], +) -> ModelResult> { + let mut tx = conn.begin().await?; + + let mut inserted_questions = Vec::new(); + + for question in questions { + let form_res = sqlx::query_as!( + ResearchFormQuestion, + " INSERT INTO course_specific_consent_form_questions ( id, course_id, @@ -134,17 +139,24 @@ INSERT INTO course_specific_consent_form_questions ( question ) VALUES ($1, $2, $3, $4) ON CONFLICT (id) -DO UPDATE SET question = $4 +DO UPDATE SET question = $4, +deleted_at = NULL RETURNING * ", - question.question_id, - question.course_id, - question.research_consent_form_id, - question.question - ) - .fetch_one(conn) - .await?; - Ok(form_res) + question.question_id, + question.course_id, + question.research_consent_form_id, + question.question + ) + .fetch_one(&mut *tx) + .await?; + + inserted_questions.push(form_res); + } + + tx.commit().await?; + + Ok(inserted_questions) } pub async fn get_research_form_questions_with_course_id( diff --git a/services/headless-lms/server/src/controllers/cms/courses.rs b/services/headless-lms/server/src/controllers/cms/courses.rs index 11b8f5c6c0c4..10f2a8e1b51d 100644 --- a/services/headless-lms/server/src/controllers/cms/courses.rs +++ b/services/headless-lms/server/src/controllers/cms/courses.rs @@ -181,21 +181,21 @@ async fn get_research_form_with_course_id( } /** -PUT `/api/v0/cms/courses/:course_id/research-consent-form-question` - Upserts questions for the courses research form from Gutenberg research form edit. +PUT `/api/v0/cms/courses/:course_id/research-consent-form-questions` - Upserts questions for the courses research form from Gutenberg research form edit. */ #[instrument(skip(pool, payload))] -async fn upsert_course_research_form_question( - payload: web::Json, +async fn upsert_course_research_form_questions( + payload: web::Json>, pool: web::Data, course_id: web::Path, user: AuthUser, -) -> ControllerResult> { +) -> ControllerResult>> { let mut conn = pool.acquire().await?; let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::GlobalPermissions).await?; - let question = payload; - let res = models::research_forms::upsert_research_form_questions(&mut conn, &question).await?; + + let res = models::research_forms::upsert_research_form_questions(&mut conn, &payload).await?; token.authorized_ok(web::Json(res)) } @@ -251,8 +251,8 @@ pub fn _add_routes(cfg: &mut ServiceConfig) { ) .route("/{course_id}/pages", web::get().to(get_all_pages)) .route( - "/{courseId}/research-consent-form-question", - web::put().to(upsert_course_research_form_question), + "/{courseId}/research-consent-form-questions", + web::put().to(upsert_course_research_form_questions), ) .route( "/{course_id}/research-consent-form", diff --git a/services/headless-lms/server/src/ts_binding_generator.rs b/services/headless-lms/server/src/ts_binding_generator.rs index 4d870f84e2a7..ad2d33373ca9 100644 --- a/services/headless-lms/server/src/ts_binding_generator.rs +++ b/services/headless-lms/server/src/ts_binding_generator.rs @@ -222,6 +222,7 @@ fn models(target: &mut File) { regradings::Regrading, regradings::RegradingInfo, regradings::RegradingSubmissionInfo, + regradings::NewRegradingIdType, repository_exercises::RepositoryExercise, research_forms::NewResearchForm, research_forms::NewResearchFormQuestion, diff --git a/services/main-frontend/src/pages/index.tsx b/services/main-frontend/src/pages/index.tsx index c6ea58170a25..418be088d96d 100644 --- a/services/main-frontend/src/pages/index.tsx +++ b/services/main-frontend/src/pages/index.tsx @@ -10,6 +10,7 @@ import { globalPermissionsRoute, globalStatsRoute, manageExerciseServicesRoute, + regradingsRoute, searchUsersRoute, } from "../shared-module/utils/routes" import withErrorBoundary from "../shared-module/utils/withErrorBoundary" @@ -112,6 +113,20 @@ const FrontPage = () => {
    + +
    + + {t("title-regradings")} + +
    +
    ) } diff --git a/services/main-frontend/src/pages/manage/regradings/[id].tsx b/services/main-frontend/src/pages/manage/regradings/[id].tsx index 94181e5778d9..c10ac3b04df6 100644 --- a/services/main-frontend/src/pages/manage/regradings/[id].tsx +++ b/services/main-frontend/src/pages/manage/regradings/[id].tsx @@ -122,7 +122,7 @@ const ViewRegradingPage: React.FC> = () => { {/* eslint-disable-next-line i18next/no-literal-string */} grade_before_regrading {/* eslint-disable-next-line i18next/no-literal-string */} - grade_after_regrading + regrading_grade diff --git a/services/main-frontend/src/pages/manage/regradings/index.tsx b/services/main-frontend/src/pages/manage/regradings/index.tsx index 47b725a81aeb..ba74e1edbe75 100644 --- a/services/main-frontend/src/pages/manage/regradings/index.tsx +++ b/services/main-frontend/src/pages/manage/regradings/index.tsx @@ -12,7 +12,11 @@ import { fetchAllRegradings, fetchRegradingsCount, } from "../../../services/backend/regradings" -import { NewRegrading, UserPointsUpdateStrategy } from "../../../shared-module/bindings" +import { + NewRegrading, + NewRegradingIdType, + UserPointsUpdateStrategy, +} from "../../../shared-module/bindings" import Button from "../../../shared-module/components/Button" import DebugModal from "../../../shared-module/components/DebugModal" import Dialog from "../../../shared-module/components/Dialog" @@ -29,8 +33,9 @@ import { isUuid } from "../../../shared-module/utils/fetching" import { dateToString } from "../../../shared-module/utils/time" interface Fields { - exerciseTaskSubmissionIds: string + ids: string userPointsUpdateStrategy: UserPointsUpdateStrategy + idType: NewRegradingIdType } const RegradingsPage: React.FC = () => { @@ -55,9 +60,11 @@ const RegradingsPage: React.FC = () => { // eslint-disable-next-line i18next/no-literal-string mode: "onChange", defaultValues: { - exerciseTaskSubmissionIds: "", + ids: "", // eslint-disable-next-line i18next/no-literal-string userPointsUpdateStrategy: "CanAddPointsButCannotRemovePoints", + // eslint-disable-next-line i18next/no-literal-string + idType: "ExerciseTaskSubmissionId", }, }) const newRegradingMutation = useToastMutation( @@ -161,10 +168,29 @@ const RegradingsPage: React.FC = () => { setNewRegradingDialogOpen(false)}>

    {t("button-text-new-regrading")}

    + + { const lines = input.trim().split("\n") if (lines.length === 0) { @@ -175,6 +201,7 @@ const RegradingsPage: React.FC = () => { }, })} /> + { { label: t("option-can-add-points-but-cannot-remove-points"), // eslint-disable-next-line i18next/no-literal-string - value: "CanAddPointsButCannotRemovePoints", + value: "CanAddPointsButCannotRemovePoints" satisfies UserPointsUpdateStrategy, }, { label: t("option-can-add-points-and-can-remove-points"), // eslint-disable-next-line i18next/no-literal-string - value: "CanAddPointsAndCanRemovePoints", + value: "CanAddPointsAndCanRemovePoints" satisfies UserPointsUpdateStrategy, }, ]} {...register("userPointsUpdateStrategy")} /> +