From f0d8de0cbf49ac5134508ecd7529e6ac9b751b8f Mon Sep 17 00:00:00 2001 From: Maija <51128208+Maijjay@users.noreply.github.com> Date: Tue, 19 Mar 2024 13:03:14 +0200 Subject: [PATCH] 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() +})