diff --git a/services/course-material/src/pages/[organizationSlug]/exams/[id].tsx b/services/course-material/src/pages/[organizationSlug]/exams/[id].tsx index 43f35c9d1453..b8054946c55f 100644 --- a/services/course-material/src/pages/[organizationSlug]/exams/[id].tsx +++ b/services/course-material/src/pages/[organizationSlug]/exams/[id].tsx @@ -13,14 +13,16 @@ 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, fetchExam } from "../../../services/backend" +import { Block, endExamTime, enrollInExam, fetchExam } from "@/services/backend" +import Button from "@/shared-module/common/components/Button" import BreakFromCentered from "@/shared-module/common/components/Centering/BreakFromCentered" import ErrorBanner from "@/shared-module/common/components/ErrorBanner" import Spinner from "@/shared-module/common/components/Spinner" import HideTextInSystemTests from "@/shared-module/common/components/system-tests/HideTextInSystemTests" import { withSignedIn } from "@/shared-module/common/contexts/LoginStateContext" -import { baseTheme } from "@/shared-module/common/styles" +import useToastMutation from "@/shared-module/common/hooks/useToastMutation" +import { baseTheme, headingFont } from "@/shared-module/common/styles" import { respondToOrLarger } from "@/shared-module/common/styles/respond" import dontRenderUntilQueryParametersReady, { SimplifiedUrlQuery, @@ -88,6 +90,20 @@ const Exam: React.FC> = ({ query }) => { await handleRefresh() }, [handleRefresh]) + const handleEndExam = () => { + endExamMutation.mutate({ id: examId }) + } + + const endExamMutation = useToastMutation( + ({ id }: { id: string }) => endExamTime(id), + { notify: true, method: "POST" }, + { + onSuccess: async () => { + await handleRefresh() + }, + }, + ) + if (exam.isPending) { return } @@ -229,6 +245,56 @@ const Exam: React.FC> = ({ query }) => { ) } + if (exam.data.enrollment_data.tag === "StudentCanViewGrading") { + return ( + <> + {examInfo} + {exam.data.enrollment_data.gradings.map( + (grade) => + !grade[0].hidden && ( +
+
+ {t("label-name")}: {grade[1].name} +
+
+ {t("points")}: {grade[0].score_given} / {grade[1].score_maximum} +
+
+ {t("label-feedback")}: +
+ {grade[0].justification} +
+
+
+ ), + )} + + ) + } + const endsAt = exam.data.ends_at ? min([ addMinutes(exam.data.enrollment_data.enrollment.started_at, exam.data.time_minutes), @@ -266,6 +332,18 @@ const Exam: React.FC> = ({ query }) => { )} + ) diff --git a/services/course-material/src/services/backend.ts b/services/course-material/src/services/backend.ts index 7d66327ab38f..3dea26047d22 100644 --- a/services/course-material/src/services/backend.ts +++ b/services/course-material/src/services/backend.ts @@ -19,6 +19,7 @@ import { CustomViewExerciseSubmissions, ExamData, ExamEnrollment, + ExerciseSlideSubmissionAndUserExerciseStateList, IsChapterFrontPage, MaterialReference, NewFeedback, @@ -62,6 +63,7 @@ import { isCoursePageWithUserData, isCustomViewExerciseSubmissions, isExamData, + isExerciseSlideSubmissionAndUserExerciseStateList, isIsChapterFrontPage, isMaterialReference, isOEmbedResponse, @@ -655,6 +657,22 @@ export const getAllCourseModuleCompletionsForUserAndCourseInstance = async ( return validateResponse(response, isArray(isCourseModuleCompletion)) } +export const fetchExerciseSubmissions = async ( + exerciseId: string, + page: number, + limit: number, +): Promise => { + const response = await courseMaterialClient.get( + `/exams/${exerciseId}/submissions?page=${page}&limit=${limit}`, + ) + return validateResponse(response, isExerciseSlideSubmissionAndUserExerciseStateList) +} + +export const endExamTime = async (examId: string): Promise => { + const response = await courseMaterialClient.post(`/exams/${examId}/end-exam-time`) + return response.data +} + export const getChatbotCurrentConversationInfo = async ( chatBotConfigurationId: string, ): Promise => { diff --git a/services/headless-lms/Cargo.lock b/services/headless-lms/Cargo.lock index e820119a09e5..5d83822e3654 100644 --- a/services/headless-lms/Cargo.lock +++ b/services/headless-lms/Cargo.lock @@ -1650,7 +1650,7 @@ dependencies = [ "pretty_assertions", "rand 0.8.5", "regex", - "reqwest 0.12.4", + "reqwest 0.12.5", "serde", "serde_json", "sha2", @@ -1694,7 +1694,7 @@ dependencies = [ "rand 0.8.5", "redis", "regex", - "reqwest 0.12.4", + "reqwest 0.12.5", "serde", "serde_json", "tempdir", @@ -1805,9 +1805,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", "http 1.1.0", @@ -1822,7 +1822,7 @@ dependencies = [ "bytes", "futures-util", "http 1.1.0", - "http-body 1.0.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -1870,16 +1870,16 @@ dependencies = [ [[package]] name = "hyper" -version = "1.3.1" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe575dd17d0862a9a33781c8c4696a55c320909004a67a00fb286ba8b1bc496d" +checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05" dependencies = [ "bytes", "futures-channel", "futures-util", "h2 0.4.5", "http 1.1.0", - "http-body 1.0.0", + "http-body 1.0.1", "httparse", "itoa", "pin-project-lite", @@ -1904,19 +1904,20 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.26.0" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c" +checksum = "5ee4be2c948921a1a5320b629c4193916ed787a7f7f293fd3f7f5a6c9de74155" dependencies = [ "futures-util", "http 1.1.0", - "hyper 1.3.1", + "hyper 1.4.1", "hyper-util", - "rustls 0.22.4", + "rustls 0.23.12", "rustls-pki-types", "tokio", - "tokio-rustls 0.25.0", + "tokio-rustls 0.26.0", "tower-service", + "webpki-roots 0.26.3", ] [[package]] @@ -1927,7 +1928,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.3.1", + "hyper 1.4.1", "hyper-util", "native-tls", "tokio", @@ -1937,16 +1938,16 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.5" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b875924a60b96e5d7b9ae7b066540b1dd1cbd90d1828f54c92e02a283351c56" +checksum = "cde7055719c54e36e95e8719f95883f22072a48ede39db7fc17a4e1d5281e9b9" dependencies = [ "bytes", "futures-channel", "futures-util", "http 1.1.0", - "http-body 1.0.0", - "hyper 1.3.1", + "http-body 1.0.1", + "hyper 1.4.1", "pin-project-lite", "socket2 0.5.6", "tokio", @@ -3298,6 +3299,54 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b22d8e7369034b9a7132bc2008cac12f2013c8132b45e0554e6e20e2617f2156" +dependencies = [ + "bytes", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.12", + "socket2 0.5.6", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "quinn-proto" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba92fb39ec7ad06ca2582c0ca834dfeadcaf06ddfc8e635c80aa7e1c05315fdd" +dependencies = [ + "bytes", + "rand 0.8.5", + "ring 0.17.8", + "rustc-hash", + "rustls 0.23.12", + "slab", + "thiserror", + "tinyvec", + "tracing", +] + +[[package]] +name = "quinn-udp" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bffec3605b73c6f1754535084a85229fa8a30f86014e6c81aeec4abb68b0285" +dependencies = [ + "libc", + "once_cell", + "socket2 0.5.6", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.35" @@ -3500,7 +3549,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 0.1.2", "system-configuration", "tokio", "tokio-rustls 0.24.1", @@ -3517,9 +3566,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "566cafdd92868e0939d3fb961bd0dc25fcfaaed179291093b3d43e6b3150ea10" +checksum = "c7d6d2a27d57148378eb5e111173f4276ad26340ecc5c49a4a2152167a2d6a37" dependencies = [ "async-compression", "base64 0.22.1", @@ -3529,10 +3578,10 @@ dependencies = [ "futures-util", "h2 0.4.5", "http 1.1.0", - "http-body 1.0.0", + "http-body 1.0.1", "http-body-util", - "hyper 1.3.1", - "hyper-rustls 0.26.0", + "hyper 1.4.1", + "hyper-rustls 0.27.2", "hyper-tls", "hyper-util", "ipnet", @@ -3543,17 +3592,18 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls 0.22.4", - "rustls-pemfile 2.1.2", + "quinn", + "rustls 0.23.12", + "rustls-pemfile 2.1.3", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.1", "system-configuration", "tokio", "tokio-native-tls", - "tokio-rustls 0.25.0", + "tokio-rustls 0.26.0", "tokio-util", "tower-service", "url", @@ -3561,7 +3611,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 0.26.2", + "webpki-roots 0.26.3", "winreg 0.52.0", ] @@ -3658,6 +3708,12 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustc-hash" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" + [[package]] name = "rustc_version" version = "0.4.0" @@ -3694,14 +3750,14 @@ dependencies = [ [[package]] name = "rustls" -version = "0.22.4" +version = "0.23.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +checksum = "c58f8c84392efc0a126acce10fa59ff7b3d2ac06ab451a33f2741989b806b044" dependencies = [ - "log", + "once_cell", "ring 0.17.8", "rustls-pki-types", - "rustls-webpki 0.102.4", + "rustls-webpki 0.102.6", "subtle", "zeroize", ] @@ -3717,9 +3773,9 @@ dependencies = [ [[package]] name = "rustls-pemfile" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d" +checksum = "196fe16b00e106300d3e45ecfcb764fa292a535d7326a29a5875c579c7417425" dependencies = [ "base64 0.22.1", "rustls-pki-types", @@ -3727,9 +3783,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d" +checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" [[package]] name = "rustls-webpki" @@ -3743,9 +3799,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.102.4" +version = "0.102.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff448f7e92e913c4b7d4c6d8e4540a1724b319b4152b8aef6d4cf8339712b33e" +checksum = "8e6b52d4fda176fd835fdc55a835d4a89b8499cad995885a21149d5ad62f852e" dependencies = [ "ring 0.17.8", "rustls-pki-types", @@ -4373,6 +4429,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "sync_wrapper" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" + [[package]] name = "synstructure" version = "0.13.1" @@ -4612,11 +4674,11 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.25.0" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" dependencies = [ - "rustls 0.22.4", + "rustls 0.23.12", "rustls-pki-types", "tokio", ] @@ -4663,9 +4725,9 @@ dependencies = [ [[package]] name = "tower-layer" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-service" @@ -5102,9 +5164,9 @@ checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" [[package]] name = "webpki-roots" -version = "0.26.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c452ad30530b54a4d8e71952716a212b08efd0f3562baa66c29a618b07da7c3" +checksum = "bd7c23921eeb1713a4e851530e9b9756e4fb0e89978582942612524cf09f01cd" dependencies = [ "rustls-pki-types", ] diff --git a/services/headless-lms/migrations/20240226142222_add_justification_to_teacher_grading_decisions.down.sql b/services/headless-lms/migrations/20240226142222_add_justification_to_teacher_grading_decisions.down.sql new file mode 100644 index 000000000000..f2752e0df15a --- /dev/null +++ b/services/headless-lms/migrations/20240226142222_add_justification_to_teacher_grading_decisions.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE teacher_grading_decisions DROP COLUMN justification; +ALTER TABLE teacher_grading_decisions DROP COLUMN hidden; diff --git a/services/headless-lms/migrations/20240226142222_add_justification_to_teacher_grading_decisions.up.sql b/services/headless-lms/migrations/20240226142222_add_justification_to_teacher_grading_decisions.up.sql new file mode 100644 index 000000000000..4fc68ea16ef8 --- /dev/null +++ b/services/headless-lms/migrations/20240226142222_add_justification_to_teacher_grading_decisions.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE teacher_grading_decisions +ADD COLUMN justification TEXT; +ALTER TABLE teacher_grading_decisions +ADD COLUMN hidden BOOLEAN NOT NULL DEFAULT false; +COMMENT ON COLUMN teacher_grading_decisions.justification IS 'The justification/feedback teachers has given to a submission'; +COMMENT ON COLUMN teacher_grading_decisions.hidden IS 'Whether or not the grading decision is hidden from the student'; diff --git a/services/headless-lms/migrations/20240626125425_add_ended_at_to_exam_enrollments.down.sql b/services/headless-lms/migrations/20240626125425_add_ended_at_to_exam_enrollments.down.sql new file mode 100644 index 000000000000..2bfaf9ed99d2 --- /dev/null +++ b/services/headless-lms/migrations/20240626125425_add_ended_at_to_exam_enrollments.down.sql @@ -0,0 +1 @@ +ALTER TABLE exam_enrollments DROP COLUMN ended_at; diff --git a/services/headless-lms/migrations/20240626125425_add_ended_at_to_exam_enrollments.up.sql b/services/headless-lms/migrations/20240626125425_add_ended_at_to_exam_enrollments.up.sql new file mode 100644 index 000000000000..97e35e7cace0 --- /dev/null +++ b/services/headless-lms/migrations/20240626125425_add_ended_at_to_exam_enrollments.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE exam_enrollments +ADD COLUMN ended_at TIMESTAMP WITH TIME ZONE; +COMMENT ON COLUMN exam_enrollments.ended_at IS 'Timestamp when the exam has ended. If null, the exam time has not ended.'; diff --git a/services/headless-lms/migrations/20240819135526_add-manual-grading-to-exams.down.sql b/services/headless-lms/migrations/20240819135526_add-manual-grading-to-exams.down.sql new file mode 100644 index 000000000000..684a0874f4e7 --- /dev/null +++ b/services/headless-lms/migrations/20240819135526_add-manual-grading-to-exams.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +ALTER TABLE exams DROP COLUMN grade_manually; diff --git a/services/headless-lms/migrations/20240819135526_add-manual-grading-to-exams.up.sql b/services/headless-lms/migrations/20240819135526_add-manual-grading-to-exams.up.sql new file mode 100644 index 000000000000..6fd91aaa1db9 --- /dev/null +++ b/services/headless-lms/migrations/20240819135526_add-manual-grading-to-exams.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE exams +ADD COLUMN grade_manually BOOLEAN NOT NULL DEFAULT FALSE; +COMMENT ON COLUMN exams.grade_manually IS 'True if the exam is graded manually, false if automatically'; diff --git a/services/headless-lms/models/.sqlx/query-3a4ddf6ae73f0f14deaf0b90c4bb571135b2d2615da09ab2db0e142f6e8163ed.json b/services/headless-lms/models/.sqlx/query-3a4ddf6ae73f0f14deaf0b90c4bb571135b2d2615da09ab2db0e142f6e8163ed.json new file mode 100644 index 000000000000..0aae254f8436 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-3a4ddf6ae73f0f14deaf0b90c4bb571135b2d2615da09ab2db0e142f6e8163ed.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE teacher_grading_decisions\n SET hidden = $1\n WHERE id = $2\n RETURNING id,\n user_exercise_state_id,\n created_at,\n updated_at,\n deleted_at,\n score_given,\n teacher_decision AS \"teacher_decision: _\",\n justification,\n hidden;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_exercise_state_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "deleted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "score_given", + "type_info": "Float4" + }, + { + "ordinal": 6, + "name": "teacher_decision: _", + "type_info": { + "Custom": { + "name": "teacher_decision_type", + "kind": { + "Enum": ["full-points", "zero-points", "custom-points", "suspected-plagiarism"] + } + } + } + }, + { + "ordinal": 7, + "name": "justification", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "hidden", + "type_info": "Bool" + } + ], + "parameters": { + "Left": ["Bool", "Uuid"] + }, + "nullable": [false, false, false, false, true, false, false, true, false] + }, + "hash": "3a4ddf6ae73f0f14deaf0b90c4bb571135b2d2615da09ab2db0e142f6e8163ed" +} diff --git a/services/headless-lms/models/.sqlx/query-3b2aab49ae3516682c089dab68514a56b5bb8d183878d29c1a9e88625dd1aa1c.json b/services/headless-lms/models/.sqlx/query-3b2aab49ae3516682c089dab68514a56b5bb8d183878d29c1a9e88625dd1aa1c.json new file mode 100644 index 000000000000..7968abe87c99 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-3b2aab49ae3516682c089dab68514a56b5bb8d183878d29c1a9e88625dd1aa1c.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "\nUPDATE exam_enrollments\nSET ended_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": "3b2aab49ae3516682c089dab68514a56b5bb8d183878d29c1a9e88625dd1aa1c" +} diff --git a/services/headless-lms/models/.sqlx/query-b97c13a86038590133180f6c54b1d4948ebef79cf621af73610ee427652f231a.json b/services/headless-lms/models/.sqlx/query-49184af920fb0531af9c2c1765ffda36177e36cf6c10f89388be1ca34def0c74.json similarity index 80% rename from services/headless-lms/models/.sqlx/query-b97c13a86038590133180f6c54b1d4948ebef79cf621af73610ee427652f231a.json rename to services/headless-lms/models/.sqlx/query-49184af920fb0531af9c2c1765ffda36177e36cf6c10f89388be1ca34def0c74.json index 1e993c60ac9e..c4a6039e0e4b 100644 --- a/services/headless-lms/models/.sqlx/query-b97c13a86038590133180f6c54b1d4948ebef79cf621af73610ee427652f231a.json +++ b/services/headless-lms/models/.sqlx/query-49184af920fb0531af9c2c1765ffda36177e36cf6c10f89388be1ca34def0c74.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nSELECT exams.id,\n exams.name,\n exams.instructions,\n pages.id AS page_id,\n exams.starts_at,\n exams.ends_at,\n exams.time_minutes,\n exams.minimum_points_treshold,\n exams.language\nFROM exams\n JOIN pages ON pages.exam_id = exams.id\nWHERE exams.id = $1\n", + "query": "\nSELECT exams.id,\n exams.name,\n exams.instructions,\n pages.id AS page_id,\n exams.starts_at,\n exams.ends_at,\n exams.time_minutes,\n exams.minimum_points_treshold,\n exams.language,\n exams.grade_manually\nFROM exams\n JOIN pages ON pages.exam_id = exams.id\nWHERE exams.id = $1\n", "describe": { "columns": [ { @@ -47,12 +47,17 @@ "ordinal": 8, "name": "language", "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "grade_manually", + "type_info": "Bool" } ], "parameters": { "Left": ["Uuid"] }, - "nullable": [false, false, false, false, true, true, false, false, true] + "nullable": [false, false, false, false, true, true, false, false, true, false] }, - "hash": "b97c13a86038590133180f6c54b1d4948ebef79cf621af73610ee427652f231a" + "hash": "49184af920fb0531af9c2c1765ffda36177e36cf6c10f89388be1ca34def0c74" } diff --git a/services/headless-lms/models/.sqlx/query-69f09958c20649684f9da3a2308530802e8590348dd0959fea8a10263b830bd4.json b/services/headless-lms/models/.sqlx/query-4ca3f8f39a9cd44d9315a208a4a10dae259aa8938ac279a0a27a54c9129cdf67.json similarity index 68% rename from services/headless-lms/models/.sqlx/query-69f09958c20649684f9da3a2308530802e8590348dd0959fea8a10263b830bd4.json rename to services/headless-lms/models/.sqlx/query-4ca3f8f39a9cd44d9315a208a4a10dae259aa8938ac279a0a27a54c9129cdf67.json index 7c61e309062c..17085869104b 100644 --- a/services/headless-lms/models/.sqlx/query-69f09958c20649684f9da3a2308530802e8590348dd0959fea8a10263b830bd4.json +++ b/services/headless-lms/models/.sqlx/query-4ca3f8f39a9cd44d9315a208a4a10dae259aa8938ac279a0a27a54c9129cdf67.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nSELECT id,\n user_exercise_state_id,\n created_at,\n updated_at,\n deleted_at,\n score_given,\n teacher_decision AS \"teacher_decision: _\"\nFROM teacher_grading_decisions\nWHERE user_exercise_state_id = $1\n AND deleted_at IS NULL\nORDER BY created_at DESC\nLIMIT 1\n ", + "query": "\nSELECT id,\n user_exercise_state_id,\n created_at,\n updated_at,\n deleted_at,\n score_given,\n teacher_decision AS \"teacher_decision: _\",\n justification,\n hidden\nFROM teacher_grading_decisions\nWHERE user_exercise_state_id = $1\n AND deleted_at IS NULL\nORDER BY created_at DESC\nLIMIT 1\n ", "describe": { "columns": [ { @@ -44,12 +44,22 @@ } } } + }, + { + "ordinal": 7, + "name": "justification", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "hidden", + "type_info": "Bool" } ], "parameters": { "Left": ["Uuid"] }, - "nullable": [false, false, false, false, true, false, false] + "nullable": [false, false, false, false, true, false, false, true, false] }, - "hash": "69f09958c20649684f9da3a2308530802e8590348dd0959fea8a10263b830bd4" + "hash": "4ca3f8f39a9cd44d9315a208a4a10dae259aa8938ac279a0a27a54c9129cdf67" } diff --git a/services/headless-lms/models/.sqlx/query-59982ff291dcca51af48183042d2b4d7691b481e5cec4cc70e7df7d5ffd2a458.json b/services/headless-lms/models/.sqlx/query-59982ff291dcca51af48183042d2b4d7691b481e5cec4cc70e7df7d5ffd2a458.json new file mode 100644 index 000000000000..bd62bde35b1f --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-59982ff291dcca51af48183042d2b4d7691b481e5cec4cc70e7df7d5ffd2a458.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 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": [] + }, + "nullable": [false, false, false, true, true, false, false, false] + }, + "hash": "59982ff291dcca51af48183042d2b4d7691b481e5cec4cc70e7df7d5ffd2a458" +} diff --git a/services/headless-lms/models/.sqlx/query-6385936f8a16f551caaa8af174a8d02dc4012432b1636142295a959456020b20.json b/services/headless-lms/models/.sqlx/query-6385936f8a16f551caaa8af174a8d02dc4012432b1636142295a959456020b20.json new file mode 100644 index 000000000000..59a3c515d23f --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-6385936f8a16f551caaa8af174a8d02dc4012432b1636142295a959456020b20.json @@ -0,0 +1,78 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT ON (user_id)\n id,\n created_at,\n updated_at,\n deleted_at,\n exercise_slide_id,\n course_id,\n course_instance_id,\n exam_id,\n exercise_id,\n user_id,\n user_points_update_strategy AS \"user_points_update_strategy: _\"\nFROM exercise_slide_submissions\nWHERE exercise_id = $1\n AND deleted_at IS NULL\nORDER BY user_id, created_at DESC\nLIMIT $2 OFFSET $3\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "deleted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "exercise_slide_id", + "type_info": "Uuid" + }, + { + "ordinal": 5, + "name": "course_id", + "type_info": "Uuid" + }, + { + "ordinal": 6, + "name": "course_instance_id", + "type_info": "Uuid" + }, + { + "ordinal": 7, + "name": "exam_id", + "type_info": "Uuid" + }, + { + "ordinal": 8, + "name": "exercise_id", + "type_info": "Uuid" + }, + { + "ordinal": 9, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 10, + "name": "user_points_update_strategy: _", + "type_info": { + "Custom": { + "name": "user_points_update_strategy", + "kind": { + "Enum": [ + "can-add-points-but-cannot-remove-points", + "can-add-points-and-can-remove-points" + ] + } + } + } + } + ], + "parameters": { + "Left": ["Uuid", "Int8", "Int8"] + }, + "nullable": [false, false, false, true, false, true, true, true, false, false, false] + }, + "hash": "6385936f8a16f551caaa8af174a8d02dc4012432b1636142295a959456020b20" +} diff --git a/services/headless-lms/models/.sqlx/query-ccefb55e5d76ed7eb3cf16329d1bfb4d9559069c775b27c6e5b5e4c87459c828.json b/services/headless-lms/models/.sqlx/query-6e9ff76b570a35f0bebf23885d0f90b94820cf360c77736192fc3fb582f91dd4.json similarity index 68% rename from services/headless-lms/models/.sqlx/query-ccefb55e5d76ed7eb3cf16329d1bfb4d9559069c775b27c6e5b5e4c87459c828.json rename to services/headless-lms/models/.sqlx/query-6e9ff76b570a35f0bebf23885d0f90b94820cf360c77736192fc3fb582f91dd4.json index ef29d95738f8..edaaa9504f0a 100644 --- a/services/headless-lms/models/.sqlx/query-ccefb55e5d76ed7eb3cf16329d1bfb4d9559069c775b27c6e5b5e4c87459c828.json +++ b/services/headless-lms/models/.sqlx/query-6e9ff76b570a35f0bebf23885d0f90b94820cf360c77736192fc3fb582f91dd4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nINSERT INTO teacher_grading_decisions (\n user_exercise_state_id,\n teacher_decision,\n score_given,\n user_id\n )\nVALUES ($1, $2, $3, $4)\nRETURNING id,\n user_exercise_state_id,\n created_at,\n updated_at,\n deleted_at,\n score_given,\n teacher_decision AS \"teacher_decision: _\";\n ", + "query": "\nINSERT INTO teacher_grading_decisions (\n user_exercise_state_id,\n teacher_decision,\n score_given,\n user_id,\n justification,\n hidden\n )\nVALUES ($1, $2, $3, $4, $5, $6)\nRETURNING id,\n user_exercise_state_id,\n created_at,\n updated_at,\n deleted_at,\n score_given,\n teacher_decision AS \"teacher_decision: _\",\n justification,\n hidden;\n ", "describe": { "columns": [ { @@ -44,6 +44,16 @@ } } } + }, + { + "ordinal": 7, + "name": "justification", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "hidden", + "type_info": "Bool" } ], "parameters": { @@ -58,10 +68,12 @@ } }, "Float4", - "Uuid" + "Uuid", + "Text", + "Bool" ] }, - "nullable": [false, false, false, false, true, false, false] + "nullable": [false, false, false, false, true, false, false, true, false] }, - "hash": "ccefb55e5d76ed7eb3cf16329d1bfb4d9559069c775b27c6e5b5e4c87459c828" + "hash": "6e9ff76b570a35f0bebf23885d0f90b94820cf360c77736192fc3fb582f91dd4" } diff --git a/services/headless-lms/models/.sqlx/query-739d7d965ef5dc3c6e897ae8de02bcd85039e2201c4c24bda4dc3c4580bdc0f8.json b/services/headless-lms/models/.sqlx/query-739d7d965ef5dc3c6e897ae8de02bcd85039e2201c4c24bda4dc3c4580bdc0f8.json new file mode 100644 index 000000000000..6a663da04582 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-739d7d965ef5dc3c6e897ae8de02bcd85039e2201c4c24bda4dc3c4580bdc0f8.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "\nUPDATE exam_enrollments\nSET ended_at = $3\nWHERE user_id IN (\n SELECT UNNEST($1::uuid [])\n )\n AND exam_id = $2\n AND deleted_at IS NULL\n", + "describe": { + "columns": [], + "parameters": { + "Left": ["UuidArray", "Uuid", "Timestamptz"] + }, + "nullable": [] + }, + "hash": "739d7d965ef5dc3c6e897ae8de02bcd85039e2201c4c24bda4dc3c4580bdc0f8" +} diff --git a/services/headless-lms/models/.sqlx/query-7a9443be7a0bcd25f02d6f7634e681dabcfc55e52d45ef6d1e107482cfd36423.json b/services/headless-lms/models/.sqlx/query-7a9443be7a0bcd25f02d6f7634e681dabcfc55e52d45ef6d1e107482cfd36423.json new file mode 100644 index 000000000000..27fa2b51546f --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-7a9443be7a0bcd25f02d6f7634e681dabcfc55e52d45ef6d1e107482cfd36423.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT DISTINCT ON (user_exercise_state_id)\n id,\n user_exercise_state_id,\n created_at,\n updated_at,\n deleted_at,\n score_given,\n teacher_decision AS \"teacher_decision: _\",\n justification,\n hidden\nFROM teacher_grading_decisions\nWHERE user_exercise_state_id IN (\n SELECT user_exercise_states.id\n FROM user_exercise_states\n WHERE user_exercise_states.user_id = $1\n AND user_exercise_states.course_instance_id = $2\n AND user_exercise_states.deleted_at IS NULL\n )\n AND deleted_at IS NULL\n ORDER BY user_exercise_state_id, created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_exercise_state_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "deleted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "score_given", + "type_info": "Float4" + }, + { + "ordinal": 6, + "name": "teacher_decision: _", + "type_info": { + "Custom": { + "name": "teacher_decision_type", + "kind": { + "Enum": ["full-points", "zero-points", "custom-points", "suspected-plagiarism"] + } + } + } + }, + { + "ordinal": 7, + "name": "justification", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "hidden", + "type_info": "Bool" + } + ], + "parameters": { + "Left": ["Uuid", "Uuid"] + }, + "nullable": [false, false, false, false, true, false, false, true, false] + }, + "hash": "7a9443be7a0bcd25f02d6f7634e681dabcfc55e52d45ef6d1e107482cfd36423" +} diff --git a/services/headless-lms/models/.sqlx/query-8316fff85e24f85bfc37ad7a7d3fc2277aa742b9a923fc17a4fbf475db2dbc09.json b/services/headless-lms/models/.sqlx/query-8316fff85e24f85bfc37ad7a7d3fc2277aa742b9a923fc17a4fbf475db2dbc09.json new file mode 100644 index 000000000000..33f772f82309 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-8316fff85e24f85bfc37ad7a7d3fc2277aa742b9a923fc17a4fbf475db2dbc09.json @@ -0,0 +1,120 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_exercise_states (user_id, exercise_id, course_instance_id, exam_id)\n SELECT UNNEST($1::uuid []), $2, $3, $4\n RETURNING id,\n user_id,\n exercise_id,\n course_instance_id,\n exam_id,\n created_at,\n updated_at,\n deleted_at,\n score_given,\n grading_progress as \"grading_progress: _\",\n activity_progress as \"activity_progress: _\",\n reviewing_stage AS \"reviewing_stage: _\",\n selected_exercise_slide_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "exercise_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "course_instance_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "exam_id", + "type_info": "Uuid" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "deleted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "score_given", + "type_info": "Float4" + }, + { + "ordinal": 9, + "name": "grading_progress: _", + "type_info": { + "Custom": { + "name": "grading_progress", + "kind": { + "Enum": ["fully-graded", "pending", "pending-manual", "failed", "not-ready"] + } + } + } + }, + { + "ordinal": 10, + "name": "activity_progress: _", + "type_info": { + "Custom": { + "name": "activity_progress", + "kind": { + "Enum": ["initialized", "started", "in-progress", "submitted", "completed"] + } + } + } + }, + { + "ordinal": 11, + "name": "reviewing_stage: _", + "type_info": { + "Custom": { + "name": "reviewing_stage", + "kind": { + "Enum": [ + "not_started", + "peer_review", + "self_review", + "waiting_for_peer_reviews", + "waiting_for_manual_grading", + "reviewed_and_locked" + ] + } + } + } + }, + { + "ordinal": 12, + "name": "selected_exercise_slide_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": ["UuidArray", "Uuid", "Uuid", "Uuid"] + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false, + true, + true, + false, + false, + false, + true + ] + }, + "hash": "8316fff85e24f85bfc37ad7a7d3fc2277aa742b9a923fc17a4fbf475db2dbc09" +} diff --git a/services/headless-lms/models/.sqlx/query-89150494c3404618275e8142dc54455395271f993375ea74cb8e8c5c31dfe48b.json b/services/headless-lms/models/.sqlx/query-89150494c3404618275e8142dc54455395271f993375ea74cb8e8c5c31dfe48b.json new file mode 100644 index 000000000000..fbc3537ae799 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-89150494c3404618275e8142dc54455395271f993375ea74cb8e8c5c31dfe48b.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\nINSERT INTO exams (\n id,\n name,\n instructions,\n starts_at,\n ends_at,\n time_minutes,\n organization_id,\n minimum_points_treshold,\n grade_manually\n )\nVALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\nRETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Jsonb", + "Timestamptz", + "Timestamptz", + "Int4", + "Uuid", + "Int4", + "Bool" + ] + }, + "nullable": [false] + }, + "hash": "89150494c3404618275e8142dc54455395271f993375ea74cb8e8c5c31dfe48b" +} diff --git a/services/headless-lms/models/.sqlx/query-da0f676c3df132fa6eee840e03ac3313356905100e6ad145abba619f8d1df249.json b/services/headless-lms/models/.sqlx/query-8c8daf11abd39defe0f73b3c65ee90881c4b1ab8b413e9d031a797e0b3aa4886.json similarity index 67% rename from services/headless-lms/models/.sqlx/query-da0f676c3df132fa6eee840e03ac3313356905100e6ad145abba619f8d1df249.json rename to services/headless-lms/models/.sqlx/query-8c8daf11abd39defe0f73b3c65ee90881c4b1ab8b413e9d031a797e0b3aa4886.json index 988a3471bd09..cc51c3bcabc2 100644 --- a/services/headless-lms/models/.sqlx/query-da0f676c3df132fa6eee840e03ac3313356905100e6ad145abba619f8d1df249.json +++ b/services/headless-lms/models/.sqlx/query-8c8daf11abd39defe0f73b3c65ee90881c4b1ab8b413e9d031a797e0b3aa4886.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nINSERT INTO exams(\n name,\n organization_id,\n instructions,\n starts_at,\n ends_at,\n language,\n time_minutes,\n minimum_points_treshold\n )\nVALUES ($1, $2, $3, $4, $5, $6, $7, $8)\nRETURNING *\n ", + "query": "\nINSERT INTO exams(\n name,\n organization_id,\n instructions,\n starts_at,\n ends_at,\n language,\n time_minutes,\n minimum_points_treshold,\n grade_manually\n )\nVALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\nRETURNING *\n ", "describe": { "columns": [ { @@ -62,12 +62,41 @@ "ordinal": 11, "name": "minimum_points_treshold", "type_info": "Int4" + }, + { + "ordinal": 12, + "name": "grade_manually", + "type_info": "Bool" } ], "parameters": { - "Left": ["Varchar", "Uuid", "Jsonb", "Timestamptz", "Timestamptz", "Varchar", "Int4", "Int4"] + "Left": [ + "Varchar", + "Uuid", + "Jsonb", + "Timestamptz", + "Timestamptz", + "Varchar", + "Int4", + "Int4", + "Bool" + ] }, - "nullable": [false, false, false, true, false, false, true, true, true, false, false, false] + "nullable": [ + false, + false, + false, + true, + false, + false, + true, + true, + true, + false, + false, + false, + false + ] }, - "hash": "da0f676c3df132fa6eee840e03ac3313356905100e6ad145abba619f8d1df249" + "hash": "8c8daf11abd39defe0f73b3c65ee90881c4b1ab8b413e9d031a797e0b3aa4886" } diff --git a/services/headless-lms/models/.sqlx/query-aa31c9ac2566ab617066e365b22d919fc138a046d8ae095af6194a7fd77fe89d.json b/services/headless-lms/models/.sqlx/query-aa31c9ac2566ab617066e365b22d919fc138a046d8ae095af6194a7fd77fe89d.json deleted file mode 100644 index 57958dd637cf..000000000000 --- a/services/headless-lms/models/.sqlx/query-aa31c9ac2566ab617066e365b22d919fc138a046d8ae095af6194a7fd77fe89d.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\nINSERT INTO exams (\n id,\n name,\n instructions,\n starts_at,\n ends_at,\n time_minutes,\n organization_id,\n minimum_points_treshold\n )\nVALUES ($1, $2, $3, $4, $5, $6, $7, $8)\nRETURNING id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": ["Uuid", "Varchar", "Jsonb", "Timestamptz", "Timestamptz", "Int4", "Uuid", "Int4"] - }, - "nullable": [false] - }, - "hash": "aa31c9ac2566ab617066e365b22d919fc138a046d8ae095af6194a7fd77fe89d" -} diff --git a/services/headless-lms/models/.sqlx/query-bb20c102c5a6481cdf1ad59df6f2037ca952fd12c1adfb2407e59fa1bc896b99.json b/services/headless-lms/models/.sqlx/query-bb20c102c5a6481cdf1ad59df6f2037ca952fd12c1adfb2407e59fa1bc896b99.json new file mode 100644 index 000000000000..6c393104b0da --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-bb20c102c5a6481cdf1ad59df6f2037ca952fd12c1adfb2407e59fa1bc896b99.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT id,\n user_exercise_state_id,\n created_at,\n updated_at,\n deleted_at,\n score_given,\n teacher_decision AS \"teacher_decision: _\",\n justification,\n hidden\nFROM teacher_grading_decisions\nWHERE user_exercise_state_id IN (\n SELECT UNNEST($1::uuid [])\n )\n AND deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_exercise_state_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "deleted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "score_given", + "type_info": "Float4" + }, + { + "ordinal": 6, + "name": "teacher_decision: _", + "type_info": { + "Custom": { + "name": "teacher_decision_type", + "kind": { + "Enum": ["full-points", "zero-points", "custom-points", "suspected-plagiarism"] + } + } + } + }, + { + "ordinal": 7, + "name": "justification", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "hidden", + "type_info": "Bool" + } + ], + "parameters": { + "Left": ["UuidArray"] + }, + "nullable": [false, false, false, false, true, false, false, true, false] + }, + "hash": "bb20c102c5a6481cdf1ad59df6f2037ca952fd12c1adfb2407e59fa1bc896b99" +} diff --git a/services/headless-lms/models/.sqlx/query-527be2e3679b1f889452a1970d9ce568dbd02458be84e5a8006d4917e8b8974d.json b/services/headless-lms/models/.sqlx/query-c81224f381a0cff94174f6605b3330e67256bf88291bf4af47068ab9671f13d9.json similarity index 58% rename from services/headless-lms/models/.sqlx/query-527be2e3679b1f889452a1970d9ce568dbd02458be84e5a8006d4917e8b8974d.json rename to services/headless-lms/models/.sqlx/query-c81224f381a0cff94174f6605b3330e67256bf88291bf4af47068ab9671f13d9.json index 0c2e6b0feae8..5df95803145a 100644 --- a/services/headless-lms/models/.sqlx/query-527be2e3679b1f889452a1970d9ce568dbd02458be84e5a8006d4917e8b8974d.json +++ b/services/headless-lms/models/.sqlx/query-c81224f381a0cff94174f6605b3330e67256bf88291bf4af47068ab9671f13d9.json @@ -1,6 +1,6 @@ { "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", + "query": "\nSELECT user_id,\n exam_id,\n started_at,\n ended_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": [ { @@ -20,11 +20,16 @@ }, { "ordinal": 3, + "name": "ended_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, "name": "is_teacher_testing", "type_info": "Bool" }, { - "ordinal": 4, + "ordinal": 5, "name": "show_exercise_answers", "type_info": "Bool" } @@ -32,7 +37,7 @@ "parameters": { "Left": ["Uuid", "Uuid"] }, - "nullable": [false, false, false, false, false] + "nullable": [false, false, false, true, false, false] }, - "hash": "527be2e3679b1f889452a1970d9ce568dbd02458be84e5a8006d4917e8b8974d" + "hash": "c81224f381a0cff94174f6605b3330e67256bf88291bf4af47068ab9671f13d9" } diff --git a/services/headless-lms/models/.sqlx/query-c8f20039441bd24044428a5c83a5275cd1ba00424f55d4462ae14563e98cd461.json b/services/headless-lms/models/.sqlx/query-c8f20039441bd24044428a5c83a5275cd1ba00424f55d4462ae14563e98cd461.json new file mode 100644 index 000000000000..b7495277684c --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-c8f20039441bd24044428a5c83a5275cd1ba00424f55d4462ae14563e98cd461.json @@ -0,0 +1,120 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT id,\n user_id,\n exercise_id,\n course_instance_id,\n exam_id,\n created_at,\n updated_at,\n deleted_at,\n score_given,\n grading_progress AS \"grading_progress: _\",\n activity_progress AS \"activity_progress: _\",\n reviewing_stage AS \"reviewing_stage: _\",\n selected_exercise_slide_id\nFROM user_exercise_states\nWHERE user_id IN (\n SELECT UNNEST($1::uuid [])\n )\n AND exercise_id = $2\n AND (course_instance_id = $3 OR exam_id = $4)\n AND deleted_at IS NULL\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "exercise_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "course_instance_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "exam_id", + "type_info": "Uuid" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "deleted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "score_given", + "type_info": "Float4" + }, + { + "ordinal": 9, + "name": "grading_progress: _", + "type_info": { + "Custom": { + "name": "grading_progress", + "kind": { + "Enum": ["fully-graded", "pending", "pending-manual", "failed", "not-ready"] + } + } + } + }, + { + "ordinal": 10, + "name": "activity_progress: _", + "type_info": { + "Custom": { + "name": "activity_progress", + "kind": { + "Enum": ["initialized", "started", "in-progress", "submitted", "completed"] + } + } + } + }, + { + "ordinal": 11, + "name": "reviewing_stage: _", + "type_info": { + "Custom": { + "name": "reviewing_stage", + "kind": { + "Enum": [ + "not_started", + "peer_review", + "self_review", + "waiting_for_peer_reviews", + "waiting_for_manual_grading", + "reviewed_and_locked" + ] + } + } + } + }, + { + "ordinal": 12, + "name": "selected_exercise_slide_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": ["UuidArray", "Uuid", "Uuid", "Uuid"] + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false, + true, + true, + false, + false, + false, + true + ] + }, + "hash": "c8f20039441bd24044428a5c83a5275cd1ba00424f55d4462ae14563e98cd461" +} diff --git a/services/headless-lms/models/.sqlx/query-c97a1ae5855479ddfebeb86e52206dcc0905f56c6d95a517ec467cb2c2ec3ed1.json b/services/headless-lms/models/.sqlx/query-c97a1ae5855479ddfebeb86e52206dcc0905f56c6d95a517ec467cb2c2ec3ed1.json new file mode 100644 index 000000000000..ac334a012cf4 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-c97a1ae5855479ddfebeb86e52206dcc0905f56c6d95a517ec467cb2c2ec3ed1.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT user_id,\n exam_id,\n started_at,\n ended_at,\n is_teacher_testing,\n show_exercise_answers\nFROM exam_enrollments\nWHERE user_id IN (\n SELECT UNNEST($1::uuid [])\n )\n AND exam_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": "ended_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "is_teacher_testing", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "show_exercise_answers", + "type_info": "Bool" + } + ], + "parameters": { + "Left": ["UuidArray", "Uuid"] + }, + "nullable": [false, false, false, true, false, false] + }, + "hash": "c97a1ae5855479ddfebeb86e52206dcc0905f56c6d95a517ec467cb2c2ec3ed1" +} diff --git a/services/headless-lms/models/.sqlx/query-d590d215c6ae2e22b063e8feb99f462cbfd24d5246b93fea32c06114afd87ecd.json b/services/headless-lms/models/.sqlx/query-d590d215c6ae2e22b063e8feb99f462cbfd24d5246b93fea32c06114afd87ecd.json new file mode 100644 index 000000000000..dd88e934353d --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-d590d215c6ae2e22b063e8feb99f462cbfd24d5246b93fea32c06114afd87ecd.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT COUNT(*) as count\nFROM exercise_slide_submissions\nWHERE exam_id = $1\nAND deleted_at IS NULL\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": ["Uuid"] + }, + "nullable": [null] + }, + "hash": "d590d215c6ae2e22b063e8feb99f462cbfd24d5246b93fea32c06114afd87ecd" +} diff --git a/services/headless-lms/models/.sqlx/query-dfc4f7eafc9c71273151629b419f251381f3ab9a4313860469f9d21d031efe9e.json b/services/headless-lms/models/.sqlx/query-dfc4f7eafc9c71273151629b419f251381f3ab9a4313860469f9d21d031efe9e.json new file mode 100644 index 000000000000..3d930171b091 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-dfc4f7eafc9c71273151629b419f251381f3ab9a4313860469f9d21d031efe9e.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT user_id,\n exam_id,\n started_at,\n ended_at,\n is_teacher_testing,\n show_exercise_answers\nFROM exam_enrollments\nWHERE\n ended_at IS NULL\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": "ended_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "is_teacher_testing", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "show_exercise_answers", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [false, false, false, true, false, false] + }, + "hash": "dfc4f7eafc9c71273151629b419f251381f3ab9a4313860469f9d21d031efe9e" +} diff --git a/services/headless-lms/models/.sqlx/query-8ea4b63e5770221ab693be4dc6f3b213bdd75361727872439d7fbb65117b4700.json b/services/headless-lms/models/.sqlx/query-e158663ad0a532fb54d2caa609507aa9154e7c3a279aedf78f9f7d778442109e.json similarity index 65% rename from services/headless-lms/models/.sqlx/query-8ea4b63e5770221ab693be4dc6f3b213bdd75361727872439d7fbb65117b4700.json rename to services/headless-lms/models/.sqlx/query-e158663ad0a532fb54d2caa609507aa9154e7c3a279aedf78f9f7d778442109e.json index 7ec14ac87413..7319dd2ee3fc 100644 --- a/services/headless-lms/models/.sqlx/query-8ea4b63e5770221ab693be4dc6f3b213bdd75361727872439d7fbb65117b4700.json +++ b/services/headless-lms/models/.sqlx/query-e158663ad0a532fb54d2caa609507aa9154e7c3a279aedf78f9f7d778442109e.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,\n minimum_points_treshold = $6\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,\n grade_manually = $7\nWHERE id = $1\n", "describe": { "columns": [], "parameters": { - "Left": ["Uuid", "Varchar", "Timestamptz", "Timestamptz", "Int4", "Int4"] + "Left": ["Uuid", "Varchar", "Timestamptz", "Timestamptz", "Int4", "Int4", "Bool"] }, "nullable": [] }, - "hash": "8ea4b63e5770221ab693be4dc6f3b213bdd75361727872439d7fbb65117b4700" + "hash": "e158663ad0a532fb54d2caa609507aa9154e7c3a279aedf78f9f7d778442109e" } diff --git a/services/headless-lms/models/.sqlx/query-b7336ab75d3bb0066457bdff2f9f3506324004fcc44a1754c2eea93ebcdac888.json b/services/headless-lms/models/.sqlx/query-e3f6516013b355780ec4db8178ad361f9014352aa905ae4f61ca6116c54b0feb.json similarity index 57% rename from services/headless-lms/models/.sqlx/query-b7336ab75d3bb0066457bdff2f9f3506324004fcc44a1754c2eea93ebcdac888.json rename to services/headless-lms/models/.sqlx/query-e3f6516013b355780ec4db8178ad361f9014352aa905ae4f61ca6116c54b0feb.json index 40368fa0e077..5b7af8fa83bf 100644 --- a/services/headless-lms/models/.sqlx/query-b7336ab75d3bb0066457bdff2f9f3506324004fcc44a1754c2eea93ebcdac888.json +++ b/services/headless-lms/models/.sqlx/query-e3f6516013b355780ec4db8178ad361f9014352aa905ae4f61ca6116c54b0feb.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nSELECT exams.id\nFROM exams\n LEFT JOIN ended_processed_exams ON (ended_processed_exams.exam_id = exams.id)\nWHERE exams.ends_at <= $1\n AND ended_processed_exams.created_at IS NULL\n AND exams.deleted_at IS NULL\n AND ended_processed_exams.deleted_at IS NULL\n ", + "query": "\nSELECT exams.id\nFROM exams\n LEFT JOIN ended_processed_exams ON (ended_processed_exams.exam_id = exams.id)\nWHERE exams.ends_at <= $1\n AND exams.grade_manually IS false\n AND ended_processed_exams.created_at IS NULL\n AND exams.deleted_at IS NULL\n AND ended_processed_exams.deleted_at IS NULL\n ", "describe": { "columns": [ { @@ -14,5 +14,5 @@ }, "nullable": [false] }, - "hash": "b7336ab75d3bb0066457bdff2f9f3506324004fcc44a1754c2eea93ebcdac888" + "hash": "e3f6516013b355780ec4db8178ad361f9014352aa905ae4f61ca6116c54b0feb" } diff --git a/services/headless-lms/models/.sqlx/query-01bb87c105475e3b694772edf92dee4bad62dabb109aceebb1124e1684b5ab04.json b/services/headless-lms/models/.sqlx/query-f177af8f32f5d6b663c05b2b70898c34e5bbb0c57c465d62f86d8b2596ef8c54.json similarity index 62% rename from services/headless-lms/models/.sqlx/query-01bb87c105475e3b694772edf92dee4bad62dabb109aceebb1124e1684b5ab04.json rename to services/headless-lms/models/.sqlx/query-f177af8f32f5d6b663c05b2b70898c34e5bbb0c57c465d62f86d8b2596ef8c54.json index 0c9757e11107..e2d44597e77d 100644 --- a/services/headless-lms/models/.sqlx/query-01bb87c105475e3b694772edf92dee4bad62dabb109aceebb1124e1684b5ab04.json +++ b/services/headless-lms/models/.sqlx/query-f177af8f32f5d6b663c05b2b70898c34e5bbb0c57c465d62f86d8b2596ef8c54.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nSELECT DISTINCT ON (user_exercise_state_id)\n id,\n user_exercise_state_id,\n created_at,\n updated_at,\n deleted_at,\n score_given,\n teacher_decision AS \"teacher_decision: _\"\nFROM teacher_grading_decisions\nWHERE user_exercise_state_id IN (\n SELECT user_exercise_states.id\n FROM user_exercise_states\n WHERE user_exercise_states.user_id = $1\n AND user_exercise_states.course_instance_id = $2\n AND user_exercise_states.deleted_at IS NULL\n )\n AND deleted_at IS NULL\n ORDER BY user_exercise_state_id, created_at DESC\n ", + "query": "\nSELECT DISTINCT ON (user_exercise_state_id)\n id,\n user_exercise_state_id,\n created_at,\n updated_at,\n deleted_at,\n score_given,\n teacher_decision AS \"teacher_decision: _\",\n justification,\n hidden\nFROM teacher_grading_decisions\nWHERE user_exercise_state_id IN (\n SELECT user_exercise_states.id\n FROM user_exercise_states\n WHERE user_exercise_states.user_id = $1\n AND user_exercise_states.exam_id = $2\n AND user_exercise_states.deleted_at IS NULL\n )\n AND deleted_at IS NULL\n ORDER BY user_exercise_state_id, created_at DESC\n ", "describe": { "columns": [ { @@ -44,12 +44,22 @@ } } } + }, + { + "ordinal": 7, + "name": "justification", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "hidden", + "type_info": "Bool" } ], "parameters": { "Left": ["Uuid", "Uuid"] }, - "nullable": [false, false, false, false, true, false, false] + "nullable": [false, false, false, false, true, false, false, true, false] }, - "hash": "01bb87c105475e3b694772edf92dee4bad62dabb109aceebb1124e1684b5ab04" + "hash": "f177af8f32f5d6b663c05b2b70898c34e5bbb0c57c465d62f86d8b2596ef8c54" } diff --git a/services/headless-lms/models/src/ended_processed_exams.rs b/services/headless-lms/models/src/ended_processed_exams.rs index af00c1bd07d7..e796e069dd3e 100644 --- a/services/headless-lms/models/src/ended_processed_exams.rs +++ b/services/headless-lms/models/src/ended_processed_exams.rs @@ -18,7 +18,7 @@ RETURNING exam_id Ok(res) } -/// Get ids for exams that have ended but haven't yet been added to the table for processed ones. +/// Get ids for automatically graded exams that have ended but haven't yet been added to the table for processed ones. pub async fn get_unprocessed_ended_exams_by_timestamp( conn: &mut PgConnection, timestamp: DateTime, @@ -29,6 +29,7 @@ SELECT exams.id FROM exams LEFT JOIN ended_processed_exams ON (ended_processed_exams.exam_id = exams.id) WHERE exams.ends_at <= $1 + AND exams.grade_manually IS false AND ended_processed_exams.created_at IS NULL AND exams.deleted_at IS NULL AND ended_processed_exams.deleted_at IS NULL diff --git a/services/headless-lms/models/src/exams.rs b/services/headless-lms/models/src/exams.rs index 59361d74c4be..6c160fc3a3a5 100644 --- a/services/headless-lms/models/src/exams.rs +++ b/services/headless-lms/models/src/exams.rs @@ -1,4 +1,5 @@ use chrono::Duration; +use std::collections::HashMap; use crate::{courses::Course, prelude::*}; use headless_lms_utils::document_schema_processor::GutenbergBlock; @@ -17,6 +18,7 @@ pub struct Exam { pub time_minutes: i32, pub minimum_points_treshold: i32, pub language: String, + pub grade_manually: bool, } impl Exam { @@ -63,7 +65,8 @@ SELECT exams.id, exams.ends_at, exams.time_minutes, exams.minimum_points_treshold, - exams.language + exams.language, + exams.grade_manually FROM exams JOIN pages ON pages.exam_id = exams.id WHERE exams.id = $1 @@ -115,6 +118,7 @@ WHERE course_exams.exam_id = $1 courses, minimum_points_treshold: exam.minimum_points_treshold, language: exam.language.unwrap_or("en-US".to_string()), + grade_manually: exam.grade_manually, }) } @@ -136,6 +140,7 @@ pub struct NewExam { pub time_minutes: i32, pub organization_id: Uuid, pub minimum_points_treshold: i32, + pub grade_manually: bool, } #[derive(Debug, Serialize)] @@ -166,9 +171,10 @@ INSERT INTO exams ( ends_at, time_minutes, organization_id, - minimum_points_treshold + minimum_points_treshold, + grade_manually ) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id ", pkey_policy.into_uuid(), @@ -179,6 +185,7 @@ RETURNING id exam.time_minutes, exam.organization_id, exam.minimum_points_treshold, + exam.grade_manually, ) .fetch_one(conn) .await?; @@ -194,7 +201,8 @@ SET name = COALESCE($2, name), starts_at = $3, ends_at = $4, time_minutes = $5, - minimum_points_treshold = $6 + minimum_points_treshold = $6, + grade_manually = $7 WHERE id = $1 ", id, @@ -203,6 +211,7 @@ WHERE id = $1 new_exam.ends_at, new_exam.time_minutes, new_exam.minimum_points_treshold, + new_exam.grade_manually, ) .execute(conn) .await?; @@ -352,12 +361,13 @@ pub async fn verify_exam_submission_can_be_made( Ok(student_has_time && exam_is_ongoing) } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] #[cfg_attr(feature = "ts_rs", derive(TS))] pub struct ExamEnrollment { pub user_id: Uuid, pub exam_id: Uuid, pub started_at: DateTime, + pub ended_at: Option>, pub is_teacher_testing: bool, pub show_exercise_answers: Option, } @@ -373,6 +383,7 @@ pub async fn get_enrollment( SELECT user_id, exam_id, started_at, + ended_at, is_teacher_testing, show_exercise_answers FROM exam_enrollments @@ -388,6 +399,89 @@ WHERE exam_id = $1 Ok(res) } +pub async fn get_exam_enrollments_for_users( + conn: &mut PgConnection, + exam_id: Uuid, + user_ids: &[Uuid], +) -> ModelResult> { + let enrollments = sqlx::query_as!( + ExamEnrollment, + " +SELECT user_id, + exam_id, + started_at, + ended_at, + is_teacher_testing, + show_exercise_answers +FROM exam_enrollments +WHERE user_id IN ( + SELECT UNNEST($1::uuid []) + ) + AND exam_id = $2 + AND deleted_at IS NULL +", + user_ids, + exam_id, + ) + .fetch_all(conn) + .await?; + + let mut res: HashMap = HashMap::new(); + for item in enrollments.into_iter() { + res.insert(item.user_id, item); + } + Ok(res) +} + +pub async fn get_ongoing_exam_enrollments( + conn: &mut PgConnection, +) -> ModelResult> { + let enrollments = sqlx::query_as!( + ExamEnrollment, + " +SELECT user_id, + exam_id, + started_at, + ended_at, + is_teacher_testing, + show_exercise_answers +FROM exam_enrollments +WHERE + ended_at IS NULL + AND deleted_at IS NULL +" + ) + .fetch_all(conn) + .await?; + Ok(enrollments) +} + +pub async fn get_exams(conn: &mut PgConnection) -> ModelResult> { + let exams = sqlx::query_as!( + OrgExam, + " +SELECT id, + name, + instructions, + starts_at, + ends_at, + time_minutes, + organization_id, + minimum_points_treshold +FROM exams +WHERE deleted_at IS NULL +" + ) + .fetch_all(conn) + .await?; + + let mut res: HashMap = HashMap::new(); + for item in exams.into_iter() { + res.insert(item.id, item); + } + Ok(res) +} + pub async fn update_exam_start_time( conn: &mut PgConnection, exam_id: Uuid, @@ -411,6 +505,54 @@ WHERE exam_id = $1 Ok(()) } +pub async fn update_exam_ended_at( + conn: &mut PgConnection, + exam_id: Uuid, + user_id: Uuid, + ended_at: DateTime, +) -> ModelResult<()> { + sqlx::query!( + " +UPDATE exam_enrollments +SET ended_at = $3 +WHERE exam_id = $1 + AND user_id = $2 + AND deleted_at IS NULL +", + exam_id, + user_id, + ended_at + ) + .execute(conn) + .await?; + Ok(()) +} + +pub async fn update_exam_ended_at_for_users_with_exam_id( + conn: &mut PgConnection, + exam_id: Uuid, + user_ids: &[Uuid], + ended_at: DateTime, +) -> ModelResult<()> { + sqlx::query!( + " +UPDATE exam_enrollments +SET ended_at = $3 +WHERE user_id IN ( + SELECT UNNEST($1::uuid []) + ) + AND exam_id = $2 + AND deleted_at IS NULL +", + user_ids, + exam_id, + ended_at + ) + .execute(conn) + .await?; + Ok(()) +} + pub async fn update_show_exercise_answers( conn: &mut PgConnection, exam_id: Uuid, diff --git a/services/headless-lms/models/src/exercise_slide_submissions.rs b/services/headless-lms/models/src/exercise_slide_submissions.rs index 3b8515bf74a6..f90c35a45bf8 100644 --- a/services/headless-lms/models/src/exercise_slide_submissions.rs +++ b/services/headless-lms/models/src/exercise_slide_submissions.rs @@ -7,12 +7,14 @@ use url::Url; use crate::{ courses::Course, + exams::{self, ExamEnrollment}, exercise_service_info::ExerciseServiceInfoApi, exercise_task_gradings::UserPointsUpdateStrategy, exercise_tasks::CourseMaterialExerciseTask, - exercises::{Exercise, GradingProgress}, + exercises::{self, Exercise, GradingProgress}, prelude::*, - user_exercise_states::CourseInstanceOrExamId, + teacher_grading_decisions::{self, TeacherGradingDecision}, + user_exercise_states::{self, CourseInstanceOrExamId, UserExerciseState}, CourseOrExamId, }; @@ -113,6 +115,23 @@ pub struct ExerciseSlideSubmissionInfo { pub exercise_slide_submission: ExerciseSlideSubmission, } +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct ExerciseSlideSubmissionAndUserExerciseState { + pub exercise: Exercise, + pub exercise_slide_submission: ExerciseSlideSubmission, + pub user_exercise_state: UserExerciseState, + pub teacher_grading_decision: Option, + pub user_exam_enrollment: ExamEnrollment, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct ExerciseSlideSubmissionAndUserExerciseStateList { + pub data: Vec, + pub total_pages: u32, +} + pub async fn insert_exercise_slide_submission( conn: &mut PgConnection, exercise_slide_submission: NewExerciseSlideSubmission, @@ -451,6 +470,143 @@ LIMIT $2 OFFSET $3 Ok(submissions) } +pub async fn exercise_slide_submission_count_with_exam_id( + conn: &mut PgConnection, + exam_id: Uuid, +) -> ModelResult { + let count = sqlx::query!( + " +SELECT COUNT(*) as count +FROM exercise_slide_submissions +WHERE exam_id = $1 +AND deleted_at IS NULL +", + exam_id, + ) + .fetch_one(conn) + .await?; + Ok(count.count.unwrap_or(0).try_into()?) +} + +pub async fn exercise_slide_submission_count_with_exercise_id( + conn: &mut PgConnection, + exercise_id: Uuid, +) -> ModelResult { + let count = sqlx::query!( + " +SELECT COUNT(*) as count +FROM exercise_slide_submissions +WHERE exercise_id = $1 +AND deleted_at IS NULL +", + exercise_id, + ) + .fetch_one(conn) + .await?; + Ok(count.count.unwrap_or(0).try_into()?) +} + +pub async fn get_latest_exercise_slide_submissions_and_user_exercise_state_list_with_exercise_id( + conn: &mut PgConnection, + exercise_id: Uuid, + pagination: Pagination, +) -> ModelResult> { + let submissions = sqlx::query_as!( + ExerciseSlideSubmission, + r#" + SELECT DISTINCT ON (user_id) + id, + created_at, + updated_at, + deleted_at, + exercise_slide_id, + course_id, + course_instance_id, + exam_id, + exercise_id, + user_id, + user_points_update_strategy AS "user_points_update_strategy: _" +FROM exercise_slide_submissions +WHERE exercise_id = $1 + AND deleted_at IS NULL +ORDER BY user_id, created_at DESC +LIMIT $2 OFFSET $3 + "#, + exercise_id, + pagination.limit(), + pagination.offset(), + ) + .fetch_all(&mut *conn) + .await?; + + let user_ids = submissions + .iter() + .map(|sub| sub.user_id) + .collect::>(); + + let exercise = exercises::get_by_id(conn, exercise_id).await?; + let exam_id = exercise.exam_id; + + let user_exercise_states_list = + user_exercise_states::get_or_create_user_exercise_state_for_users( + conn, + &user_ids, + exercise_id, + None, + exam_id, + ) + .await?; + + let mut user_exercise_state_id_list: Vec = Vec::new(); + + for (_key, value) in user_exercise_states_list.clone().into_iter() { + user_exercise_state_id_list.push(value.id); + } + + let exercise = exercises::get_by_id(conn, exercise_id).await?; + let exam_id = exercise + .exam_id + .ok_or_else(|| ModelError::new(ModelErrorType::Generic, "No exam id found".into(), None))?; + + let teacher_grading_decisions_list = teacher_grading_decisions::try_to_get_latest_grading_decision_by_user_exercise_state_id_for_users(conn, &user_exercise_state_id_list).await?; + + let user_exam_enrollments_list = + exams::get_exam_enrollments_for_users(conn, exam_id, &user_ids).await?; + + let mut list: Vec = Vec::new(); + for sub in submissions { + let user_exercise_state = user_exercise_states_list.get(&sub.user_id).ok_or_else(|| { + ModelError::new(ModelErrorType::Generic, "No user found".into(), None) + })?; + + let teacher_grading_decision = teacher_grading_decisions_list.get(&user_exercise_state.id); + let user_exam_enrollment = + user_exam_enrollments_list + .get(&sub.user_id) + .ok_or_else(|| { + ModelError::new( + ModelErrorType::Generic, + "No users exam_enrollment found".into(), + None, + ) + })?; + + //Add submissions to the list only if the students exam time has ended + if user_exam_enrollment.ended_at.is_some() { + let data = ExerciseSlideSubmissionAndUserExerciseState { + exercise: exercise.clone(), + exercise_slide_submission: sub, + user_exercise_state: user_exercise_state.clone(), + teacher_grading_decision: teacher_grading_decision.cloned(), + user_exam_enrollment: user_exam_enrollment.clone(), + }; + list.push(data); + } + } + + Ok(list) +} + pub async fn get_course_daily_slide_submission_counts( conn: &mut PgConnection, course: &Course, diff --git a/services/headless-lms/models/src/library/copying.rs b/services/headless-lms/models/src/library/copying.rs index 28e3c84997e5..91779f6c299f 100644 --- a/services/headless-lms/models/src/library/copying.rs +++ b/services/headless-lms/models/src/library/copying.rs @@ -239,9 +239,10 @@ INSERT INTO exams( ends_at, language, time_minutes, - minimum_points_treshold + minimum_points_treshold, + grade_manually ) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING * ", new_exam.name, @@ -252,6 +253,7 @@ RETURNING * parent_exam_fields.language, new_exam.time_minutes, parent_exam_fields.minimum_points_treshold, + new_exam.grade_manually, ) .fetch_one(&mut *tx) .await?; @@ -322,6 +324,7 @@ WHERE id = $2; page_id: get_page_id.page_id, minimum_points_treshold: copied_exam.minimum_points_treshold, language: copied_exam.language.unwrap_or("en-US".to_string()), + grade_manually: copied_exam.grade_manually, }) } diff --git a/services/headless-lms/models/src/pages.rs b/services/headless-lms/models/src/pages.rs index 4b79a80556c2..2da201e335d6 100644 --- a/services/headless-lms/models/src/pages.rs +++ b/services/headless-lms/models/src/pages.rs @@ -3284,6 +3284,7 @@ mod test { time_minutes: 120, organization_id: org, minimum_points_treshold: 24, + grade_manually: false, }, ) .await diff --git a/services/headless-lms/models/src/peer_review_queue_entries.rs b/services/headless-lms/models/src/peer_review_queue_entries.rs index 7048e3d71dc1..36975b04bde7 100644 --- a/services/headless-lms/models/src/peer_review_queue_entries.rs +++ b/services/headless-lms/models/src/peer_review_queue_entries.rs @@ -462,6 +462,8 @@ pub async fn remove_from_queue_and_give_full_points( exercise.score_maximum as f32, // Giver is none because the system made the decision None, + None, + false, ) .await?; user_exercise_state_updater::update_user_exercise_state(&mut tx, user_exercise_state.id) diff --git a/services/headless-lms/models/src/teacher_grading_decisions.rs b/services/headless-lms/models/src/teacher_grading_decisions.rs index c22490b491bb..7524c8156539 100644 --- a/services/headless-lms/models/src/teacher_grading_decisions.rs +++ b/services/headless-lms/models/src/teacher_grading_decisions.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use crate::prelude::*; #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] @@ -10,6 +12,8 @@ pub struct TeacherGradingDecision { pub deleted_at: Option>, pub score_given: f32, pub teacher_decision: TeacherDecisionType, + pub justification: Option, + pub hidden: Option, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, sqlx::Type)] @@ -29,6 +33,8 @@ pub struct NewTeacherGradingDecision { pub exercise_id: Uuid, pub action: TeacherDecisionType, pub manual_points: Option, + pub justification: Option, + pub hidden: bool, } pub async fn add_teacher_grading_decision( @@ -37,6 +43,8 @@ pub async fn add_teacher_grading_decision( action: TeacherDecisionType, score_given: f32, decision_maker_user_id: Option, + justification: Option, + hidden: bool, ) -> ModelResult { let res = sqlx::query_as!( TeacherGradingDecision, @@ -45,21 +53,27 @@ INSERT INTO teacher_grading_decisions ( user_exercise_state_id, teacher_decision, score_given, - user_id + user_id, + justification, + hidden ) -VALUES ($1, $2, $3, $4) +VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, user_exercise_state_id, created_at, updated_at, deleted_at, score_given, - teacher_decision AS "teacher_decision: _"; + teacher_decision AS "teacher_decision: _", + justification, + hidden; "#, user_exercise_state_id, action as TeacherDecisionType, score_given, - decision_maker_user_id + decision_maker_user_id, + justification, + hidden ) .fetch_one(conn) .await?; @@ -79,7 +93,9 @@ SELECT id, updated_at, deleted_at, score_given, - teacher_decision AS "teacher_decision: _" + teacher_decision AS "teacher_decision: _", + justification, + hidden FROM teacher_grading_decisions WHERE user_exercise_state_id = $1 AND deleted_at IS NULL @@ -93,6 +109,41 @@ LIMIT 1 Ok(res) } +pub async fn try_to_get_latest_grading_decision_by_user_exercise_state_id_for_users( + conn: &mut PgConnection, + user_exercise_state_ids: &[Uuid], +) -> ModelResult> { + let decisions = sqlx::query_as!( + TeacherGradingDecision, + r#" +SELECT id, + user_exercise_state_id, + created_at, + updated_at, + deleted_at, + score_given, + teacher_decision AS "teacher_decision: _", + justification, + hidden +FROM teacher_grading_decisions +WHERE user_exercise_state_id IN ( + SELECT UNNEST($1::uuid []) + ) + AND deleted_at IS NULL + "#, + user_exercise_state_ids, + ) + .fetch_all(conn) + .await?; + + let mut res: HashMap = HashMap::new(); + for item in decisions.into_iter() { + res.insert(item.user_exercise_state_id, item); + } + + Ok(res) +} + pub async fn get_all_latest_grading_decisions_by_user_id_and_course_instance_id( conn: &mut PgConnection, user_id: Uuid, @@ -108,7 +159,9 @@ SELECT DISTINCT ON (user_exercise_state_id) updated_at, deleted_at, score_given, - teacher_decision AS "teacher_decision: _" + teacher_decision AS "teacher_decision: _", + justification, + hidden FROM teacher_grading_decisions WHERE user_exercise_state_id IN ( SELECT user_exercise_states.id @@ -127,3 +180,69 @@ WHERE user_exercise_state_id IN ( .await?; Ok(res) } + +pub async fn get_all_latest_grading_decisions_by_user_id_and_exam_id( + conn: &mut PgConnection, + user_id: Uuid, + exam_id: Uuid, +) -> ModelResult> { + let res = sqlx::query_as!( + TeacherGradingDecision, + r#" +SELECT DISTINCT ON (user_exercise_state_id) + id, + user_exercise_state_id, + created_at, + updated_at, + deleted_at, + score_given, + teacher_decision AS "teacher_decision: _", + justification, + hidden +FROM teacher_grading_decisions +WHERE user_exercise_state_id IN ( + SELECT user_exercise_states.id + FROM user_exercise_states + WHERE user_exercise_states.user_id = $1 + AND user_exercise_states.exam_id = $2 + AND user_exercise_states.deleted_at IS NULL + ) + AND deleted_at IS NULL + ORDER BY user_exercise_state_id, created_at DESC + "#, + user_id, + exam_id, + ) + .fetch_all(conn) + .await?; + Ok(res) +} + +pub async fn update_teacher_grading_decision_hidden_field( + conn: &mut PgConnection, + teacher_grading_decision_id: Uuid, + hidden: bool, +) -> ModelResult { + let res = sqlx::query_as!( + TeacherGradingDecision, + r#" + UPDATE teacher_grading_decisions + SET hidden = $1 + WHERE id = $2 + RETURNING id, + user_exercise_state_id, + created_at, + updated_at, + deleted_at, + score_given, + teacher_decision AS "teacher_decision: _", + justification, + hidden; + "#, + hidden, + teacher_grading_decision_id + ) + .fetch_one(conn) + .await?; + Ok(res) +} diff --git a/services/headless-lms/models/src/user_exercise_states.rs b/services/headless-lms/models/src/user_exercise_states.rs index 5f85808ee607..ceb64b455025 100644 --- a/services/headless-lms/models/src/user_exercise_states.rs +++ b/services/headless-lms/models/src/user_exercise_states.rs @@ -537,6 +537,89 @@ WHERE user_id = $1 Ok(res) } +pub async fn get_or_create_user_exercise_state_for_users( + conn: &mut PgConnection, + user_ids: &[Uuid], + exercise_id: Uuid, + course_instance_id: Option, + exam_id: Option, +) -> ModelResult> { + let existing = sqlx::query_as!( + UserExerciseState, + r#" +SELECT id, + user_id, + exercise_id, + course_instance_id, + exam_id, + created_at, + updated_at, + deleted_at, + score_given, + grading_progress AS "grading_progress: _", + activity_progress AS "activity_progress: _", + reviewing_stage AS "reviewing_stage: _", + selected_exercise_slide_id +FROM user_exercise_states +WHERE user_id IN ( + SELECT UNNEST($1::uuid []) + ) + AND exercise_id = $2 + AND (course_instance_id = $3 OR exam_id = $4) + AND deleted_at IS NULL +"#, + user_ids, + exercise_id, + course_instance_id, + exam_id + ) + .fetch_all(&mut *conn) + .await?; + + let mut res = HashMap::with_capacity(user_ids.len()); + for item in existing.into_iter() { + res.insert(item.user_id, item); + } + + let missing_user_ids = user_ids + .iter() + .filter(|user_id| !res.contains_key(user_id)) + .copied() + .collect::>(); + + let created = sqlx::query_as!( + UserExerciseState, + r#" + INSERT INTO user_exercise_states (user_id, exercise_id, course_instance_id, exam_id) + SELECT UNNEST($1::uuid []), $2, $3, $4 + RETURNING id, + user_id, + exercise_id, + course_instance_id, + exam_id, + created_at, + updated_at, + deleted_at, + score_given, + grading_progress as "grading_progress: _", + activity_progress as "activity_progress: _", + reviewing_stage AS "reviewing_stage: _", + selected_exercise_slide_id + "#, + &missing_user_ids, + exercise_id, + course_instance_id, + exam_id + ) + .fetch_all(&mut *conn) + .await?; + + for item in created.into_iter() { + res.insert(item.user_id, item); + } + Ok(res) +} + pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult { let res = sqlx::query_as!( UserExerciseState, 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 67d1ba37a076..6c8d8c15a839 100644 --- a/services/headless-lms/server/src/controllers/course_material/exams.rs +++ b/services/headless-lms/server/src/controllers/course_material/exams.rs @@ -1,7 +1,13 @@ use chrono::{DateTime, Duration, Utc}; +use headless_lms_models::{ + exercises::Exercise, user_exercise_states::CourseInstanceOrExamId, ModelError, ModelErrorType, +}; use models::{ exams::{self, ExamEnrollment}, + exercises, pages::{self, Page}, + teacher_grading_decisions::{self, TeacherGradingDecision}, + user_exercise_states, }; use crate::prelude::*; @@ -111,6 +117,11 @@ pub enum ExamEnrollmentData { NotYetStarted, /// The exam is still open but the student has run out of time. StudentTimeUp, + // Exam is still open but student can view published grading results + StudentCanViewGrading { + gradings: Vec<(TeacherGradingDecision, Exercise)>, + enrollment: ExamEnrollment, + }, } /** @@ -165,12 +176,108 @@ pub async fn fetch_exam_for_user( let enrollment = if let Some(enrollment) = exams::get_enrollment(&mut conn, *exam_id, user.id).await? { + if exam.grade_manually { + // Get the grading results, if the student has any + let teachers_grading_decisions_list = + teacher_grading_decisions::get_all_latest_grading_decisions_by_user_id_and_exam_id( + &mut conn, user.id, *exam_id, + ) + .await?; + let teacher_grading_decisions = teachers_grading_decisions_list.clone(); + + let exam_exercises = exercises::get_exercises_by_exam_id(&mut conn, *exam_id).await?; + + let user_exercise_states = + user_exercise_states::get_all_for_user_and_course_instance_or_exam( + &mut conn, + user.id, + CourseInstanceOrExamId::Exam(*exam_id), + ) + .await?; + + let mut grading_decision_and_exercise_list: Vec<(TeacherGradingDecision, Exercise)> = + Vec::new(); + + // Check if student has any published grading results they can view at the exam page + for grading_decision in teachers_grading_decisions_list.into_iter() { + if let Some(hidden) = grading_decision.hidden { + if !hidden { + // Get the corresponding exercise for the grading result + for grading in teacher_grading_decisions.into_iter() { + let user_exercise_state = user_exercise_states + .iter() + .find(|state| state.id == grading.user_exercise_state_id) + .ok_or_else(|| { + ModelError::new( + ModelErrorType::Generic, + "User_exercise_state not found".into(), + None, + ) + })?; + + let exercise = exam_exercises + .iter() + .find(|exercise| exercise.id == user_exercise_state.exercise_id) + .ok_or_else(|| { + ModelError::new( + ModelErrorType::Generic, + "Exercise not found".into(), + None, + ) + })?; + + grading_decision_and_exercise_list.push((grading, exercise.clone())); + } + + let token = + authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_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::StudentCanViewGrading { + gradings: grading_decision_and_exercise_list, + enrollment, + }, + language: exam.language, + })); + } + } + } + // user has ended the exam + if enrollment.ended_at.is_some() { + let token: domain::authorization::AuthorizationToken = + authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_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::StudentTimeUp, + language: exam.language, + })); + } + } + // user has started the exam if Utc::now() < ends_at - && Utc::now() > enrollment.started_at + Duration::minutes(exam.time_minutes.into()) + && (Utc::now() > enrollment.started_at + Duration::minutes(exam.time_minutes.into()) + || enrollment.ended_at.is_some()) { - // exam is still open but the student's time has expired - let token = authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_id)).await?; + // exam is still open but the student's time has expired or student has ended their exam + if enrollment.ended_at.is_none() { + exams::update_exam_ended_at(&mut conn, *exam_id, user.id, Utc::now()).await?; + } + let token: domain::authorization::AuthorizationToken = + authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_id)).await?; return token.authorized_ok(web::Json(ExamData { id: exam.id, name: exam.name, @@ -290,8 +397,32 @@ pub async fn fetch_exam_for_testing( })) } +#[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/reset-exam-progress +POST /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(())) +} + +/** +POST /api/v0/course-material/exams/:id/reset-exam-progress Used for testing an exam, resets exercise submissions and restarts the exam time. */ @@ -315,27 +446,23 @@ pub async fn reset_exam_progress( 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 +POST /api/v0/course-material/exams/:id/end-exam-time -Used for testing an exam, updates wheter exercise answers are shown. +Used for marking the students exam as ended in the exam enrollment */ #[instrument(skip(pool))] -pub async fn update_show_exercise_answers( +pub async fn end_exam_time( 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?; + + let ended_at = Utc::now(); + models::exams::update_exam_ended_at(&mut conn, *exam_id, user.id, ended_at).await?; + + let token = authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_id)).await?; token.authorized_ok(web::Json(())) } @@ -361,5 +488,6 @@ pub fn _add_routes(cfg: &mut ServiceConfig) { .route( "/testexam/{id}/reset-exam-progress", web::post().to(reset_exam_progress), - ); + ) + .route("/{id}/end-exam-time", web::post().to(end_exam_time)); } 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 c895988005a1..eacf4bfb6755 100644 --- a/services/headless-lms/server/src/controllers/main_frontend/exams.rs +++ b/services/headless-lms/server/src/controllers/main_frontend/exams.rs @@ -1,7 +1,16 @@ +use futures::future; + use chrono::Utc; use models::{ course_exams, exams::{self, Exam, NewExam}, + exercise_slide_submissions::{ + ExerciseSlideSubmissionAndUserExerciseState, + ExerciseSlideSubmissionAndUserExerciseStateList, + }, + exercises::Exercise, + library::user_exercise_state_updater, + teacher_grading_decisions, }; use crate::{ @@ -182,6 +191,131 @@ async fn edit_exam( token.authorized_ok(web::Json(())) } + +/** +GET `/api/v0/main-frontend/exam/:exercise_id/submissions-with-exercise_id` - Returns all the exercise submissions and user exercise states with exercise_id. + */ +#[instrument(skip(pool))] +async fn get_exercise_slide_submissions_and_user_exercise_states_with_exercise_id( + pool: web::Data, + exercise_id: web::Path, + pagination: web::Query, + user: AuthUser, +) -> ControllerResult> { + let mut conn = pool.acquire().await?; + + let token = authorize( + &mut conn, + Act::Teach, + Some(user.id), + Res::Exercise(*exercise_id), + ) + .await?; + + let submission_count = + models::exercise_slide_submissions::exercise_slide_submission_count_with_exercise_id( + &mut conn, + *exercise_id, + ); + let mut conn = pool.acquire().await?; + let submissions = models::exercise_slide_submissions::get_latest_exercise_slide_submissions_and_user_exercise_state_list_with_exercise_id( + &mut conn, + *exercise_id, + *pagination, + ); + let (submission_count, submissions) = future::try_join(submission_count, submissions).await?; + let total_pages = pagination.total_pages(submission_count); + + token.authorized_ok(web::Json(ExerciseSlideSubmissionAndUserExerciseStateList { + data: submissions, + total_pages, + })) +} + +/** +GET `/api/v0/main-frontend/exam/:exam_id/submissions-with-exam-id` - Returns all the exercise submissions and user exercise states with exam_id. + */ +#[instrument(skip(pool))] +async fn get_exercise_slide_submissions_and_user_exercise_states_with_exam_id( + pool: web::Data, + exam_id: web::Path, + pagination: web::Query, + 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 mut submissions_and_user_exercise_states: Vec< + Vec, + > = Vec::new(); + + let exercises = models::exercises::get_exercises_by_exam_id(&mut conn, *exam_id).await?; + + let mut conn = pool.acquire().await?; + for exercise in exercises.iter() { + let submissions = models::exercise_slide_submissions::get_latest_exercise_slide_submissions_and_user_exercise_state_list_with_exercise_id( + &mut conn, + exercise.id, + *pagination, + ).await?; + submissions_and_user_exercise_states.push(submissions) + } + + token.authorized_ok(web::Json(submissions_and_user_exercise_states)) +} + +/** +GET `/api/v0/main-frontend/exam/:exam_id/exam-exercises` - Returns all the exercises with exam_id. + */ +#[instrument(skip(pool))] +async fn get_exercises_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 exercises = models::exercises::get_exercises_by_exam_id(&mut conn, *exam_id).await?; + + token.authorized_ok(web::Json(exercises)) +} + +/** +POST `/api/v0/main-frontend/exam/:exam_id/release-grades` - Publishes grading results of an exam by updating user_exercise_states according to teacher_grading_decisons and changes teacher_grading_decisions hidden field to false. + */ +#[instrument(skip(pool))] +async fn release_grades( + pool: web::Data, + exam_id: web::Path, + user: AuthUser, + payload: web::Json>, +) -> ControllerResult> { + let mut conn = pool.acquire().await?; + let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?; + + let exercise_slide_submissions_and_user_exercise_state_list = payload.0; + + for submission in exercise_slide_submissions_and_user_exercise_state_list.into_iter() { + if submission.teacher_grading_decision.is_some() { + user_exercise_state_updater::update_user_exercise_state( + &mut conn, + submission.user_exercise_state.id, + ) + .await?; + teacher_grading_decisions::update_teacher_grading_decision_hidden_field( + &mut conn, + submission.teacher_grading_decision.unwrap().id, + false, + ) + .await?; + } + } + + token.authorized_ok(web::Json(())) +} + /** Add a route for each controller in this module. @@ -199,5 +333,18 @@ pub fn _add_routes(cfg: &mut ServiceConfig) { web::get().to(export_submissions), ) .route("/{id}/edit-exam", web::post().to(edit_exam)) - .route("/{id}/duplicate", web::post().to(duplicate_exam)); + .route("/{id}/duplicate", web::post().to(duplicate_exam)) + .route( + "/{exercise_id}/submissions-with-exercise-id", + web::get().to(get_exercise_slide_submissions_and_user_exercise_states_with_exercise_id), + ) + .route( + "/{exam_id}/submissions-with-exam-id", + web::get().to(get_exercise_slide_submissions_and_user_exercise_states_with_exam_id), + ) + .route("/{exam_id}/release-grades", web::post().to(release_grades)) + .route( + "/{exam_id}/exam-exercises", + web::get().to(get_exercises_with_exam_id), + ); } diff --git a/services/headless-lms/server/src/controllers/main_frontend/exercise_slide_submissions.rs b/services/headless-lms/server/src/controllers/main_frontend/exercise_slide_submissions.rs index 2e11991f3e30..6dc3ac025201 100644 --- a/services/headless-lms/server/src/controllers/main_frontend/exercise_slide_submissions.rs +++ b/services/headless-lms/server/src/controllers/main_frontend/exercise_slide_submissions.rs @@ -3,7 +3,9 @@ use headless_lms_models::exercise_slide_submissions::ExerciseSlideSubmissionInfo use models::{ exercises::get_exercise_by_id, library::user_exercise_state_updater, - teacher_grading_decisions::{NewTeacherGradingDecision, TeacherDecisionType}, + teacher_grading_decisions::{ + NewTeacherGradingDecision, TeacherDecisionType, TeacherGradingDecision, + }, user_exercise_states::UserExerciseState, }; @@ -49,6 +51,8 @@ async fn update_answer_requiring_attention( let exercise_id = payload.exercise_id; let user_exercise_state_id = payload.user_exercise_state_id; let manual_points = payload.manual_points; + let justification = &payload.justification; + let hidden = payload.hidden; let mut conn = pool.acquire().await?; let token = authorize( &mut conn, @@ -88,6 +92,8 @@ async fn update_answer_requiring_attention( *action, points_given, Some(user.id), + justification.clone(), + hidden, ) .await?; @@ -111,10 +117,113 @@ async fn update_answer_requiring_attention( token.authorized_ok(web::Json(new_user_exercise_state)) } +#[derive(Debug, Deserialize)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct ExerciseStateIds { + exercise_id: Uuid, + user_id: Uuid, +} +/** +GET `/api/v0/main-frontend/exercise-slide-submissions/{exam_id}/{exercise_id}/{user_id}/user-exercise-state-info`- +*/ +#[instrument(skip(pool))] +async fn get_user_exercise_state_info( + exam_id: web::Path, + pool: web::Data, + query_ids: web::Query, + 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 res = models::user_exercise_states::get_or_create_user_exercise_state( + &mut conn, + query_ids.user_id, + query_ids.exercise_id, + None, + Some(*exam_id), + ) + .await?; + token.authorized_ok(web::Json(res)) +} + +/** +PUT `/api/v0/main-frontend/exercise-slide-submissions/add_teacher_grading"` - Adds a new teacher grading decision, without updating user exercise state +*/ +#[instrument(skip(pool))] +async fn add_teacher_grading( + payload: web::Json, + pool: web::Data, + user: AuthUser, +) -> ControllerResult> { + let action = &payload.action; + let exercise_id = payload.exercise_id; + let user_exercise_state_id = payload.user_exercise_state_id; + let manual_points = payload.manual_points; + let justification = &payload.justification; + let mut conn = pool.acquire().await?; + let token = authorize( + &mut conn, + Act::Edit, + Some(user.id), + Res::Exercise(exercise_id), + ) + .await?; + + let points_given; + if *action == TeacherDecisionType::CustomPoints { + let exercise = get_exercise_by_id(&mut conn, exercise_id).await?; + let max_points = exercise.score_maximum as f32; + + points_given = manual_points.unwrap_or(0.0); + + if max_points < points_given { + return Err(ControllerError::new( + ControllerErrorType::BadRequest, + "Cannot give more points than maximum score".to_string(), + None, + )); + } + } else { + return Err(ControllerError::new( + ControllerErrorType::BadRequest, + "Invalid query".to_string(), + None, + )); + } + + info!( + "Teacher took the following action: {:?}. Points given: {:?}.", + &action, points_given + ); + + let mut tx = conn.begin().await?; + + let _res = models::teacher_grading_decisions::add_teacher_grading_decision( + &mut tx, + user_exercise_state_id, + *action, + points_given, + Some(user.id), + justification.clone(), + true, + ) + .await?; + + tx.commit().await?; + + token.authorized_ok(web::Json(_res)) +} + pub fn _add_routes(cfg: &mut ServiceConfig) { cfg.route("/{submission_id}/info", web::get().to(get_submission_info)) .route( "/update-answer-requiring-attention", web::put().to(update_answer_requiring_attention), - ); + ) + .route( + "/{exam_id}/user-exercise-state-info", + web::get().to(get_user_exercise_state_info), + ) + .route("/add-teacher-grading", web::put().to(add_teacher_grading)); } diff --git a/services/headless-lms/server/src/programs/ended_exams_processor.rs b/services/headless-lms/server/src/programs/ended_exams_processor.rs index ee5ec085637e..972776231c7f 100644 --- a/services/headless-lms/server/src/programs/ended_exams_processor.rs +++ b/services/headless-lms/server/src/programs/ended_exams_processor.rs @@ -1,9 +1,13 @@ -use std::{collections::HashSet, env}; +use std::{ + collections::{HashMap, HashSet}, + env, +}; use crate::setup_tracing; -use chrono::Utc; +use chrono::{Duration, Utc}; use dotenv::dotenv; -use headless_lms_models as models; +use headless_lms_models::{self as models, ModelError, ModelErrorType}; +use headless_lms_utils::prelude::BackendError; use sqlx::{Connection, PgConnection, PgPool}; use uuid::Uuid; @@ -15,7 +19,8 @@ pub async fn main() -> anyhow::Result<()> { .unwrap_or_else(|_| "postgres://localhost/headless_lms_dev".to_string()); let db_pool = PgPool::connect(&database_url).await?; let mut conn = db_pool.acquire().await?; - process_ended_exams(&mut conn).await + process_ended_exams(&mut conn).await?; + process_ended_exam_enrollments(&mut conn).await } /// Fetches ended exams that haven't yet been processed and updates completions for them. @@ -66,3 +71,61 @@ async fn process_ended_exam( tx.commit().await?; Ok(()) } + +/// Processes ended exam enrollments +async fn process_ended_exam_enrollments(conn: &mut PgConnection) -> anyhow::Result<()> { + let mut tx = conn.begin().await?; + let mut success = 0; + let mut failed = 0; + + let ongoing_exam_enrollments: Vec = + models::exams::get_ongoing_exam_enrollments(&mut tx).await?; + let exams = models::exams::get_exams(&mut tx).await?; + + let mut needs_ended_at_date: HashMap> = HashMap::new(); + for enrollment in ongoing_exam_enrollments { + let exam = exams.get(&enrollment.exam_id).ok_or_else(|| { + ModelError::new(ModelErrorType::Generic, "Exam not found".into(), None) + })?; + + //Check if users exams should have ended + if Utc::now() > enrollment.started_at + Duration::minutes(exam.time_minutes.into()) { + needs_ended_at_date + .entry(exam.id) + .or_default() + .push(enrollment.user_id); + } + } + + for entry in needs_ended_at_date.into_iter() { + let exam_id = entry.0; + let user_ids = entry.1; + match models::exams::update_exam_ended_at_for_users_with_exam_id( + &mut tx, + exam_id, + &user_ids, + Utc::now(), + ) + .await + { + Ok(_) => success += user_ids.len(), + Err(err) => { + failed += user_ids.len(); + tracing::error!( + "Failed to end exam enrolments for exam {}: {:#?}", + exam_id, + err + ); + } + } + } + + tracing::info!( + "Exam enrollments processed. Succeeded: {}, failed: {}.", + success, + failed + ); + tx.commit().await?; + + Ok(()) +} diff --git a/services/headless-lms/server/src/programs/seed/seed_helpers.rs b/services/headless-lms/server/src/programs/seed/seed_helpers.rs index 4d86ed033974..8de94eeef5a1 100644 --- a/services/headless-lms/server/src/programs/seed/seed_helpers.rs +++ b/services/headless-lms/server/src/programs/seed/seed_helpers.rs @@ -472,6 +472,7 @@ pub async fn create_exam( minimum_points_treshold: i32, base_url: String, jwt_key: Arc, + grade_manually: bool, ) -> Result { let new_exam_id = exams::insert( conn, @@ -483,6 +484,7 @@ pub async fn create_exam( time_minutes, organization_id, minimum_points_treshold, + grade_manually, }, ) .await?; diff --git a/services/headless-lms/server/src/programs/seed/seed_organizations/uh_cs.rs b/services/headless-lms/server/src/programs/seed/seed_organizations/uh_cs.rs index 74153f87956a..1802f4b3597b 100644 --- a/services/headless-lms/server/src/programs/seed/seed_organizations/uh_cs.rs +++ b/services/headless-lms/server/src/programs/seed/seed_organizations/uh_cs.rs @@ -366,6 +366,7 @@ pub async fn seed_organization_uh_cs( 0, base_url.clone(), Arc::clone(&jwt_key), + false, ) .await?; create_exam( @@ -381,6 +382,7 @@ pub async fn seed_organization_uh_cs( 0, base_url.clone(), Arc::clone(&jwt_key), + false, ) .await?; create_exam( @@ -396,6 +398,24 @@ pub async fn seed_organization_uh_cs( 0, base_url.clone(), Arc::clone(&jwt_key), + false, + ) + .await?; + + create_exam( + &mut conn, + "Exam for manual grading".to_string(), + Some(Utc::now()), + Some(Utc::now() + Duration::minutes(120)), + 1, + uh_cs_organization_id, + cs_intro, + Uuid::parse_str("fee8bb0c-8629-477c-86eb-1785005143ae")?, + teacher_user_id, + 0, + base_url.clone(), + Arc::clone(&jwt_key), + true, ) .await?; create_exam( @@ -411,6 +431,7 @@ pub async fn seed_organization_uh_cs( 0, base_url.clone(), Arc::clone(&jwt_key), + false, ) .await?; create_exam( @@ -426,6 +447,7 @@ pub async fn seed_organization_uh_cs( 0, base_url.clone(), Arc::clone(&jwt_key), + false, ) .await?; let automatic_course_exam = create_exam( @@ -441,6 +463,7 @@ pub async fn seed_organization_uh_cs( 0, base_url.clone(), Arc::clone(&jwt_key), + false, ) .await?; course_exams::upsert( diff --git a/services/headless-lms/server/src/ts_binding_generator.rs b/services/headless-lms/server/src/ts_binding_generator.rs index 40c02bb3ad96..2974c5b60908 100644 --- a/services/headless-lms/server/src/ts_binding_generator.rs +++ b/services/headless-lms/server/src/ts_binding_generator.rs @@ -111,6 +111,8 @@ fn models(target: &mut File) { exercise_slide_submissions::ExerciseSlideSubmissionCountByExercise, exercise_slide_submissions::ExerciseSlideSubmissionCountByWeekAndHour, exercise_slide_submissions::ExerciseSlideSubmissionInfo, + exercise_slide_submissions::ExerciseSlideSubmissionAndUserExerciseState, + exercise_slide_submissions::ExerciseSlideSubmissionAndUserExerciseStateList, exercise_task_submissions::PeerOrSelfReviewsReceived, exercise_slides::CourseMaterialExerciseSlide, exercise_slides::ExerciseSlide, diff --git a/services/main-frontend/src/components/forms/EditExamForm.tsx b/services/main-frontend/src/components/forms/EditExamForm.tsx index c6b26db5fd6f..84361459db4a 100644 --- a/services/main-frontend/src/components/forms/EditExamForm.tsx +++ b/services/main-frontend/src/components/forms/EditExamForm.tsx @@ -24,6 +24,7 @@ interface EditExamFields { parentId: string | null automaticCompletionEnabled: boolean minimumPointsTreshold: number + manualGradingEnabled: boolean } const EditExamForm: React.FC> = ({ @@ -51,6 +52,7 @@ const EditExamForm: React.FC> = ({ ? Number(data.minimumPointsTreshold) : 0, organization_id: organizationId, + grade_manually: data.manualGradingEnabled, }) }) @@ -89,6 +91,11 @@ const EditExamForm: React.FC> = ({ label={t("label-time-minutes")} {...register("timeMinutes", { required: t("required-field") })} /> + > = ({ + submissionId, +}) => { + const { t } = useTranslation() + + const { register, handleSubmit } = useForm() + const [nextSubmissionId, setNextSubmissionId] = useState("") + const paginationInfo = usePaginationInfo() + + const getSubmissionInfo = useQuery({ + queryKey: [`submission-${submissionId}`], + queryFn: () => fetchSubmissionInfo(submissionId), + }) + + const examId = getSubmissionInfo.data?.exercise.exam_id ?? "" + const exerciseId = getSubmissionInfo.data?.exercise.id ?? "" + const userId = getSubmissionInfo.data?.exercise_slide_submission.user_id ?? "" + + const getCurrentGradingInfo = useQuery({ + queryKey: [`exercise-slide-submissions-${examId}-user-exercise-state-info`, exerciseId, userId], + queryFn: () => fetchGradingInfo(examId, exerciseId, userId), + enabled: getSubmissionInfo.isFetched, + }) + + const getSubmissions = useExamSubmissionsInfo( + exerciseId, + paginationInfo.page, + paginationInfo.limit, + ) + + // Current submission for reviewing + const currentSubmission = getSubmissions.data?.data.filter((submission) => { + return submission.exercise_slide_submission.id === submissionId + })[0] + + // Get next submission + if (getSubmissions.isSuccess && nextSubmissionId === "") { + const currentSubmissionIndex = getSubmissions.data?.data.findIndex( + (id) => id.exercise_slide_submission.id === submissionId, + ) + if (currentSubmissionIndex + 1 !== getSubmissions.data.data.length) { + setNextSubmissionId( + getSubmissions.data.data.at(currentSubmissionIndex + 1)?.exercise_slide_submission.id ?? "", + ) + } else { + // eslint-disable-next-line i18next/no-literal-string + setNextSubmissionId("lastAnswer") + } + } + + const onSubmitWrapper = handleSubmit((data) => { + const newGrading: NewTeacherGradingDecision = { + user_exercise_state_id: getCurrentGradingInfo.data?.id ?? "", + justification: data.justification, + hidden: true, + exercise_id: getSubmissionInfo.data?.exercise.id ?? "", + // eslint-disable-next-line i18next/no-literal-string + action: "CustomPoints", + manual_points: Number(data.manual_points), + } + submitMutation.mutate(newGrading) + }) + + const submitMutation = useToastMutation( + (update: NewTeacherGradingDecision) => { + return addTeacherGrading(update) + }, + { + notify: true, + method: "PUT", + // eslint-disable-next-line i18next/no-literal-string + errorMessage: "Cannot give more points than maximum points", + }, + { + onSuccess: () => { + getCurrentGradingInfo.refetch() + }, + }, + ) + + return ( +
+
+
+

+ {t("label-justification")} / {t("label-feedback")} +

+ +
+ +
+

+ {t("label-points")} / {getSubmissionInfo.data?.exercise.score_maximum} +

+ +
+
+
+
+ +
+
+ +
+
+
+ ) +} + +export default dontRenderUntilQueryParametersReady(GradeExamAnswerForm) diff --git a/services/main-frontend/src/components/forms/NewExamForm.tsx b/services/main-frontend/src/components/forms/NewExamForm.tsx index 0f25efb82450..31f6b0cb13e7 100644 --- a/services/main-frontend/src/components/forms/NewExamForm.tsx +++ b/services/main-frontend/src/components/forms/NewExamForm.tsx @@ -27,6 +27,7 @@ interface NewExamFields { parentId: string | null automaticCompletionEnabled: boolean minimumPointsTreshold: number + manualGradingEnabled: boolean } const NewExamForm: React.FC> = ({ @@ -63,6 +64,7 @@ const NewExamForm: React.FC> = ({ minimum_points_treshold: data.automaticCompletionEnabled ? Number(data.minimumPointsTreshold) : 0, + grade_manually: data.manualGradingEnabled, }) }) @@ -77,6 +79,7 @@ const NewExamForm: React.FC> = ({ minimum_points_treshold: data.automaticCompletionEnabled ? Number(data.minimumPointsTreshold) : 0, + grade_manually: data.manualGradingEnabled, } const examId = String(parentId) onDuplicateExam(examId, newExam) @@ -126,10 +129,12 @@ const NewExamForm: React.FC> = ({ label={t("label-time-minutes")} {...register("timeMinutes", { required: t("required-field") })} /> + + {automaticEnabled && ( = ({ exercise_id, action: action, manual_points: manual_points, + justification: null, + hidden: false, }) } diff --git a/services/main-frontend/src/hooks/useExamSubmissionsInfo.tsx b/services/main-frontend/src/hooks/useExamSubmissionsInfo.tsx new file mode 100644 index 000000000000..a059787beb2c --- /dev/null +++ b/services/main-frontend/src/hooks/useExamSubmissionsInfo.tsx @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query" + +import { fetchExerciseSubmissionsAndUserExerciseStatesWithExerciseId } from "../services/backend/exams" + +const useExamSubmissionsInfo = (exercise_id: string, pageNumber: number, pageLimit: number) => { + return useQuery({ + queryKey: [`/exams/${exercise_id}/submissions`, exercise_id, pageNumber, pageLimit], + queryFn: () => + fetchExerciseSubmissionsAndUserExerciseStatesWithExerciseId( + exercise_id, + pageNumber, + pageLimit, + ), + }) +} + +export default useExamSubmissionsInfo 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 7af0b1f1611b..ab03015933c9 100644 --- a/services/main-frontend/src/pages/manage/exams/[id]/index.tsx +++ b/services/main-frontend/src/pages/manage/exams/[id]/index.tsx @@ -116,6 +116,9 @@ const Organization: React.FC> = ( {t("link-export-submissions")} +
  • + {t("grading")} +
  • {t("link-test-exam")} diff --git a/services/main-frontend/src/pages/manage/exams/[id]/questions.tsx b/services/main-frontend/src/pages/manage/exams/[id]/questions.tsx new file mode 100644 index 000000000000..de2905288ba5 --- /dev/null +++ b/services/main-frontend/src/pages/manage/exams/[id]/questions.tsx @@ -0,0 +1,355 @@ +import { css } from "@emotion/css" +import { useQuery } from "@tanstack/react-query" +import React, { useMemo } from "react" +import { useTranslation } from "react-i18next" + +import { + fetchExam, + fetchExerciseSubmissionsAndUserExerciseStatesWithExamId, + fetchExercisesWithExamId, + releaseGrades, +} from "../../../../services/backend/exams" + +import { ExerciseSlideSubmissionAndUserExerciseState } from "@/shared-module/common/bindings" +import Breadcrumbs, { BreadcrumbPiece } from "@/shared-module/common/components/Breadcrumbs" +import Button from "@/shared-module/common/components/Button" +import BreakFromCentered from "@/shared-module/common/components/Centering/BreakFromCentered" +import ErrorBanner from "@/shared-module/common/components/ErrorBanner" +import GenericInfobox from "@/shared-module/common/components/GenericInfobox" +import InfoComponent from "@/shared-module/common/components/InfoComponent" +import Spinner from "@/shared-module/common/components/Spinner" +import { PageMarginOffset } from "@/shared-module/common/components/layout/PageMarginOffset" +import { withSignedIn } from "@/shared-module/common/contexts/LoginStateContext" +import useToastMutation from "@/shared-module/common/hooks/useToastMutation" +import { baseTheme, fontWeights, headingFont } from "@/shared-module/common/styles" +import { MARGIN_BETWEEN_NAVBAR_AND_CONTENT } from "@/shared-module/common/utils/constants" +import dontRenderUntilQueryParametersReady, { + SimplifiedUrlQuery, +} from "@/shared-module/common/utils/dontRenderUntilQueryParametersReady" +import withErrorBoundary from "@/shared-module/common/utils/withErrorBoundary" + +interface SubmissionPageProps { + query: SimplifiedUrlQuery<"id"> +} + +const GradingPage: React.FC> = ({ query }) => { + const { t } = useTranslation() + + const getExam = useQuery({ + queryKey: [`/exams/${query.id}/`, query.id], + queryFn: () => fetchExam(query.id), + }) + + const getExercises = useQuery({ + queryKey: [`/exams/${query.id}/exam-exercises`, query.id], + queryFn: () => fetchExercisesWithExamId(query.id), + }) + + const sorted = getExercises.data?.sort((a, b) => + a.order_number > b.order_number ? 1 : b.order_number > a.order_number ? -1 : 0, + ) + + const getAllSubmissions = useQuery({ + queryKey: [`/exams/${query.id}/submissions-with-exam-id`, query.id], + queryFn: () => fetchExerciseSubmissionsAndUserExerciseStatesWithExamId(query.id), + staleTime: 1, + }) + + const allSubmissionsList = getAllSubmissions.data?.reduce( + (acc, submissionlist) => ({ + ...acc, + [submissionlist.at(0)?.exercise.id ?? ""]: submissionlist, + }), + {} as Record, + ) + + const handlePublishGradingResults = () => { + getAllSubmissions.refetch() + generateSubs() + publishMutation.mutate({ id: query.id, submissions: generateSubs() }) + } + + const publishMutation = useToastMutation( + ({ + id, + submissions, + }: { + id: string + submissions: ExerciseSlideSubmissionAndUserExerciseState[] + }) => releaseGrades(id, submissions), + { notify: true, method: "PUT" }, + { + onSuccess: () => { + getAllSubmissions.refetch(), checkPublishable() + }, + }, + ) + + const generateSubs = () => { + const submissionList: ExerciseSlideSubmissionAndUserExerciseState[] = [] + getAllSubmissions.data?.map((exerciseSubmissionList) => { + exerciseSubmissionList.map((submission) => { + submissionList.push(submission) + }) + }) + return submissionList + } + + const checkPublishable = () => { + let unpublishedCount = 0 + getAllSubmissions.data?.map((s) => + s.map((sub) => { + if (sub.teacher_grading_decision?.hidden === true) { + unpublishedCount = unpublishedCount + 1 + } + }), + ) + return unpublishedCount + } + + const gradedCheck = (id: string) => { + if (!getExam.data?.grade_manually) { + return ( +
    + {t("label-graded-automatically")} +
    + ) + } + + const submissions = allSubmissionsList?.[id] + if (submissions) { + const countGraded = submissions.filter((sub) => sub.teacher_grading_decision).length + if (submissions.length === countGraded) { + return ( +
    + {t("status-graded")} +
    + ) + } else if (countGraded === 0) { + return ( +
    + {t("status-ungraded")} +
    + ) + } else if (submissions.length > countGraded) { + return ( +
    + {t("status-in-progress")} +
    + ) + } + } else { + return " " + } + } + + const totalAnswered = (id: string) => { + const submissions = allSubmissionsList?.[id] + if (submissions) { + return submissions.length + } else { + return "0" + } + } + + const totalGraded = (id: string) => { + if (!getExam.data?.grade_manually) { + return
    {totalAnswered(id)}
    + } + const submissions = allSubmissionsList?.[id] + if (submissions) { + return submissions.filter((sub) => sub.teacher_grading_decision).length + } else { + return "0" + } + } + + const totalPublished = (id: string) => { + if (!getExam.data?.grade_manually) { + return
    0
    + } + const submissions = allSubmissionsList?.[id] + let count = 0 + if (submissions) { + submissions.map((sub) => { + if (sub.teacher_grading_decision?.hidden === true) { + count = count + 1 + } + }) + } + return count + } + + const pieces: BreadcrumbPiece[] = useMemo(() => { + const pieces = [ + // eslint-disable-next-line i18next/no-literal-string + { text: t("link-manage"), url: `/manage/exams/${query.id}` }, + // eslint-disable-next-line i18next/no-literal-string + { text: t("questions"), url: `/manage/exams/${query.id}/questions` }, + ] + return pieces + }, [query.id, t]) + + return ( +
    + + + + + + {getExercises.isError && } + {getExercises.isPending && } + {getExercises.isSuccess && ( + <> +

    + {getExam.data?.name} +

    + + + + + + + + + + + + + + {sorted?.map((exercise) => ( + + + + + + + + + + ))} + +
    {t("label-grade")}{t("question")}{t("status")}{t("number-of-answered")}{t("number-of-graded")}{t("number-of-unpublished-gradings")}{t("points")}
    + {getExam.data?.grade_manually ? ( + + ) : ( + + )} + {t("question-n", { n: exercise.order_number + 1 })}{exercise.id && gradedCheck(exercise.id)}{exercise.id && totalAnswered(exercise.id)}{exercise.id && totalGraded(exercise.id)}{exercise.id && totalPublished(exercise.id)}{exercise.score_maximum}
    + {getExam.data?.grade_manually && ( +
    + {checkPublishable() != 0 && ( + + {t("unpublishable-grading-results", { amount: checkPublishable() })} + + )} +
    + )} +
    + + +
    + + )} +
    + ) +} + +export default withErrorBoundary(withSignedIn(dontRenderUntilQueryParametersReady(GradingPage))) diff --git a/services/main-frontend/src/pages/manage/exercises/[id]/exam-submissions.tsx b/services/main-frontend/src/pages/manage/exercises/[id]/exam-submissions.tsx new file mode 100644 index 000000000000..23159afc0c8d --- /dev/null +++ b/services/main-frontend/src/pages/manage/exercises/[id]/exam-submissions.tsx @@ -0,0 +1,240 @@ +import { css } from "@emotion/css" +import { useQuery } from "@tanstack/react-query" +import { parseISO } from "date-fns" +import React, { useMemo } from "react" +import { useTranslation } from "react-i18next" + +import useExamSubmissionsInfo from "../../../../hooks/useExamSubmissionsInfo" + +import { fetchExam } from "@/services/backend/exams" +import Breadcrumbs, { BreadcrumbPiece } from "@/shared-module/common/components/Breadcrumbs" +import Button from "@/shared-module/common/components/Button" +import BreakFromCentered from "@/shared-module/common/components/Centering/BreakFromCentered" +import ErrorBanner from "@/shared-module/common/components/ErrorBanner" +import Pagination from "@/shared-module/common/components/Pagination" +import Spinner from "@/shared-module/common/components/Spinner" +import { PageMarginOffset } from "@/shared-module/common/components/layout/PageMarginOffset" +import { withSignedIn } from "@/shared-module/common/contexts/LoginStateContext" +import usePaginationInfo from "@/shared-module/common/hooks/usePaginationInfo" +import { baseTheme, fontWeights, headingFont } from "@/shared-module/common/styles" +import { MARGIN_BETWEEN_NAVBAR_AND_CONTENT } from "@/shared-module/common/utils/constants" +import dontRenderUntilQueryParametersReady, { + SimplifiedUrlQuery, +} from "@/shared-module/common/utils/dontRenderUntilQueryParametersReady" +import withErrorBoundary from "@/shared-module/common/utils/withErrorBoundary" + +interface SubmissionPageProps { + query: SimplifiedUrlQuery<"id"> +} +const GradingPage: React.FC> = ({ query }) => { + const { t } = useTranslation() + const paginationInfo = usePaginationInfo() + + const getSubmissions = useExamSubmissionsInfo(query.id, paginationInfo.page, paginationInfo.limit) + + const examId = getSubmissions.data?.data[0].exercise.exam_id + const getExam = useQuery({ + queryKey: [`/exams/${examId}/`, examId], + queryFn: () => fetchExam(examId ?? ""), + }) + + const pieces: BreadcrumbPiece[] = useMemo(() => { + const pieces = [ + // eslint-disable-next-line i18next/no-literal-string + { text: t("link-manage"), url: `/manage/exams/${examId}` }, + // eslint-disable-next-line i18next/no-literal-string + { text: t("questions"), url: `/manage/exams/${examId}/questions` }, + { text: t("header-submissions"), url: "" }, + ] + return pieces + }, [examId, t]) + + return ( +
    + + + + + + {getSubmissions.isError && } + {getSubmissions.isPending && } + {getSubmissions.isSuccess && getExam.isSuccess && ( + <> +

    + {t("header-submissions")} +

    + + + + + + + + + + + + + {getSubmissions.data.data.map((submission) => ( + + + + + + + + + ))} + +
    {t("label-action")}{t("user-id")}{t("status")}{t("published")}{t("label-submission-time")}{t("label-points")}
    + {getExam.data?.grade_manually ? ( + submission.teacher_grading_decision ? ( + + ) : ( + + ) + ) : ( + + )} + {submission.exercise_slide_submission.user_id} + {getExam.data?.grade_manually ? ( + submission.teacher_grading_decision ? ( +
    + {t("status-graded")} +
    + ) : ( +
    + {t("status-ungraded")} +
    + ) + ) : ( +
    + {t("label-graded-automatically")} +
    + )} +
    + {getExam.data?.grade_manually ? ( + submission.teacher_grading_decision ? ( + submission.teacher_grading_decision.hidden ? ( +
    + {t("unpublished")} +
    + ) : ( +
    + {t("published")} +
    + ) + ) : ( + <>- + ) + ) : ( + <>- + )} +
    + {parseISO(submission.exercise_slide_submission.created_at).toLocaleString()} + + {getExam.data?.grade_manually + ? submission.teacher_grading_decision + ? submission.teacher_grading_decision.score_given + : 0 + : submission.user_exercise_state.score_given} + / {submission.exercise.score_maximum} +
    + + + )} +
    + ) +} + +export default withErrorBoundary(withSignedIn(dontRenderUntilQueryParametersReady(GradingPage))) diff --git a/services/main-frontend/src/pages/submissions/[id]/grading.tsx b/services/main-frontend/src/pages/submissions/[id]/grading.tsx new file mode 100644 index 000000000000..128ac38a5aa0 --- /dev/null +++ b/services/main-frontend/src/pages/submissions/[id]/grading.tsx @@ -0,0 +1,143 @@ +import { css } from "@emotion/css" +import { useQuery } from "@tanstack/react-query" +import React, { useMemo } from "react" +import { useTranslation } from "react-i18next" + +import GradeExamAnswerForm from "../../../components/forms/GradeExamAnswerForm" +import SubmissionIFrame from "../../../components/page-specific/submissions/id/SubmissionIFrame" +import { Block } from "../../../services/backend/exercises" +import { fetchSubmissionInfo } from "../../../services/backend/submissions" + +import { fetchExam } from "@/services/backend/exams" +import { CourseMaterialExerciseTask } from "@/shared-module/common/bindings" +import Breadcrumbs, { BreadcrumbPiece } from "@/shared-module/common/components/Breadcrumbs" +import BreakFromCentered from "@/shared-module/common/components/Centering/BreakFromCentered" +import Centered from "@/shared-module/common/components/Centering/Centered" +import ErrorBanner from "@/shared-module/common/components/ErrorBanner" +import Spinner from "@/shared-module/common/components/Spinner" +import { PageMarginOffset } from "@/shared-module/common/components/layout/PageMarginOffset" +import { fontWeights, headingFont } from "@/shared-module/common/styles" +import { MARGIN_BETWEEN_NAVBAR_AND_CONTENT } from "@/shared-module/common/utils/constants" +import dontRenderUntilQueryParametersReady, { + SimplifiedUrlQuery, +} from "@/shared-module/common/utils/dontRenderUntilQueryParametersReady" + +interface SubmissionPageProps { + query: SimplifiedUrlQuery<"id"> +} + +const Submission: React.FC> = ({ query }) => { + const { t } = useTranslation() + + const getSubmissionInfo = useQuery({ + queryKey: [`submission-${query.id}`], + queryFn: () => fetchSubmissionInfo(query.id), + }) + + const handleGetAssignments = (task: CourseMaterialExerciseTask) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const assignments = task.assignment as Block[] + return assignments.map((assignment) => assignment.attributes?.content) + } + + const examId = getSubmissionInfo.data?.exercise.exam_id + const exerciseId = getSubmissionInfo.data?.exercise.id + + const getExam = useQuery({ + queryKey: [`/exams/${examId}/`, examId], + queryFn: () => fetchExam(examId ?? ""), + }) + + const pieces: BreadcrumbPiece[] = useMemo(() => { + const pieces = [ + // eslint-disable-next-line i18next/no-literal-string + { text: t("link-manage"), url: `/manage/exams/${examId}` }, + // eslint-disable-next-line i18next/no-literal-string + { text: t("questions"), url: `/manage/exams/${examId}/questions` }, + { + text: t("header-submissions"), + // eslint-disable-next-line i18next/no-literal-string + url: `/manage/exercises/${exerciseId}/exam-submissions`, + }, + { text: query.id, url: "" }, + ] + return pieces + }, [examId, exerciseId, query.id, t]) + + return ( +
    + + + + + + {getSubmissionInfo.isError && ( + + )} + {getSubmissionInfo.isPending && } + {getSubmissionInfo.isSuccess && getExam.isSuccess && ( + +
    +

    + {t("label-grade")} {getSubmissionInfo.data.exercise.name} +

    + {getSubmissionInfo.data.tasks + .sort((a, b) => a.order_number - b.order_number) + .map((task) => ( +
    +
    + {handleGetAssignments(task)} +
    + + {!getExam.data?.grade_manually && ( +
    + {t("message-this-submission-has-been-graded-automatically")}: +
    + {task.previous_submission_grading?.score_given} / + {task.previous_submission_grading?.unscaled_score_maximum} +
    +
    + )} +
    + ))} +
    + + {getExam.data?.grade_manually && ( + + )} +
    + )} +
    + ) +} + +export default dontRenderUntilQueryParametersReady(Submission) diff --git a/services/main-frontend/src/services/backend/answers-requiring-attention.ts b/services/main-frontend/src/services/backend/answers-requiring-attention.ts index 16cf34d010ce..44d9fa28fd51 100644 --- a/services/main-frontend/src/services/backend/answers-requiring-attention.ts +++ b/services/main-frontend/src/services/backend/answers-requiring-attention.ts @@ -30,10 +30,11 @@ export const updateAnswerRequiringAttention = async ({ exercise_id, action, manual_points, + hidden, }: NewTeacherGradingDecision): Promise => { const response = await mainFrontendClient.put( `/exercise-slide-submissions/update-answer-requiring-attention`, - { user_exercise_state_id, exercise_id, action, manual_points }, + { user_exercise_state_id, exercise_id, action, manual_points, hidden }, ) return validateResponse(response, isUserExerciseState) } diff --git a/services/main-frontend/src/services/backend/exams.ts b/services/main-frontend/src/services/backend/exams.ts index 9879db73866b..0020b7a6e0b0 100644 --- a/services/main-frontend/src/services/backend/exams.ts +++ b/services/main-frontend/src/services/backend/exams.ts @@ -4,12 +4,22 @@ import { CourseExam, Exam, ExamCourseInfo, + Exercise, + ExerciseSlideSubmission, + ExerciseSlideSubmissionAndUserExerciseState, + ExerciseSlideSubmissionAndUserExerciseStateList, NewExam, Organization, OrgExam, + UserExerciseState, } from "@/shared-module/common/bindings" -import { isOrganization } from "@/shared-module/common/bindings.guard" -import { validateResponse } from "@/shared-module/common/utils/fetching" +import { + isExercise, + isExerciseSlideSubmissionAndUserExerciseState, + isExerciseSlideSubmissionAndUserExerciseStateList, + isOrganization, +} from "@/shared-module/common/bindings.guard" +import { isArray, validateResponse } from "@/shared-module/common/utils/fetching" export const createExam = async (organizationId: string, data: NewExam) => { await mainFrontendClient.post(`/organizations/${organizationId}/exams`, data) @@ -56,3 +66,38 @@ export const unsetCourse = async (examId: string, courseId: string): Promise + total_pages: number +} + +export const fetchExerciseSubmissionsAndUserExerciseStatesWithExamId = async ( + examId: string, +): Promise>> => { + const response = await mainFrontendClient.get(`/exams/${examId}/submissions-with-exam-id`) + return validateResponse(response, isArray(isArray(isExerciseSlideSubmissionAndUserExerciseState))) +} + +export const fetchExerciseSubmissionsAndUserExerciseStatesWithExerciseId = async ( + exerciseId: string, + page: number, + limit: number, +): Promise => { + const response = await mainFrontendClient.get( + `/exams/${exerciseId}/submissions-with-exercise-id?page=${page}&limit=${limit}`, + ) + return validateResponse(response, isExerciseSlideSubmissionAndUserExerciseStateList) +} + +export const fetchExercisesWithExamId = async (examId: string): Promise> => { + const response = await mainFrontendClient.get(`/exams/${examId}/exam-exercises`) + return validateResponse(response, isArray(isExercise)) +} + +export const releaseGrades = async ( + examId: string, + submissions: Array, +) => { + await mainFrontendClient.post(`/exams/${examId}/release-grades`, submissions) +} diff --git a/services/main-frontend/src/services/backend/exercises.ts b/services/main-frontend/src/services/backend/exercises.ts index e86a5ca9daad..6cd784b13f3b 100644 --- a/services/main-frontend/src/services/backend/exercises.ts +++ b/services/main-frontend/src/services/backend/exercises.ts @@ -14,3 +14,11 @@ export const fetchExerciseSubmissions = async ( ) return validateResponse(response, isExerciseSubmissions) } + +export interface Block { + name: string + isValid: boolean + clientId: string + attributes: T + innerBlocks: Block[] +} diff --git a/services/main-frontend/src/services/backend/submissions.ts b/services/main-frontend/src/services/backend/submissions.ts index 300bd74d36d2..4145aaef757c 100644 --- a/services/main-frontend/src/services/backend/submissions.ts +++ b/services/main-frontend/src/services/backend/submissions.ts @@ -1,7 +1,16 @@ import { mainFrontendClient } from "../mainFrontendClient" -import { ExerciseSlideSubmissionInfo } from "@/shared-module/common/bindings" -import { isExerciseSlideSubmissionInfo } from "@/shared-module/common/bindings.guard" +import { + ExerciseSlideSubmissionInfo, + NewTeacherGradingDecision, + TeacherGradingDecision, + UserExerciseState, +} from "@/shared-module/common/bindings" +import { + isExerciseSlideSubmissionInfo, + isTeacherGradingDecision, + isUserExerciseState, +} from "@/shared-module/common/bindings.guard" import { validateResponse } from "@/shared-module/common/utils/fetching" export const fetchSubmissionInfo = async ( @@ -10,3 +19,29 @@ export const fetchSubmissionInfo = async ( const response = await mainFrontendClient.get(`/exercise-slide-submissions/${submissionId}/info`) return validateResponse(response, isExerciseSlideSubmissionInfo) } + +export const addTeacherGrading = async ( + data: NewTeacherGradingDecision, +): Promise => { + const response = await mainFrontendClient.put(`/exercise-slide-submissions/add-teacher-grading`, { + ...data, + }) + return validateResponse(response, isTeacherGradingDecision) +} + +export interface GradingInfo { + examId: string + exerciseId: string + userId: string +} + +export const fetchGradingInfo = async ( + examId: string, + exerciseId: string, + userId: string, +): Promise => { + const response = await mainFrontendClient.get( + `/exercise-slide-submissions/${examId}/user-exercise-state-info?exercise_id=${exerciseId}&user_id=${userId}`, + ) + return validateResponse(response, isUserExerciseState) +} diff --git a/shared-module/packages/common/src/bindings.guard.ts b/shared-module/packages/common/src/bindings.guard.ts index 1b1872479b49..d2d1b83d25a0 100644 --- a/shared-module/packages/common/src/bindings.guard.ts +++ b/shared-module/packages/common/src/bindings.guard.ts @@ -103,6 +103,8 @@ import { ExerciseServiceNewOrUpdate, ExerciseSlide, ExerciseSlideSubmission, + ExerciseSlideSubmissionAndUserExerciseState, + ExerciseSlideSubmissionAndUserExerciseStateList, ExerciseSlideSubmissionCount, ExerciseSlideSubmissionCountByExercise, ExerciseSlideSubmissionCountByWeekAndHour, @@ -1099,7 +1101,8 @@ export function isExam(obj: unknown): obj is Exam { (typedObj["ends_at"] === null || typeof typedObj["ends_at"] === "string") && typeof typedObj["time_minutes"] === "number" && typeof typedObj["minimum_points_treshold"] === "number" && - typeof typedObj["language"] === "string" + typeof typedObj["language"] === "string" && + typeof typedObj["grade_manually"] === "boolean" ) } @@ -1110,6 +1113,7 @@ export function isExamEnrollment(obj: unknown): obj is ExamEnrollment { typeof typedObj["user_id"] === "string" && typeof typedObj["exam_id"] === "string" && typeof typedObj["started_at"] === "string" && + (typedObj["ended_at"] === null || typeof typedObj["ended_at"] === "string") && typeof typedObj["is_teacher_testing"] === "boolean" && (typedObj["show_exercise_answers"] === null || typedObj["show_exercise_answers"] === false || @@ -1139,7 +1143,8 @@ export function isNewExam(obj: unknown): obj is NewExam { (typedObj["ends_at"] === null || typeof typedObj["ends_at"] === "string") && typeof typedObj["time_minutes"] === "number" && typeof typedObj["organization_id"] === "string" && - typeof typedObj["minimum_points_treshold"] === "number" + typeof typedObj["minimum_points_treshold"] === "number" && + typeof typedObj["grade_manually"] === "boolean" ) } @@ -1338,6 +1343,35 @@ export function isExerciseSlideSubmissionInfo(obj: unknown): obj is ExerciseSlid ) } +export function isExerciseSlideSubmissionAndUserExerciseState( + obj: unknown, +): obj is ExerciseSlideSubmissionAndUserExerciseState { + const typedObj = obj as ExerciseSlideSubmissionAndUserExerciseState + return ( + ((typedObj !== null && typeof typedObj === "object") || typeof typedObj === "function") && + (isExercise(typedObj["exercise"]) as boolean) && + (isExerciseSlideSubmission(typedObj["exercise_slide_submission"]) as boolean) && + (isUserExerciseState(typedObj["user_exercise_state"]) as boolean) && + (typedObj["teacher_grading_decision"] === null || + (isTeacherGradingDecision(typedObj["teacher_grading_decision"]) as boolean)) && + (isExamEnrollment(typedObj["user_exam_enrollment"]) as boolean) + ) +} + +export function isExerciseSlideSubmissionAndUserExerciseStateList( + obj: unknown, +): obj is ExerciseSlideSubmissionAndUserExerciseStateList { + const typedObj = obj as ExerciseSlideSubmissionAndUserExerciseStateList + return ( + ((typedObj !== null && typeof typedObj === "object") || typeof typedObj === "function") && + Array.isArray(typedObj["data"]) && + typedObj["data"].every( + (e: any) => isExerciseSlideSubmissionAndUserExerciseState(e) as boolean, + ) && + typeof typedObj["total_pages"] === "number" + ) +} + export function isPeerOrSelfReviewsReceived(obj: unknown): obj is PeerOrSelfReviewsReceived { const typedObj = obj as PeerOrSelfReviewsReceived return ( @@ -3048,7 +3082,9 @@ export function isNewTeacherGradingDecision(obj: unknown): obj is NewTeacherGrad typeof typedObj["user_exercise_state_id"] === "string" && typeof typedObj["exercise_id"] === "string" && (isTeacherDecisionType(typedObj["action"]) as boolean) && - (typedObj["manual_points"] === null || typeof typedObj["manual_points"] === "number") + (typedObj["manual_points"] === null || typeof typedObj["manual_points"] === "number") && + (typedObj["justification"] === null || typeof typedObj["justification"] === "string") && + typeof typedObj["hidden"] === "boolean" ) } @@ -3072,7 +3108,9 @@ export function isTeacherGradingDecision(obj: unknown): obj is TeacherGradingDec typeof typedObj["updated_at"] === "string" && (typedObj["deleted_at"] === null || typeof typedObj["deleted_at"] === "string") && typeof typedObj["score_given"] === "number" && - (isTeacherDecisionType(typedObj["teacher_decision"]) as boolean) + (isTeacherDecisionType(typedObj["teacher_decision"]) as boolean) && + (typedObj["justification"] === null || typeof typedObj["justification"] === "string") && + (typedObj["hidden"] === null || typedObj["hidden"] === false || typedObj["hidden"] === true) ) } @@ -3408,7 +3446,17 @@ export function isExamEnrollmentData(obj: unknown): obj is ExamEnrollmentData { (((typedObj !== null && typeof typedObj === "object") || typeof typedObj === "function") && typedObj["tag"] === "NotYetStarted") || (((typedObj !== null && typeof typedObj === "object") || typeof typedObj === "function") && - typedObj["tag"] === "StudentTimeUp") + typedObj["tag"] === "StudentTimeUp") || + (((typedObj !== null && typeof typedObj === "object") || typeof typedObj === "function") && + typedObj["tag"] === "StudentCanViewGrading" && + Array.isArray(typedObj["gradings"]) && + typedObj["gradings"].every( + (e: any) => + Array.isArray(e) && + (isTeacherGradingDecision(e[0]) as boolean) && + (isExercise(e[1]) as boolean), + ) && + (isExamEnrollment(typedObj["enrollment"]) as boolean)) ) } diff --git a/shared-module/packages/common/src/bindings.ts b/shared-module/packages/common/src/bindings.ts index 24c8d69219d1..50202f14f775 100644 --- a/shared-module/packages/common/src/bindings.ts +++ b/shared-module/packages/common/src/bindings.ts @@ -534,12 +534,14 @@ export interface Exam { time_minutes: number minimum_points_treshold: number language: string + grade_manually: boolean } export interface ExamEnrollment { user_id: string exam_id: string started_at: string + ended_at: string | null is_teacher_testing: boolean show_exercise_answers: boolean | null } @@ -560,6 +562,7 @@ export interface NewExam { time_minutes: number organization_id: string minimum_points_treshold: number + grade_manually: boolean } export interface OrgExam { @@ -685,6 +688,19 @@ export interface ExerciseSlideSubmissionInfo { exercise_slide_submission: ExerciseSlideSubmission } +export interface ExerciseSlideSubmissionAndUserExerciseState { + exercise: Exercise + exercise_slide_submission: ExerciseSlideSubmission + user_exercise_state: UserExerciseState + teacher_grading_decision: TeacherGradingDecision | null + user_exam_enrollment: ExamEnrollment +} + +export interface ExerciseSlideSubmissionAndUserExerciseStateList { + data: Array + total_pages: number +} + export interface PeerOrSelfReviewsReceived { peer_or_self_review_questions: Array peer_or_self_review_question_submissions: Array @@ -1690,6 +1706,8 @@ export interface NewTeacherGradingDecision { exercise_id: string action: TeacherDecisionType manual_points: number | null + justification: string | null + hidden: boolean } export type TeacherDecisionType = @@ -1706,6 +1724,8 @@ export interface TeacherGradingDecision { deleted_at: string | null score_given: number teacher_decision: TeacherDecisionType + justification: string | null + hidden: boolean | null } export interface UserCourseInstanceExerciseServiceVariable { @@ -1925,6 +1945,11 @@ export type ExamEnrollmentData = | { tag: "NotEnrolled"; can_enroll: boolean } | { tag: "NotYetStarted" } | { tag: "StudentTimeUp" } + | { + tag: "StudentCanViewGrading" + gradings: Array<[TeacherGradingDecision, Exercise]> + enrollment: ExamEnrollment + } export interface CourseMaterialPeerOrSelfReviewDataWithToken { course_material_peer_or_self_review_data: CourseMaterialPeerOrSelfReviewData diff --git a/shared-module/packages/common/src/components/InfoComponent.tsx b/shared-module/packages/common/src/components/InfoComponent.tsx new file mode 100644 index 000000000000..b358adbbcf68 --- /dev/null +++ b/shared-module/packages/common/src/components/InfoComponent.tsx @@ -0,0 +1,102 @@ +import { css } from "@emotion/css" +import { InfoCircle } from "@vectopus/atlas-icons-react" +import React, { useLayoutEffect, useRef, useState } from "react" + +import Button from "./Button" +import SpeechBalloon from "./SpeechBalloon" + +interface TimeComponentProps { + label?: string + text: string + right?: boolean + boldLabel?: boolean +} + +const TimeComponent: React.FC< + React.PropsWithChildren> +> = ({ label, text, right, boldLabel }) => { + const [visible, setVisible] = useState(false) + + const speechBubbleRef = useRef(null) + const parentRef = useRef(null) + const pivotPointRef = useRef(null) + const [top, setTop] = useState(0) + const [left, setLeft] = useState(0) + + useLayoutEffect(() => { + const speechBubble = speechBubbleRef.current + const parent = parentRef.current + const pivotPoint = pivotPointRef.current + + if (!speechBubble || !parent || !pivotPoint) { + return + } + + const rect = speechBubble.getBoundingClientRect() + const parentRect = parent.getBoundingClientRect() + const pivotPointRect = pivotPoint.getBoundingClientRect() + + // Relative position to parent + const globalX = pivotPointRect.x - rect.width / 2 + pivotPointRect.width / 2 + const globalY = pivotPointRect.y - rect.height + 10 + + setLeft(globalX - parentRect.x) + setTop(globalY - parentRect.y) + }, []) + + return ( + + +

    + {text} +

    +
    + {label && ( + + {label} + + )} + +
    + ) +} + +export default TimeComponent diff --git a/shared-module/packages/common/src/locales/en/course-material.json b/shared-module/packages/common/src/locales/en/course-material.json index 9b3e65dee859..5f72d8e44367 100644 --- a/shared-module/packages/common/src/locales/en/course-material.json +++ b/shared-module/packages/common/src/locales/en/course-material.json @@ -13,6 +13,7 @@ "available-in-languages": "Available in {{num}} languages", "available-on-date-at-time": "Available {{ date }} at {{ time }}", "block-invalid-without-course": "This block cannot be used on a page not related to a course.", + "button-end-exam": "End exam", "button-label-search-for-pages": "Search for pages", "button-text-agree": "Agree", "button-text-give-extra-peer-review": "Give extra peer review", @@ -107,7 +108,9 @@ "label-country": "Country", "label-course-instance": "Course instance", "label-exercise": "Exercise", + "label-feedback": "Feedback", "label-message": "Message", + "label-name": "Name", "language-language": "Language: {{language}}", "link-text-open-accessible-view-of-this-content": "Open an accessible view of this content.", "loading": "Loading", @@ -117,6 +120,7 @@ "max-points": "Max points", "max-score-n-marks": "Max score: <2>{{marks}} marks", "message-already-on-different-language-version": "You're already on a different language version of this course. Before answering any exercises, please return to <1>{{name}} or change your active language in the course settings.", + "message-do-you-want-to-end-the-exam": "Are you sure you want to end the exam? Make sure you have submitted every exercise before doing this. You cannot send any more answers after this.", "message-the-exam-has-not-started-yet": "You cannot start the exam yet. Please come back later.", "message-you-have-not-met-the-requirements-for-taking-this-exam": "You have not met the requirements for taking this exam.", "n-characters-left": "{{n}} characters left", diff --git a/shared-module/packages/common/src/locales/en/main-frontend.json b/shared-module/packages/common/src/locales/en/main-frontend.json index f4206b1cb57b..5ad777d6642d 100644 --- a/shared-module/packages/common/src/locales/en/main-frontend.json +++ b/shared-module/packages/common/src/locales/en/main-frontend.json @@ -56,6 +56,7 @@ "button-text-new-chapter": "New chapter", "button-text-new-page": "New page", "button-text-new-regrading": "New regrading", + "button-text-next-answer": "Next answer", "button-text-open-course-front-page": "Open course front page", "button-text-preview": "Preview", "button-text-reject": "Reject", @@ -63,6 +64,7 @@ "button-text-remove": "Remove", "button-text-reset-url": "Reset URL", "button-text-save": "Save", + "button-text-save-and-next": "Save and next", "button-text-search": "Search", "button-text-select-image": "Select image", "button-text-send": "Send", @@ -232,6 +234,7 @@ "given-peer-reviews-to-other-students": "Given peer reviews to other students", "given-text-data": "Given text data", "global-permissions": "Global permissions", + "grade": "Grade", "grading": "Grading", "grading-explanation": "The exercise service creates this data from the grade endpoint when the backend posts a submission to it to be graded.", "grant-access-to-users-with-permissions-to-original-course": "Grant access to this course to everyone who had access to the original one", @@ -312,13 +315,19 @@ "label-example-name": "Example name", "label-examples": "Examples", "label-exercise-task": "Exercise task", + "label-exercise-task-submission-ids": "Exercise task submission ids, one per line", + "label-feedback": "Feedback", "label-font-size": "Font size", "label-grade": "Grade", + "label-grade-exam-manually": "Grade exam manually", + "label-graded-automatically": "Graded automatically", "label-hidden": "Hidden", "label-id-type": "Id type", "label-ids-one-per-line": "Ids, one per line", + "label-justification": "Justification", "label-link": "Link", "label-locale": "Locale", + "label-model-solution": "Model solution", "label-name": "Name", "label-null": "Not set", "label-opens-at": "Opens at", @@ -342,6 +351,7 @@ "label-registered": "Registered", "label-related-courses-can-be-completed-automatically": "Related courses can be completed automatically", "label-result-after-merging": "Result after merging:", + "label-review": "Review", "label-role": "Role", "label-send-model-solution-spec": "Send model solution spec (happens when one has ran out of tries or gotten full points from the exercise)", "label-send-previous-submission": "Send previous submission (happens when one has answered the exercise previously and tries to answer it again)", @@ -421,6 +431,7 @@ "message-creating-failed": "Something went wrong, couldn't create", "message-deleting-failed": "Something went wrong, couldn't complete deletion", "message-deleting-succesful": "Deleted succesfully", + "message-do-you-want-to-publish-all-currently-graded-submissions": "Do you want to publish all currently graded submissions?", "message-do-you-want-to-save-the-changes-to-the-chapter-ordering": "Do you want to save the changes to the chapter ordering?", "message-do-you-want-to-save-the-changes-to-the-page-ordering": "Do you want to save the changes to the page ordering?", "message-invalid-query": "Invalid query", @@ -428,6 +439,7 @@ "message-please-confirm-your-email-address": "Please confirm your email address.", "message-saved-succesfully": "Saved succesfully", "message-saving-failed": "Something went wrong, couldn't complete saving", + "message-this-submission-has-been-graded-automatically": "This submission has been graded automatically", "message-update-failed": "Something went wrong, couldn't complete updating", "message-update-succesful": "Update succesful", "message-you-have-not-selected-an-action-for-every-change-yet": "You have not selected an action for every change yet.", @@ -459,7 +471,10 @@ "no-submissions": "No submissions found", "no-support-email-set": "No support email set", "nothing-here": "Nothing here!", + "number-of-answered": "Number of answered", + "number-of-graded": "Number of graded", "number-of-students": "Number of students", + "number-of-unpublished-gradings": "Number of unpublished gradings", "number-of-users-attempted-the-exercise": "Number of users attempted the exercise", "number-of-users-with-max-points": "Number of users with max points", "number-of-users-with-some-points": "Number of users with some points", @@ -500,8 +515,12 @@ "previous-title-current-title": "Previous: {{current-title}} | Current: {{selected-title}}", "private-spec": "Private spec", "public-spec-explanation": "Public spec is used for rendering the user interface when the user is starting to answer an exercise.", + "publish-grading-results": "Publish grading results", + "publish-grading-results-info": "The students won’t automatically see the grading results. After publishing the grading results, the students can see the points and the feedback you have given to them. Also, the students who pass the course can register their completion to the study register.", + "published": "Published", "question": "Question", "question-n": "Question {{n}}", + "questions": "Questions", "read": "Read", "received-enough-peer-reviews": "Received enough peer reviews", "received-number-data": "Received number data", @@ -542,6 +561,7 @@ "roles-for-exam": "Roles for exam", "roles-for-organization": "Roles for organization", "save": "Save", + "save-as-draft": "Save as draft", "save-as-png": "Save as PNG", "save-changes": "Save changes", "save-edited-role": "Save edited role", @@ -562,6 +582,9 @@ "starts": "Starts", "stats": "Stats", "status": "Status", + "status-graded": "Graded", + "status-in-progress": "In progress", + "status-ungraded": "Ungraded", "student-answer": "Student answer", "student-id": "Student id", "student-name": "Student name", @@ -641,6 +664,8 @@ "uh-course-code": "University of Helsinki course code", "undread": "Unread", "unlisted": "Unlisted", + "unpublishable-grading-results": "You have {{amount}} unpublished grading results", + "unpublished": "Unpublished", "unread": "Unread", "update-peer-review-queue-reviews-received": "Update peer review queue reviews received", "updated-definition": "Updated definition", diff --git a/shared-module/packages/common/src/locales/fi/course-material.json b/shared-module/packages/common/src/locales/fi/course-material.json index 2f71d001f58c..3667c07e4700 100644 --- a/shared-module/packages/common/src/locales/fi/course-material.json +++ b/shared-module/packages/common/src/locales/fi/course-material.json @@ -12,6 +12,7 @@ "available-in-languages": "Saatavilla {{num}} kielellä", "available-on-date-at-time": "Avoinna {{ date }} klo {{ time }}", "block-invalid-without-course": "Tätä lohkoa ei voi käyttää sivulla joka ei liity kurssiin.", + "button-end-exam": "Lopeta koe", "button-label-search-for-pages": "Selaa sivuja", "button-text-give-extra-peer-review": "Anna ylimääräinen vertaisarvio", "button-text-manage-course": "Hallinnoi kurssia", @@ -102,6 +103,8 @@ "label-country": "Maa", "label-course-instance": "Kurssiversio", "label-exercise": "Tehtävä", + "label-feedback": "Palaute", + "label-name": "Nimi", "language-language": "Kieli: {{language}}", "link-text-open-accessible-view-of-this-content": "Avaa saavutettava näkymä tästä sisällöstä", "loading": "Lataa", @@ -111,6 +114,7 @@ "max-points": "Maksimipisteet", "max-score-n-marks": "Maksimi pisteet: <2>{{marks}}", "message-already-on-different-language-version": "Olet tekemässä kurssia jo toisella kielellä. Ennen kuin vastaat mihinkään tehtävään, palaa kieliversioon <1>{{name}} tai vaihda käytössä oleva kieli kurssin asetuksista.", + "message-do-you-want-to-end-the-exam": "Oletko varma, että haluat lopettaa kokeen? Varmista, että olet lähettänyt jokaisen tehtävän vastauksen ennen kokeen lopetusta. Et voi lähettää enää uusia vastauksia kokeen lopettamisen jälkeen.", "message-the-exam-has-not-started-yet": "Et voi vielä aloittaa koetta. Ole hyvä ja palaa myöhemmin.", "message-you-have-not-met-the-requirements-for-taking-this-exam": "Et ole täyttänyt esivaatimuksia tämän kokeen suorittamiseen.", "n-characters-left": "{{n}} merkkiä jäljellä", diff --git a/shared-module/packages/common/src/locales/fi/main-frontend.json b/shared-module/packages/common/src/locales/fi/main-frontend.json index c1e0c2f24b75..954cc749d1a6 100644 --- a/shared-module/packages/common/src/locales/fi/main-frontend.json +++ b/shared-module/packages/common/src/locales/fi/main-frontend.json @@ -53,6 +53,7 @@ "button-text-new-chapter": "Uusi luku", "button-text-new-page": "Uusi sivu", "button-text-new-regrading": "Uusi uudelleenarvostelu", + "button-text-next-answer": "Seuraava vastaus", "button-text-open-course-front-page": "Avaa kurssin etusivu", "button-text-preview": "Esikatsele", "button-text-reject": "Hylkää", @@ -60,6 +61,7 @@ "button-text-remove": "Poista", "button-text-reset-url": "Nollaa URL", "button-text-save": "Tallenna", + "button-text-save-and-next": "Tallenna ja seuraava", "button-text-search": "Etsi", "button-text-select-image": "Lisää kuva", "button-text-send": "Lähetä", @@ -223,6 +225,7 @@ "given-peer-reviews-to-other-students": "Muille opiskelijoille annetut vertaisarviot", "given-text-data": "Annettu teksti palaute", "global-permissions": "Järjestelmänlaajuiset oikeudet", + "grade": "Arvostele", "grading": "Arvostelu", "grading-description": "Käytä mukautettuja pisteitä, kun haluat antaa muuta kuin 0 tai täydet pisteet", "grading-explanation": "Tehtäväpalvelu luo tämän datan grade endpointista kun backend lähettää tehtäväpalautuksen sille arvosteltavaksi.", @@ -304,13 +307,19 @@ "label-example-name": "Esimerkin nimi", "label-examples": "Esimerkit", "label-exercise-task": "Tehtävän osa", + "label-exercise-task-submission-ids": "Exercise task submissioneiden id:t, yksi per rivi", + "label-feedback": "Palaute", "label-font-size": "Fonttikoko", "label-grade": "Arvosana", + "label-grade-exam-manually": "Arvostele koe manuaalisesti", + "label-graded-automatically": "Arvosteltu automaattisesti", "label-hidden": "Piilotettu", "label-id-type": "Id:n tyyppi", "label-ids-one-per-line": "Id:t, yksi per rivi", + "label-justification": "Perustelu", "label-link": "Linkki", "label-locale": "Kieli", + "label-model-solution": "Mallivastaus", "label-name": "Nimi", "label-null": "Tyhjä", "label-opens-at": "Avautuu", @@ -334,6 +343,7 @@ "label-registered": "Rekisteröity", "label-related-courses-can-be-completed-automatically": "Liitetyt kurssit voidaan suorittaa automaattisesti", "label-result-after-merging": "Tulos yhdistämisen jälkeen:", + "label-review": "Tarkastele", "label-role": "Rooli", "label-send-model-solution-spec": "Lähetä model solution spec (tapahtuu kun yritykset on loppu tai käyttäjä on saanut täydet pisteet tehtävästä)", "label-send-previous-submission": "Lähetä edellinen palautus (tapahtuu kun käyttäjä on aikaisemmin vastannut tehtävään ja oppilas koittaa vastata tehtävään uudelleen)", @@ -411,6 +421,7 @@ "message-creating-failed": "Jokin meni vikaan, ei voitu luoda", "message-deleting-failed": "Jotakin meni pieleen, poistaminen ei onnistunut", "message-deleting-succesful": "Poistaminen onnistui", + "message-do-you-want-to-publish-all-currently-graded-submissions": "Haluatko julkaista kaikki tällä hetkellä arvioidut palautukset?", "message-do-you-want-to-save-the-changes-to-the-chapter-ordering": "Haluatko tallentaa kappaleen uuden järjestyksen?", "message-do-you-want-to-save-the-changes-to-the-page-ordering": "Haluatko tallentaa muutokset sivujen järjestykseen?", "message-invalid-query": "Epäkelpo kysely", @@ -418,6 +429,7 @@ "message-please-confirm-your-email-address": "Please confirm your email address.", "message-saved-succesfully": "Tallennus onnistui", "message-saving-failed": "Jotakin meni pieleen, tallennus ei onnistunut", + "message-this-submission-has-been-graded-automatically": "Tämä palautus on arvosteltu automaattisesti", "message-update-failed": "Jotakin meni pieleen, päivitys ei onnistunut", "message-update-succesful": "Päivitys onnistui", "message-you-have-not-selected-an-action-for-every-change-yet": "Et ole vielä valinnut toimintoa jokaiselle muutokselle.", @@ -449,7 +461,10 @@ "no-submissions": "Ei palautuksia tehtävälle", "no-support-email-set": "Tukisähköpostia ei ole asetettu", "nothing-here": "Täällä ei ole mitään!", + "number-of-answered": "Vastanneiden määrä", + "number-of-graded": "Arvosteltujen määrä", "number-of-students": "Opiskelijoiden määrä", + "number-of-unpublished-gradings": "Julkaisemattomien arvosteluiden määrä", "number-of-users-attempted-the-exercise": "Tehtävää yrittäneet käyttäjät", "number-of-users-with-max-points": "Tehtävästä täydet pisteet saaneet käyttäjät", "number-of-users-with-some-points": "Tehtävästä pisteitä saaneet käyttäjät", @@ -491,8 +506,12 @@ "previous-title-current-title": "Edellinen: {{current-title}} | Nykyinen: {{selected-title}}", "private-spec": "Private spec", "public-spec-explanation": "Public spec:iä käytetään käyttöliittymän piirtämiseen kun oppilas on aloittamassa tehtävään vastaamiseen..", + "publish-grading-results": "Julkaise arvostelut", + "publish-grading-results-info": "Opiskelijat eivät automaattisesti näe antamiasi arvosteluja. Antamiesi arvosteluiden julkaisemisen jälkeen opiskelijat näkevät pisteet ja heille antamasi palautteen. Lisäksi kurssin läpäisevät opiskelijat voivat rekisteröidä suorituksensa opintorekisteriin.", + "published": "Julkaistu", "question": "Kysymys", "question-n": "{{n}}. kysymys", + "questions": "Kysymykset", "read": "Luetut", "received-enough-peer-reviews": "Tarpeeksi saatuja vertaisarvionteja", "received-number-data": "Saatu numero palaute", @@ -533,6 +552,7 @@ "roles-for-exam": "Roolit kokeelle", "roles-for-organization": "Roolit organisaatiolle", "save": "Tallenna", + "save-as-draft": "Tallenna luonnos", "save-as-png": "Tallenna PNG tiedostona", "save-changes": "Tallenna muutokset", "save-edited-role": "Tallenna muutettu rooli", @@ -552,6 +572,9 @@ "starts": "Alku", "stats": "Tilastot", "status": "Tila", + "status-graded": "Arvosteltu", + "status-in-progress": "Kesken", + "status-not-started": "Ei arvosteltu", "student-answer": "Opiskelijan vastaus", "student-name": "Opiskelijan nimi", "support-email": "Tukisähköposti", @@ -624,6 +647,8 @@ "uh-course-code": "Helsingin yliopiston kurssikoodi", "undread": "Lukemattomat", "unlisted": "Piilotettu", + "unpublishable-grading-results": "Sinulla on {{amount}} julkaisemattomia arvosteluja", + "unpublished": "Julkaisematon", "unread": "Lukemattomat", "update-peer-review-queue-reviews-received": "Päivitä vertaisarviojonon arvioita vastaanotettu", "updated-definition": "Uusi määritelmä", diff --git a/system-tests/src/__screenshots__/exam-list.spec.ts/create-exam-dialog-desktop-regular.png b/system-tests/src/__screenshots__/exam-list.spec.ts/create-exam-dialog-desktop-regular.png index 0f78fe9db487..f3ac347046e6 100644 Binary files a/system-tests/src/__screenshots__/exam-list.spec.ts/create-exam-dialog-desktop-regular.png and b/system-tests/src/__screenshots__/exam-list.spec.ts/create-exam-dialog-desktop-regular.png differ diff --git a/system-tests/src/__screenshots__/exam-list.spec.ts/create-exam-dialog-filled-desktop-regular.png b/system-tests/src/__screenshots__/exam-list.spec.ts/create-exam-dialog-filled-desktop-regular.png index d925cdce1392..cf0ae03e1594 100644 Binary files a/system-tests/src/__screenshots__/exam-list.spec.ts/create-exam-dialog-filled-desktop-regular.png and b/system-tests/src/__screenshots__/exam-list.spec.ts/create-exam-dialog-filled-desktop-regular.png differ diff --git a/system-tests/src/__screenshots__/exam-list.spec.ts/create-exam-dialog-filled-mobile-tall.png b/system-tests/src/__screenshots__/exam-list.spec.ts/create-exam-dialog-filled-mobile-tall.png index fa2f6ad3d27a..44a58749adb8 100644 Binary files a/system-tests/src/__screenshots__/exam-list.spec.ts/create-exam-dialog-filled-mobile-tall.png and b/system-tests/src/__screenshots__/exam-list.spec.ts/create-exam-dialog-filled-mobile-tall.png differ diff --git a/system-tests/src/__screenshots__/exam-list.spec.ts/create-exam-dialog-mobile-tall.png b/system-tests/src/__screenshots__/exam-list.spec.ts/create-exam-dialog-mobile-tall.png index 8a9e1c5bc3bc..0c3835f62e7c 100644 Binary files a/system-tests/src/__screenshots__/exam-list.spec.ts/create-exam-dialog-mobile-tall.png and b/system-tests/src/__screenshots__/exam-list.spec.ts/create-exam-dialog-mobile-tall.png differ diff --git a/system-tests/src/__screenshots__/exam-list.spec.ts/exam-listing-desktop-regular.png b/system-tests/src/__screenshots__/exam-list.spec.ts/exam-listing-desktop-regular.png index 5f012d9749ec..691006195cdc 100644 Binary files a/system-tests/src/__screenshots__/exam-list.spec.ts/exam-listing-desktop-regular.png and b/system-tests/src/__screenshots__/exam-list.spec.ts/exam-listing-desktop-regular.png differ diff --git a/system-tests/src/__screenshots__/exam-list.spec.ts/exam-listing-mobile-tall.png b/system-tests/src/__screenshots__/exam-list.spec.ts/exam-listing-mobile-tall.png index db2928878b85..a6ae3b66afda 100644 Binary files a/system-tests/src/__screenshots__/exam-list.spec.ts/exam-listing-mobile-tall.png and b/system-tests/src/__screenshots__/exam-list.spec.ts/exam-listing-mobile-tall.png differ diff --git a/system-tests/src/__screenshots__/exams/taking-exam.spec.ts/exam-exercise-answered-desktop-regular.png b/system-tests/src/__screenshots__/exams/taking-exam.spec.ts/exam-exercise-answered-desktop-regular.png index 8b052871520f..affba2dca845 100644 Binary files a/system-tests/src/__screenshots__/exams/taking-exam.spec.ts/exam-exercise-answered-desktop-regular.png and b/system-tests/src/__screenshots__/exams/taking-exam.spec.ts/exam-exercise-answered-desktop-regular.png differ diff --git a/system-tests/src/tests/grade-exams-manually.spec.ts b/system-tests/src/tests/grade-exams-manually.spec.ts new file mode 100644 index 000000000000..c7ffc712a382 --- /dev/null +++ b/system-tests/src/tests/grade-exams-manually.spec.ts @@ -0,0 +1,199 @@ +import { BrowserContext, expect, test } from "@playwright/test" + +import { scrollLocatorsParentIframeToViewIfNeeded } from "@/utils/iframeLocators" + +test.use({ + storageState: "src/states/admin@example.com.json", +}) + +let context1: BrowserContext +let context2: BrowserContext +let context3: BrowserContext + +test.beforeEach(async ({ browser }) => { + context1 = await browser.newContext({ storageState: "src/states/student1@example.com.json" }) + context2 = await browser.newContext({ storageState: "src/states/student2@example.com.json" }) + context3 = await browser.newContext({ storageState: "src/states/teacher@example.com.json" }) +}) + +test.afterEach(async () => { + await context1.close() + await context2.close() + await context3.close() +}) + +test("Grade exams manually", async ({}) => { + test.slow() + const student1Page = await context1.newPage() + const student2Page = await context2.newPage() + const teacherPage = await context3.newPage() + + // Student1 goes to the exam page and submits answers and then ends exam + await student1Page.goto( + "http://project-331.local/org/uh-cs/exams/fee8bb0c-8629-477c-86eb-1785005143ae", + ) + + student1Page.once("dialog", (dialog) => { + dialog.accept() + }) + await student1Page.getByRole("button", { name: "Start the exam!" }).click() + + await student1Page + .frameLocator('iframe[title="Exercise 2\\, task 1 content"]') + .getByRole("button", { name: "cargo" }) + .click() + await student1Page + .getByLabel("Exercise:Multiple choice with") + .getByRole("button", { name: "Submit" }) + .click() + await student1Page.getByRole("button", { name: "Try again" }).waitFor() + await scrollLocatorsParentIframeToViewIfNeeded( + student1Page + .frameLocator('iframe[title="Exercise 1\\, task 1 content"]') + .getByRole("checkbox", { name: "b" }), + ) + await student1Page + .frameLocator('iframe[title="Exercise 1\\, task 1 content"]') + .getByRole("checkbox", { name: "b" }) + .click() + await student1Page.getByRole("button", { name: "Submit" }).click() + await student1Page.getByRole("button", { name: "Try again" }).nth(1).waitFor() + + student1Page.once("dialog", (dialog) => { + dialog.accept() + }) + await student1Page.getByRole("button", { name: "End exam" }).click() + await expect(student1Page.getByText("Success", { exact: true })).toBeVisible() + + // Student2 goes to the exam page and submits answers and then ends exam + await student2Page.goto( + "http://project-331.local/org/uh-cs/exams/fee8bb0c-8629-477c-86eb-1785005143ae", + ) + + student2Page.once("dialog", (dialog) => { + dialog.accept() + }) + await student2Page.getByRole("button", { name: "Start the exam!" }).click() + + await student2Page + .frameLocator('iframe[title="Exercise 2\\, task 1 content"]') + .getByRole("button", { name: "npm" }) + .click() + await student2Page + .getByLabel("Exercise:Multiple choice with") + .getByRole("button", { name: "Submit" }) + .click() + await student2Page.getByRole("button", { name: "Try again" }).waitFor() + await scrollLocatorsParentIframeToViewIfNeeded( + student2Page + .frameLocator('iframe[title="Exercise 1\\, task 1 content"]') + .getByRole("checkbox", { name: "b" }), + ) + await student2Page + .frameLocator('iframe[title="Exercise 1\\, task 1 content"]') + .getByRole("checkbox", { name: "b" }) + .click() + await student2Page.getByRole("button", { name: "Submit" }).click() + await student2Page.getByRole("button", { name: "Try again" }).nth(1).waitFor() + + student2Page.once("dialog", (dialog) => { + dialog.accept() + }) + await student2Page.getByRole("button", { name: "End exam" }).click() + await expect(student2Page.getByText("Success", { exact: true })).toBeVisible() + + // Teacher goes to the grading page and grades the students submissions + await teacherPage.goto("http://project-331.local/organizations") + await teacherPage.getByLabel("University of Helsinki, Department of Computer Science").click() + await teacherPage + .locator("li") + .filter({ hasText: "Exam for manual gradingManage" }) + .getByRole("link") + .nth(1) + .click() + await teacherPage.getByRole("link", { name: "Grading", exact: true }).click() + + // Check that there are both students submissions + await teacherPage.getByRole("cell", { name: "Number of answered" }).waitFor() + await expect(teacherPage.getByRole("cell", { name: "2" }).first()).toBeVisible() + + await teacherPage.getByRole("row", { name: "Grade Question 1" }).getByRole("button").click() + + // Check the first submissions has 0 points and it's ungraded + await expect(teacherPage.getByRole("cell", { name: "Ungraded" }).first()).toBeVisible() + await expect(teacherPage.getByText("0/ 1").first()).toBeVisible() + + // Grade both submissions + await teacherPage + .getByRole("row", { name: "Grade 02364d40-2aac-4763-8a06" }) + .getByRole("button") + .click() + await teacherPage.locator("#Justification").fill("Ok") + await teacherPage.getByLabel("Score", { exact: true }).fill("1") + await teacherPage.getByRole("button", { name: "Save and next" }).click() + + await teacherPage.locator("#Justification").fill("Good") + await teacherPage.getByLabel("Score", { exact: true }).fill("0.5") + await teacherPage.getByRole("button", { name: "Submit" }).click() + await teacherPage.getByText("Operation successful!").waitFor() + + // Check both submissions are graded + await teacherPage.getByRole("link", { name: "Submissions" }).click() + await expect(teacherPage.getByText("Graded").first()).toBeVisible() + await expect(teacherPage.getByRole("cell", { name: "1/ 1" }).first()).toBeVisible() + await expect(teacherPage.getByText("Graded").nth(1)).toBeVisible() + await expect(teacherPage.getByRole("cell", { name: "0.5/ 1" })).toBeVisible() + + await teacherPage.getByRole("link", { name: "Questions" }).click() + + // Check question 1 is fully graded and unpublished + await expect(teacherPage.getByText("Graded", { exact: true })).toBeVisible() + await expect( + teacherPage.getByRole("row", { name: "Grade Question 1 Graded 2 2 2" }), + ).toBeVisible() + await expect(teacherPage.getByText("You have 2 unpublished grading results")).toBeVisible() + + // Check students can't see grading results before they are published + await student1Page.goto( + "http://project-331.local/org/uh-cs/exams/fee8bb0c-8629-477c-86eb-1785005143ae", + ) + await expect( + student1Page.getByText("Your time has run out and the exam is now closed"), + ).toBeVisible() + + await student2Page.goto( + "http://project-331.local/org/uh-cs/exams/fee8bb0c-8629-477c-86eb-1785005143ae", + ) + await expect( + student2Page.getByText("Your time has run out and the exam is now closed"), + ).toBeVisible() + + // Publish grading results + teacherPage.once("dialog", (dialog) => { + dialog.accept() + }) + + await teacherPage.getByRole("button", { name: "Publish grading results" }).click() + await teacherPage.getByText("Operation successful!").waitFor() + + await expect( + teacherPage.getByRole("row", { name: "Grade Question 1 Graded 2 2 0" }), + ).toBeVisible() + + //Both students check that they can see grading results after the teacher published them + + await student1Page.goto( + "http://project-331.local/org/uh-cs/exams/fee8bb0c-8629-477c-86eb-1785005143ae", + ) + + await expect(student1Page.getByText("Name: Best exercise")).toBeVisible() + await expect(student1Page.getByText("Points: 1 / 1")).toBeVisible() + await expect(student1Page.getByText("Feedback:Ok")).toBeVisible() + + await student2Page.goto( + "http://project-331.local/org/uh-cs/exams/fee8bb0c-8629-477c-86eb-1785005143ae", + ) + await expect(student2Page.getByText("Name: Best exercise")).toBeVisible() + await expect(student2Page.getByText("Points: 0.5 / 1")).toBeVisible() + await expect(student2Page.getByText("Feedback:Good")).toBeVisible() +}) diff --git a/system-tests/src/utils/iframeLocators.ts b/system-tests/src/utils/iframeLocators.ts index 6ee05e92ba93..9b65ec1518e2 100644 --- a/system-tests/src/utils/iframeLocators.ts +++ b/system-tests/src/utils/iframeLocators.ts @@ -32,6 +32,10 @@ export async function getLocatorForNthExerciseServiceIframe( * If the locator is inside an iframe, scrolls the iframe to the view. Sometimes needed for making locators working inside the iframe to work. */ export async function scrollLocatorsParentIframeToViewIfNeeded(locator: Locator) { + const page = locator.page() + // We must wait here to counteract the automatic scrolling we do in `makeSureComponentStaysVisibleAfterChangingView` + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(550) // Logic to make getting element handles from inside iframes that are offscreen to work await expect(async () => { const elementHandle = await locator.elementHandle({ timeout: 500 }) diff --git a/system-tests/tsconfig.json b/system-tests/tsconfig.json index 78f2dc4427ab..a5bc893e979f 100644 --- a/system-tests/tsconfig.json +++ b/system-tests/tsconfig.json @@ -1,20 +1,11 @@ { "compilerOptions": { "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] }, "target": "es5", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], - "typeRoots": [ - "types", - "./node_modules/@types" - ], + "lib": ["dom", "dom.iterable", "esnext"], + "typeRoots": ["types", "./node_modules/@types"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -28,12 +19,6 @@ "jsx": "preserve", "incremental": true }, - "include": [ - "**/*.ts", - "**/*.tsx" - ], - "exclude": [ - "node_modules", - "src/shared-module" - ] + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "src/shared-module"] }