From b7974963c7da9f283c43f6edc4d23e7bab30b6bc Mon Sep 17 00:00:00 2001 From: Anastasia Diseth <58883418+anadis504@users.noreply.github.com> Date: Thu, 21 Mar 2024 13:53:08 +0200 Subject: [PATCH] Research questions download (#1255) * add user variables to custom view * fix Uuid from prev pr * Consent form ansers csv expert and fixes * small fix --- .../CustomViewIframe.tsx | 95 ++++++++++++------- .../course-material/src/services/backend.ts | 6 +- ...e592440904801cabf1b200ee96e7917a4ccd3.json | 48 ---------- ...cfa2c46cd4e24dfb1e5a064802d3351d72b85.json | 48 ++++++++++ ...fa25c340c5c83d6710b80b98436a6cc4c608.json} | 4 +- ...216c9da8fb3728d99c6711dff808431d0d8d6.json | 63 ++++++++++++ ...e22fd2d87fecbcab0830a647ba5676cd75afc.json | 53 +++++++++++ ...f7befa51865d17fc22b855953be5a17ed5d8a.json | 28 ------ ...3e111e2adda9ef1b3be47205fc9086020a020.json | 28 ++++++ .../models/src/exercise_task_submissions.rs | 60 +++++------- .../headless-lms/models/src/exercise_tasks.rs | 30 +++--- services/headless-lms/models/src/exercises.rs | 2 +- .../src/library/custom_view_exercises.rs | 6 +- .../headless-lms/models/src/research_forms.rs | 41 ++++++++ ...rse_instance_exercise_service_variables.rs | 25 +++++ .../course_material/course_modules.rs | 11 ++- .../src/controllers/main_frontend/courses.rs | 43 +++++++++ ..._research_form_questions_answers_export.rs | 83 ++++++++++++++++ .../server/src/domain/csv_export/mod.rs | 1 + .../manage/courses/id/index/ManageCourse.tsx | 8 ++ shared-module/src/bindings.guard.ts | 6 +- shared-module/src/bindings.ts | 1 + .../exercise-service-protocol-types.guard.ts | 1 + .../src/exercise-service-protocol-types.ts | 2 +- .../src/locales/en/main-frontend.json | 1 + .../src/locales/fi/main-frontend.json | 1 + 26 files changed, 518 insertions(+), 177 deletions(-) delete mode 100644 services/headless-lms/models/.sqlx/query-0646a9e32998f1c57327702deb4e592440904801cabf1b200ee96e7917a4ccd3.json create mode 100644 services/headless-lms/models/.sqlx/query-17c516418ede7b0240835c473c0cfa2c46cd4e24dfb1e5a064802d3351d72b85.json rename services/headless-lms/models/.sqlx/{query-2357564991d128aeeb5db65686276b24c3aff7c1914cd2d4ad614c3eed3c449e.json => query-58e5aac7861f558385ecf507e49afa25c340c5c83d6710b80b98436a6cc4c608.json} (81%) create mode 100644 services/headless-lms/models/.sqlx/query-8c1f861182b005b8045c6d6d096216c9da8fb3728d99c6711dff808431d0d8d6.json create mode 100644 services/headless-lms/models/.sqlx/query-bdf6c7a6ff6ecd02bf62a5e8ec4e22fd2d87fecbcab0830a647ba5676cd75afc.json delete mode 100644 services/headless-lms/models/.sqlx/query-c7644233ed24e991b9308419adaf7befa51865d17fc22b855953be5a17ed5d8a.json create mode 100644 services/headless-lms/models/.sqlx/query-d1ceba14b45d756bfb0ad9935533e111e2adda9ef1b3be47205fc9086020a020.json create mode 100644 services/headless-lms/server/src/domain/csv_export/course_research_form_questions_answers_export.rs diff --git a/services/course-material/src/components/ContentRenderer/moocfi/ExerciseCustomViewBlock/CustomViewIframe.tsx b/services/course-material/src/components/ContentRenderer/moocfi/ExerciseCustomViewBlock/CustomViewIframe.tsx index 20e2d5e2f6ba..bd50499146d1 100644 --- a/services/course-material/src/components/ContentRenderer/moocfi/ExerciseCustomViewBlock/CustomViewIframe.tsx +++ b/services/course-material/src/components/ContentRenderer/moocfi/ExerciseCustomViewBlock/CustomViewIframe.tsx @@ -1,9 +1,10 @@ import { useQuery } from "@tanstack/react-query" import { parseISO } from "date-fns" -import React, { useContext } from "react" +import React, { useContext, useMemo } from "react" import { useTranslation } from "react-i18next" import PageContext from "../../../../contexts/PageContext" +import useCourseInfo from "../../../../hooks/useCourseInfo" import { fetchCourseModuleExercisesAndSubmissionsByType, fetchDefaultModuleIdByCourseId, @@ -11,7 +12,10 @@ import { } from "../../../../services/backend" import ErrorBanner from "../../../../shared-module/components/ErrorBanner" import MessageChannelIFrame from "../../../../shared-module/components/MessageChannelIFrame" -import { CustomViewIframeState } from "../../../../shared-module/exercise-service-protocol-types" +import { + CustomViewIframeState, + UserVariablesMap, +} from "../../../../shared-module/exercise-service-protocol-types" import useUserInfo from "../../../../shared-module/hooks/useUserInfo" import { assertNotNullOrUndefined } from "../../../../shared-module/utils/nullability" @@ -33,6 +37,8 @@ const CustomViewIframe: React.FC> const courseInstanceId = pageContext.instance?.id const courseId = pageContext.settings?.current_course_id + const courseInfo = useCourseInfo(pageContext.settings?.current_course_id) + console.log(courseInfo.data?.name) const moduleIdByChapter = useQuery({ queryKey: [`course-modules-chapter-${chapterId}`], queryFn: () => fetchModuleIdByChapterId(assertNotNullOrUndefined(chapterId)), @@ -59,53 +65,76 @@ const CustomViewIframe: React.FC> enabled: !!moduleId && !!courseInstanceId, }) - const subs_by_exercise = submissions_by_exercise.data?.exercises.map((exer) => { - return { - exercise_id: exer.id, - exercise_name: exer.name, - exercise_tasks: submissions_by_exercise.data.exercise_tasks.task_gradings - .filter((grading) => grading.exercise_id == exer.id) - .map((grading) => { - const answer = submissions_by_exercise.data.exercise_tasks.task_submissions.filter( - (sub) => sub.exercise_task_grading_id == grading.id, - ) - const publicSpec = submissions_by_exercise.data.exercise_tasks.exercise_tasks.find( - (task) => task.id == grading.exercise_task_id, - )?.public_spec - return { - task_id: grading.exercise_task_id, - public_spec: publicSpec, - user_answer: answer, - grading: grading, - } - }) - .sort( - (a, b) => - a.task_id.localeCompare(b.task_id) || - parseISO(b.grading.created_at).getTime() - parseISO(a.grading.created_at).getTime(), - ) - .filter( - (task, index, array) => array.findIndex((el) => el.task_id === task.task_id) === index, - ), + const submission_data = submissions_by_exercise.data + const subs_by_exercise = useMemo(() => { + if (!submission_data) { + return null } - }) + return submission_data.exercises.map((exer) => { + return { + exercise_id: exer.id, + exercise_name: exer.name, + exercise_tasks: submission_data.exercise_tasks.task_gradings + .filter((grading) => grading.exercise_id == exer.id) + .map((grading) => { + const answer = submission_data.exercise_tasks.task_submissions + .filter((sub) => sub.exercise_task_grading_id == grading.id) + .sort((a, b) => parseISO(b.created_at).getTime() - parseISO(a.created_at).getTime()) + .filter( + (task_asnwer, index, array) => + array.findIndex((el) => el.exercise_task_id === task_asnwer.exercise_task_id) === + index, + )[0] + const publicSpec = submission_data.exercise_tasks.exercise_tasks.find( + (task) => task.id == grading.exercise_task_id, + )?.public_spec + return { + task_id: grading.exercise_task_id, + public_spec: publicSpec, + user_answer: answer, + grading: grading, + } + }) + .sort( + (a, b) => + a.task_id.localeCompare(b.task_id) || + parseISO(b.grading.created_at).getTime() - parseISO(a.grading.created_at).getTime(), + ) + .filter( + (task, index, array) => array.findIndex((el) => el.task_id === task.task_id) === index, + ), + } + }) + }, [submission_data]) + const user_vars = useMemo(() => { + if (!submission_data) { + return null + } + const res: UserVariablesMap = {} + submission_data?.user_variables.forEach( + (item) => (res[item.variable_key] = item.variable_value), + ) + return res + }, [submission_data]) if (!url || url.trim() === "") { return } - if (!userInfo.data || !subs_by_exercise) { - return <> + if (!userInfo.data || !subs_by_exercise || !courseInfo.data) { + return null } const postThisStateToIFrame: CustomViewIframeState = { // eslint-disable-next-line i18next/no-literal-string view_type: "custom-view", + course_name: courseInfo.data?.name, user_information: { user_id: userInfo.data.user_id, first_name: userInfo.data.first_name, last_name: userInfo.data.last_name, }, + user_variables: user_vars, data: { submissions_by_exercise: subs_by_exercise, }, diff --git a/services/course-material/src/services/backend.ts b/services/course-material/src/services/backend.ts index 7d1acb71f0bf..78367ba7ad39 100644 --- a/services/course-material/src/services/backend.ts +++ b/services/course-material/src/services/backend.ts @@ -82,6 +82,7 @@ import { isObjectMap, isString, isUnion, + isUuid, validateResponse, } from "../shared-module/utils/fetching" @@ -614,13 +615,12 @@ export const fetchModuleIdByChapterId = async (chapter_id: string) => { const res = await courseMaterialClient.get(`/course-modules/chapter/${chapter_id}`, { responseType: "json", }) - return validateResponse(res, isString) + return validateResponse(res, isUuid) } export const fetchDefaultModuleIdByCourseId = async (course_id: string) => { - console.log("in here") const res = await courseMaterialClient.get(`/course-modules/course/${course_id}`, { responseType: "json", }) - return validateResponse(res, isString) + return validateResponse(res, isUuid) } diff --git a/services/headless-lms/models/.sqlx/query-0646a9e32998f1c57327702deb4e592440904801cabf1b200ee96e7917a4ccd3.json b/services/headless-lms/models/.sqlx/query-0646a9e32998f1c57327702deb4e592440904801cabf1b200ee96e7917a4ccd3.json deleted file mode 100644 index 4f1cab942473..000000000000 --- a/services/headless-lms/models/.sqlx/query-0646a9e32998f1c57327702deb4e592440904801cabf1b200ee96e7917a4ccd3.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\nSELECT id,\n created_at,\n exercise_slide_submission_id,\n exercise_slide_id,\n exercise_task_id,\n exercise_task_grading_id,\n data_json\nFROM exercise_task_submissions g\nWHERE deleted_at IS NULL\nAND g.exercise_task_id IN (\n SELECT distinct (t.id)\n FROM exercise_tasks t\n WHERE deleted_at IS NULL\n AND t.exercise_slide_id IN (\n SELECT s.exercise_slide_id\n FROM exercise_slide_submissions s\n WHERE s.user_id = $1\n AND s.course_instance_id = $4\n AND deleted_at IS NULL\n AND s.exercise_id IN (\n SELECT id\n FROM exercises e\n WHERE exercise_type = $2\n AND deleted_at IS NULL\n AND e.chapter_id IN (\n SELECT id\n FROM chapters c\n WHERE c.course_module_id = $3\n AND deleted_at IS NULL\n )\n )\n )\n )\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 2, - "name": "exercise_slide_submission_id", - "type_info": "Uuid" - }, - { - "ordinal": 3, - "name": "exercise_slide_id", - "type_info": "Uuid" - }, - { - "ordinal": 4, - "name": "exercise_task_id", - "type_info": "Uuid" - }, - { - "ordinal": 5, - "name": "exercise_task_grading_id", - "type_info": "Uuid" - }, - { - "ordinal": 6, - "name": "data_json", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": ["Uuid", "Text", "Uuid", "Uuid"] - }, - "nullable": [false, false, false, false, false, true, true] - }, - "hash": "0646a9e32998f1c57327702deb4e592440904801cabf1b200ee96e7917a4ccd3" -} diff --git a/services/headless-lms/models/.sqlx/query-17c516418ede7b0240835c473c0cfa2c46cd4e24dfb1e5a064802d3351d72b85.json b/services/headless-lms/models/.sqlx/query-17c516418ede7b0240835c473c0cfa2c46cd4e24dfb1e5a064802d3351d72b85.json new file mode 100644 index 000000000000..9ceb542f4c7c --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-17c516418ede7b0240835c473c0cfa2c46cd4e24dfb1e5a064802d3351d72b85.json @@ -0,0 +1,48 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT g.id,\n g.created_at,\n g.exercise_slide_submission_id,\n g.exercise_slide_id,\n g.exercise_task_id,\n g.exercise_task_grading_id,\n g.data_json\n FROM exercise_task_submissions g\n JOIN exercise_tasks et ON et.id = g.exercise_task_id\n JOIN exercise_slide_submissions ess ON ess.id = g.exercise_slide_submission_id\n JOIN exercises e ON e.id = ess.exercise_id\n JOIN chapters c ON c.id = e.chapter_id\n WHERE ess.user_id = $1\n AND ess.course_instance_id = $2\n AND et.exercise_type = $3\n AND c.course_module_id = $4\n AND g.deleted_at IS NULL\n AND et.deleted_at IS NULL\n AND ess.deleted_at IS NULL\n AND e.deleted_at IS NULL\n AND c.deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "exercise_slide_submission_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "exercise_slide_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "exercise_task_id", + "type_info": "Uuid" + }, + { + "ordinal": 5, + "name": "exercise_task_grading_id", + "type_info": "Uuid" + }, + { + "ordinal": 6, + "name": "data_json", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": ["Uuid", "Uuid", "Text", "Uuid"] + }, + "nullable": [false, false, false, false, false, true, true] + }, + "hash": "17c516418ede7b0240835c473c0cfa2c46cd4e24dfb1e5a064802d3351d72b85" +} diff --git a/services/headless-lms/models/.sqlx/query-2357564991d128aeeb5db65686276b24c3aff7c1914cd2d4ad614c3eed3c449e.json b/services/headless-lms/models/.sqlx/query-58e5aac7861f558385ecf507e49afa25c340c5c83d6710b80b98436a6cc4c608.json similarity index 81% rename from services/headless-lms/models/.sqlx/query-2357564991d128aeeb5db65686276b24c3aff7c1914cd2d4ad614c3eed3c449e.json rename to services/headless-lms/models/.sqlx/query-58e5aac7861f558385ecf507e49afa25c340c5c83d6710b80b98436a6cc4c608.json index 2c8db664269c..97376626f9a8 100644 --- a/services/headless-lms/models/.sqlx/query-2357564991d128aeeb5db65686276b24c3aff7c1914cd2d4ad614c3eed3c449e.json +++ b/services/headless-lms/models/.sqlx/query-58e5aac7861f558385ecf507e49afa25c340c5c83d6710b80b98436a6cc4c608.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nSELECT ex.*\nFROM exercises ex\n JOIN exercise_slides slides ON ex.id = slides.exercise_id\n JOIN exercise_tasks tasks ON slides.id = tasks.exercise_slide_id\n JOIN chapters c ON ex.chapter_id = c.id\nwhere tasks.exercise_type = $1\n AND c.course_module_id = $2\n AND ex.deleted_at IS NULL\n AND tasks.deleted_at IS NULL\n and c.deleted_at IS NULL\n and slides.deleted_at IS NULL\n ", + "query": "\nSELECT DISTINCT(ex.*)\nFROM exercises ex\n JOIN exercise_slides slides ON ex.id = slides.exercise_id\n JOIN exercise_tasks tasks ON slides.id = tasks.exercise_slide_id\n JOIN chapters c ON ex.chapter_id = c.id\nwhere tasks.exercise_type = $1\n AND c.course_module_id = $2\n AND ex.deleted_at IS NULL\n AND tasks.deleted_at IS NULL\n and c.deleted_at IS NULL\n and slides.deleted_at IS NULL\n ", "describe": { "columns": [ { @@ -118,5 +118,5 @@ true ] }, - "hash": "2357564991d128aeeb5db65686276b24c3aff7c1914cd2d4ad614c3eed3c449e" + "hash": "58e5aac7861f558385ecf507e49afa25c340c5c83d6710b80b98436a6cc4c608" } diff --git a/services/headless-lms/models/.sqlx/query-8c1f861182b005b8045c6d6d096216c9da8fb3728d99c6711dff808431d0d8d6.json b/services/headless-lms/models/.sqlx/query-8c1f861182b005b8045c6d6d096216c9da8fb3728d99c6711dff808431d0d8d6.json new file mode 100644 index 000000000000..d7b43f11c76e --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-8c1f861182b005b8045c6d6d096216c9da8fb3728d99c6711dff808431d0d8d6.json @@ -0,0 +1,63 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT *\nFROM user_course_instance_exercise_service_variables\nWHERE deleted_at IS NULL\n AND user_id = $1\n AND course_instance_id = $2\n AND exercise_service_slug = $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_service_slug", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 6, + "name": "course_instance_id", + "type_info": "Uuid" + }, + { + "ordinal": 7, + "name": "exam_id", + "type_info": "Uuid" + }, + { + "ordinal": 8, + "name": "variable_key", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "variable_value", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": ["Uuid", "Uuid", "Text"] + }, + "nullable": [false, false, false, true, false, false, true, true, false, false] + }, + "hash": "8c1f861182b005b8045c6d6d096216c9da8fb3728d99c6711dff808431d0d8d6" +} diff --git a/services/headless-lms/models/.sqlx/query-bdf6c7a6ff6ecd02bf62a5e8ec4e22fd2d87fecbcab0830a647ba5676cd75afc.json b/services/headless-lms/models/.sqlx/query-bdf6c7a6ff6ecd02bf62a5e8ec4e22fd2d87fecbcab0830a647ba5676cd75afc.json new file mode 100644 index 000000000000..84aeeb76974e --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-bdf6c7a6ff6ecd02bf62a5e8ec4e22fd2d87fecbcab0830a647ba5676cd75afc.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT ON (a.research_form_question_id)\n q.course_id,\n q.research_consent_form_id,\n a.research_form_question_id,\n q.question,\n a.user_id,\n a.research_consent,\n a.created_at,\n a.updated_at\n FROM course_specific_consent_form_answers a\n LEFT JOIN course_specific_consent_form_questions q ON a.research_form_question_id = q.id\n WHERE a.course_id = $1\n AND a.deleted_at IS NULL\n AND q.deleted_at IS NULL\n ORDER BY a.research_form_question_id, a.updated_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "course_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "research_consent_form_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "research_form_question_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "question", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 5, + "name": "research_consent", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": ["Uuid"] + }, + "nullable": [false, false, false, false, false, false, false, false] + }, + "hash": "bdf6c7a6ff6ecd02bf62a5e8ec4e22fd2d87fecbcab0830a647ba5676cd75afc" +} diff --git a/services/headless-lms/models/.sqlx/query-c7644233ed24e991b9308419adaf7befa51865d17fc22b855953be5a17ed5d8a.json b/services/headless-lms/models/.sqlx/query-c7644233ed24e991b9308419adaf7befa51865d17fc22b855953be5a17ed5d8a.json deleted file mode 100644 index 5c6b3939cb9e..000000000000 --- a/services/headless-lms/models/.sqlx/query-c7644233ed24e991b9308419adaf7befa51865d17fc22b855953be5a17ed5d8a.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\nSELECT distinct (t.id),\n t.public_spec,\n t.order_number\nfrom exercise_tasks t\nwhere t.exercise_slide_id in (\n SELECT id\n from exercise_slides s\n where s.exercise_id in (\n SELECT id\n from exercises e\n where exercise_type = $1\n AND e.chapter_id in (\n SELECT id\n from chapters c\n where c.course_module_id = $2\n )\n )\n )\n AND deleted_at IS NULL;\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "public_spec", - "type_info": "Jsonb" - }, - { - "ordinal": 2, - "name": "order_number", - "type_info": "Int4" - } - ], - "parameters": { - "Left": ["Text", "Uuid"] - }, - "nullable": [false, true, false] - }, - "hash": "c7644233ed24e991b9308419adaf7befa51865d17fc22b855953be5a17ed5d8a" -} diff --git a/services/headless-lms/models/.sqlx/query-d1ceba14b45d756bfb0ad9935533e111e2adda9ef1b3be47205fc9086020a020.json b/services/headless-lms/models/.sqlx/query-d1ceba14b45d756bfb0ad9935533e111e2adda9ef1b3be47205fc9086020a020.json new file mode 100644 index 000000000000..d97807fa696d --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-d1ceba14b45d756bfb0ad9935533e111e2adda9ef1b3be47205fc9086020a020.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT distinct (et.id),\n et.public_spec,\n et.order_number\n FROM exercise_tasks et\n JOIN exercise_slides es ON es.id = et.exercise_slide_id\n JOIN exercises e ON es.exercise_id = e.id JOIN chapters c ON e.chapter_id = c.id\n WHERE et.exercise_type = $1 AND c.course_module_id = $2\n AND et.deleted_at IS NULL\n AND es.deleted_at IS NULL\n AND e.deleted_at IS NULL\n AND c.deleted_at IS NULL;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "public_spec", + "type_info": "Jsonb" + }, + { + "ordinal": 2, + "name": "order_number", + "type_info": "Int4" + } + ], + "parameters": { + "Left": ["Text", "Uuid"] + }, + "nullable": [false, true, false] + }, + "hash": "d1ceba14b45d756bfb0ad9935533e111e2adda9ef1b3be47205fc9086020a020" +} diff --git a/services/headless-lms/models/src/exercise_task_submissions.rs b/services/headless-lms/models/src/exercise_task_submissions.rs index c96089963666..bbd045d5b768 100644 --- a/services/headless-lms/models/src/exercise_task_submissions.rs +++ b/services/headless-lms/models/src/exercise_task_submissions.rs @@ -496,7 +496,7 @@ pub async fn get_user_custom_view_exercise_tasks_by_module_and_exercise_type( Ok(res) } -// get all submissions for user and course module and exercise type +/// get all submissions for user and course module and exercise type pub async fn get_user_exersice_task_submissions_by_course_module_and_exercise_type( conn: &mut PgConnection, user_id: Uuid, @@ -507,44 +507,32 @@ pub async fn get_user_exersice_task_submissions_by_course_module_and_exercise_ty let res: Vec = sqlx::query_as!( CustomViewExerciseTaskSubmission, r#" -SELECT id, - created_at, - exercise_slide_submission_id, - exercise_slide_id, - exercise_task_id, - exercise_task_grading_id, - data_json -FROM exercise_task_submissions g -WHERE deleted_at IS NULL -AND g.exercise_task_id IN ( - SELECT distinct (t.id) - FROM exercise_tasks t - WHERE deleted_at IS NULL - AND t.exercise_slide_id IN ( - SELECT s.exercise_slide_id - FROM exercise_slide_submissions s - WHERE s.user_id = $1 - AND s.course_instance_id = $4 - AND deleted_at IS NULL - AND s.exercise_id IN ( - SELECT id - FROM exercises e - WHERE exercise_type = $2 - AND deleted_at IS NULL - AND e.chapter_id IN ( - SELECT id - FROM chapters c - WHERE c.course_module_id = $3 - AND deleted_at IS NULL - ) - ) - ) - ) + SELECT g.id, + g.created_at, + g.exercise_slide_submission_id, + g.exercise_slide_id, + g.exercise_task_id, + g.exercise_task_grading_id, + g.data_json + FROM exercise_task_submissions g + JOIN exercise_tasks et ON et.id = g.exercise_task_id + JOIN exercise_slide_submissions ess ON ess.id = g.exercise_slide_submission_id + JOIN exercises e ON e.id = ess.exercise_id + JOIN chapters c ON c.id = e.chapter_id + WHERE ess.user_id = $1 + AND ess.course_instance_id = $2 + AND et.exercise_type = $3 + AND c.course_module_id = $4 + AND g.deleted_at IS NULL + AND et.deleted_at IS NULL + AND ess.deleted_at IS NULL + AND e.deleted_at IS NULL + AND c.deleted_at IS NULL "#, user_id, + course_instance_id, exercise_type, - module_id, - course_instance_id + module_id ) .fetch_all(conn) .await?; diff --git a/services/headless-lms/models/src/exercise_tasks.rs b/services/headless-lms/models/src/exercise_tasks.rs index 654902b71ca9..c5594c39aa00 100644 --- a/services/headless-lms/models/src/exercise_tasks.rs +++ b/services/headless-lms/models/src/exercise_tasks.rs @@ -496,25 +496,17 @@ pub async fn get_all_exercise_tasks_by_module_and_exercise_type( let res: Vec = sqlx::query_as!( CustomViewExerciseTaskSpec, r#" -SELECT distinct (t.id), - t.public_spec, - t.order_number -from exercise_tasks t -where t.exercise_slide_id in ( - SELECT id - from exercise_slides s - where s.exercise_id in ( - SELECT id - from exercises e - where exercise_type = $1 - AND e.chapter_id in ( - SELECT id - from chapters c - where c.course_module_id = $2 - ) - ) - ) - AND deleted_at IS NULL; + SELECT distinct (et.id), + et.public_spec, + et.order_number + FROM exercise_tasks et + JOIN exercise_slides es ON es.id = et.exercise_slide_id + JOIN exercises e ON es.exercise_id = e.id JOIN chapters c ON e.chapter_id = c.id + WHERE et.exercise_type = $1 AND c.course_module_id = $2 + AND et.deleted_at IS NULL + AND es.deleted_at IS NULL + AND e.deleted_at IS NULL + AND c.deleted_at IS NULL; "#, exercise_type, module_id diff --git a/services/headless-lms/models/src/exercises.rs b/services/headless-lms/models/src/exercises.rs index 6502e318c07d..984f5038d1ff 100644 --- a/services/headless-lms/models/src/exercises.rs +++ b/services/headless-lms/models/src/exercises.rs @@ -885,7 +885,7 @@ pub async fn get_exercises_by_module_containing_exercise_type( let res: Vec = sqlx::query_as!( Exercise, r#" -SELECT ex.* +SELECT DISTINCT(ex.*) FROM exercises ex JOIN exercise_slides slides ON ex.id = slides.exercise_id JOIN exercise_tasks tasks ON slides.id = tasks.exercise_slide_id diff --git a/services/headless-lms/models/src/library/custom_view_exercises.rs b/services/headless-lms/models/src/library/custom_view_exercises.rs index 682271165fa5..9012734c3235 100644 --- a/services/headless-lms/models/src/library/custom_view_exercises.rs +++ b/services/headless-lms/models/src/library/custom_view_exercises.rs @@ -1,4 +1,7 @@ -use crate::{exercises::Exercise, prelude::*}; +use crate::{ + exercises::Exercise, prelude::*, + user_course_instance_exercise_service_variables::UserCourseInstanceExerciseServiceVariable, +}; #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] #[cfg_attr(feature = "ts_rs", derive(TS))] @@ -36,6 +39,7 @@ pub struct CustomViewExerciseTaskSubmission { pub struct CustomViewExerciseSubmissions { pub exercise_tasks: CustomViewExerciseTasks, pub exercises: Vec, + pub user_variables: Vec, } #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] diff --git a/services/headless-lms/models/src/research_forms.rs b/services/headless-lms/models/src/research_forms.rs index 8a2038a5537c..39a4e8938577 100644 --- a/services/headless-lms/models/src/research_forms.rs +++ b/services/headless-lms/models/src/research_forms.rs @@ -1,3 +1,5 @@ +use futures::Stream; + use crate::prelude::*; #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] @@ -177,6 +179,45 @@ AND deleted_at IS NULL Ok(form_res) } +pub struct ExportedCourseResearchFormQustionAnswer { + pub course_id: Uuid, + pub research_consent_form_id: Uuid, + pub research_form_question_id: Uuid, + pub question: String, + pub user_id: Uuid, + pub research_consent: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +pub fn stream_course_research_form_user_answers( + conn: &mut PgConnection, + course_id: Uuid, +) -> impl Stream> + '_ { + sqlx::query_as!( + ExportedCourseResearchFormQustionAnswer, + r#" + SELECT DISTINCT ON (a.research_form_question_id) + q.course_id, + q.research_consent_form_id, + a.research_form_question_id, + q.question, + a.user_id, + a.research_consent, + a.created_at, + a.updated_at + FROM course_specific_consent_form_answers a + LEFT JOIN course_specific_consent_form_questions q ON a.research_form_question_id = q.id + WHERE a.course_id = $1 + AND a.deleted_at IS NULL + AND q.deleted_at IS NULL + ORDER BY a.research_form_question_id, a.updated_at DESC + "#, + course_id + ) + .fetch(conn) +} + pub async fn upsert_research_form_anwser( conn: &mut PgConnection, course_id: Uuid, diff --git a/services/headless-lms/models/src/user_course_instance_exercise_service_variables.rs b/services/headless-lms/models/src/user_course_instance_exercise_service_variables.rs index 22fa3c23f8bc..3d23a8778245 100644 --- a/services/headless-lms/models/src/user_course_instance_exercise_service_variables.rs +++ b/services/headless-lms/models/src/user_course_instance_exercise_service_variables.rs @@ -46,6 +46,31 @@ WHERE deleted_at IS NULL Ok(res) } +pub async fn get_all_user_variables_for_user_and_course_instance_and_exercise_type( + conn: &mut PgConnection, + user_id: Uuid, + course_instance_id: Uuid, + exercise_type: &str, +) -> ModelResult> { + let res = sqlx::query_as!( + UserCourseInstanceExerciseServiceVariable, + r#" +SELECT * +FROM user_course_instance_exercise_service_variables +WHERE deleted_at IS NULL + AND user_id = $1 + AND course_instance_id = $2 + AND exercise_service_slug = $3; + "#, + user_id, + course_instance_id, + exercise_type + ) + .fetch_all(conn) + .await?; + Ok(res) +} + pub(crate) async fn insert_after_exercise_task_graded( conn: &mut PgConnection, set_user_variables: &Option>, diff --git a/services/headless-lms/server/src/controllers/course_material/course_modules.rs b/services/headless-lms/server/src/controllers/course_material/course_modules.rs index 4f7fd0a281eb..51bcad3d5d72 100644 --- a/services/headless-lms/server/src/controllers/course_material/course_modules.rs +++ b/services/headless-lms/server/src/controllers/course_material/course_modules.rs @@ -1,9 +1,10 @@ +use headless_lms_models::user_course_instance_exercise_service_variables; use models::{course_modules, library::custom_view_exercises::CustomViewExerciseSubmissions}; use crate::{domain::authorization::skip_authorize, prelude::*}; /** -GET `/api/v0/course-material/course-modules/chapter/:chapter_id/` +GET `/api/v0/course-material/course-modules/chapter/:chapter_id` Returns course module id based on chapter. */ @@ -20,7 +21,7 @@ async fn get_course_module_id_by_chapter_id( } /** -GET `/api/v0/course-material/course-modules/course/:course_instance_id/` +GET `/api/v0/course-material/course-modules/course/:course_instance_id` Returns course module id based on chapter. */ @@ -38,7 +39,7 @@ async fn get_default_course_module_id_by_course_id( } /** -GET `/api/v0/course-material/course-modules/:course_module_id/exercise-tasks/:exercise_type/:course_instance_id/` +GET `/api/v0/course-material/course-modules/:course_module_id/exercise-tasks/:exercise_type/:course_instance_id` Returns exercise submissions for user to be used in en exercise service Custom view. */ @@ -64,11 +65,13 @@ async fn get_user_course_module_exercises_by_exercise_type( course_module_id, ) .await?; + let user_variables = + user_course_instance_exercise_service_variables::get_all_user_variables_for_user_and_course_instance_and_exercise_type(&mut conn, user.id, course_instance_id, &exercise_type).await?; let token = skip_authorize(); - let res = CustomViewExerciseSubmissions { exercise_tasks, exercises, + user_variables, }; token.authorized_ok(web::Json(res)) diff --git a/services/headless-lms/server/src/controllers/main_frontend/courses.rs b/services/headless-lms/server/src/controllers/main_frontend/courses.rs index 02c9bbd09a7a..1fa2202456e8 100644 --- a/services/headless-lms/server/src/controllers/main_frontend/courses.rs +++ b/services/headless-lms/server/src/controllers/main_frontend/courses.rs @@ -32,6 +32,7 @@ use crate::{ domain::{ csv_export::{ course_instance_export::CourseInstancesExportOperation, + course_research_form_questions_answers_export::CourseResearchFormExportOperation, exercise_tasks_export::CourseExerciseTasksExportOperation, general_export, submissions::CourseSubmissionExportOperation, users_export::UsersExportOperation, }, @@ -1057,6 +1058,44 @@ pub async fn course_instances_export( .await } +/** +GET `/api/v0/main-frontend/courses/${course.id}/export-course-user-consents` + +gets SCV course specific research form questions and user answers for course +*/ +#[instrument(skip(pool))] +pub async fn course_consent_form_answers_export( + course_id: web::Path, + pool: web::Data, + user: AuthUser, +) -> ControllerResult { + let mut conn = pool.acquire().await?; + + let token = authorize( + &mut conn, + Act::Teach, + Some(user.id), + Res::Course(*course_id), + ) + .await?; + + let course = models::courses::get_course(&mut conn, *course_id).await?; + + general_export( + pool, + &format!( + "attachment; filename=\"Course: {} - User Consents {}.csv\"", + course.name, + Utc::now().format("%Y-%m-%d") + ), + CourseResearchFormExportOperation { + course_id: *course_id, + }, + token, + ) + .await +} + /** GET `/api/v0/main-frontend/courses/${course.id}/page-visit-datum-summary` - Gets aggregated statistics for page visits for the course. */ @@ -1380,6 +1419,10 @@ pub fn _add_routes(cfg: &mut ServiceConfig) { "/{course_id}/export-course-instances", web::get().to(course_instances_export), ) + .route( + "/{course_id}/export-course-user-consents", + web::get().to(course_consent_form_answers_export), + ) .route( "/{course_id}/page-visit-datum-summary", web::get().to(get_page_visit_datum_summary), diff --git a/services/headless-lms/server/src/domain/csv_export/course_research_form_questions_answers_export.rs b/services/headless-lms/server/src/domain/csv_export/course_research_form_questions_answers_export.rs new file mode 100644 index 000000000000..ec43045ccf0b --- /dev/null +++ b/services/headless-lms/server/src/domain/csv_export/course_research_form_questions_answers_export.rs @@ -0,0 +1,83 @@ +use anyhow::Result; +use bytes::Bytes; + +use futures::TryStreamExt; +use headless_lms_models::research_forms; + +use crate::domain::csv_export::CsvWriter; +use async_trait::async_trait; + +use sqlx::PgConnection; +use std::io::Write; +use tokio::sync::mpsc::UnboundedSender; + +use uuid::Uuid; + +use crate::prelude::*; + +use super::{ + super::authorization::{AuthorizationToken, AuthorizedResponse}, + CSVExportAdapter, CsvExportDataLoader, +}; + +pub struct CourseResearchFormExportOperation { + pub course_id: Uuid, +} + +#[async_trait] +impl CsvExportDataLoader for CourseResearchFormExportOperation { + async fn load_data( + &self, + sender: UnboundedSender, ControllerError>>, + conn: &mut PgConnection, + token: AuthorizationToken, + ) -> anyhow::Result { + export_course_research_form_question_user_answers( + &mut *conn, + self.course_id, + CSVExportAdapter { + sender, + authorization_token: token, + }, + ) + .await + } +} + +pub async fn export_course_research_form_question_user_answers( + conn: &mut PgConnection, + course_id: Uuid, + writer: W, +) -> Result +where + W: Write + Send + 'static, +{ + let headers = IntoIterator::into_iter([ + "course_id".to_string(), + "research_consent_form_id".to_string(), + "research_form_question_id".to_string(), + "question".to_string(), + "user_id".to_string(), + "research_consent".to_string(), + "created_at".to_string(), + "updated_at".to_string(), + ]); + let mut stream = research_forms::stream_course_research_form_user_answers(conn, course_id); + + let writer = CsvWriter::new_with_initialized_headers(writer, headers).await?; + while let Some(next) = stream.try_next().await? { + let csv_row = vec![ + next.course_id.to_string(), + next.research_consent_form_id.to_string(), + next.research_form_question_id.to_string(), + next.question.to_string(), + next.user_id.to_string(), + next.research_consent.to_string(), + next.created_at.to_rfc3339(), + next.updated_at.to_rfc3339(), + ]; + writer.write_record(csv_row); + } + let writer = writer.finish().await?; + Ok(writer) +} diff --git a/services/headless-lms/server/src/domain/csv_export/mod.rs b/services/headless-lms/server/src/domain/csv_export/mod.rs index 5693d783abee..d7eff61070e8 100644 --- a/services/headless-lms/server/src/domain/csv_export/mod.rs +++ b/services/headless-lms/server/src/domain/csv_export/mod.rs @@ -1,4 +1,5 @@ pub mod course_instance_export; +pub mod course_research_form_questions_answers_export; pub mod exercise_tasks_export; pub mod points; pub mod submissions; diff --git a/services/main-frontend/src/components/page-specific/manage/courses/id/index/ManageCourse.tsx b/services/main-frontend/src/components/page-specific/manage/courses/id/index/ManageCourse.tsx index 3efd286093fd..39e34d3cfacf 100644 --- a/services/main-frontend/src/components/page-specific/manage/courses/id/index/ManageCourse.tsx +++ b/services/main-frontend/src/components/page-specific/manage/courses/id/index/ManageCourse.tsx @@ -270,6 +270,14 @@ const ManageCourse: React.FC> = ({ course, refetc {t("link-export-course-instances")} +
  • + + {t("link-export-course-user-consents")} + +
  • diff --git a/shared-module/src/bindings.guard.ts b/shared-module/src/bindings.guard.ts index 2419586c3a97..2db8b4956237 100644 --- a/shared-module/src/bindings.guard.ts +++ b/shared-module/src/bindings.guard.ts @@ -1838,7 +1838,11 @@ export function isCustomViewExerciseSubmissions( ((typedObj !== null && typeof typedObj === "object") || typeof typedObj === "function") && (isCustomViewExerciseTasks(typedObj["exercise_tasks"]) as boolean) && Array.isArray(typedObj["exercises"]) && - typedObj["exercises"].every((e: any) => isExercise(e) as boolean) + typedObj["exercises"].every((e: any) => isExercise(e) as boolean) && + Array.isArray(typedObj["user_variables"]) && + typedObj["user_variables"].every( + (e: any) => isUserCourseInstanceExerciseServiceVariable(e) as boolean, + ) ) } diff --git a/shared-module/src/bindings.ts b/shared-module/src/bindings.ts index 80a38bd033e3..8ef9c55e721a 100644 --- a/shared-module/src/bindings.ts +++ b/shared-module/src/bindings.ts @@ -956,6 +956,7 @@ export interface CourseInstanceCompletionSummary { export interface CustomViewExerciseSubmissions { exercise_tasks: CustomViewExerciseTasks exercises: Array + user_variables: Array } export interface CustomViewExerciseTaskGrading { diff --git a/shared-module/src/exercise-service-protocol-types.guard.ts b/shared-module/src/exercise-service-protocol-types.guard.ts index 2bf8a38a0e4c..2e0cca0664a6 100644 --- a/shared-module/src/exercise-service-protocol-types.guard.ts +++ b/shared-module/src/exercise-service-protocol-types.guard.ts @@ -242,6 +242,7 @@ export function isCustomViewIframeState(obj: unknown): obj is CustomViewIframeSt (typeof typedObj["user_variables"] === "undefined" || typedObj["user_variables"] === null || (isUserVariablesMap(typedObj["user_variables"]) as boolean)) && + typeof typedObj["course_name"] === "string" && ((typedObj["data"] !== null && typeof typedObj["data"] === "object") || typeof typedObj["data"] === "function") && Array.isArray(typedObj["data"]["submissions_by_exercise"]) && diff --git a/shared-module/src/exercise-service-protocol-types.ts b/shared-module/src/exercise-service-protocol-types.ts index b71c9fc81972..3d4468b6522d 100644 --- a/shared-module/src/exercise-service-protocol-types.ts +++ b/shared-module/src/exercise-service-protocol-types.ts @@ -121,9 +121,9 @@ export type ExerciseEditorIframeState = { export type CustomViewIframeState = { view_type: "custom-view" - //exercise_task_id: string // confirm this!! user_information: UserInfo user_variables?: UserVariablesMap | null + course_name: string data: { submissions_by_exercise: Array<{ exercise_id: string diff --git a/shared-module/src/locales/en/main-frontend.json b/shared-module/src/locales/en/main-frontend.json index 658e0cb99200..fac22e92a7e1 100644 --- a/shared-module/src/locales/en/main-frontend.json +++ b/shared-module/src/locales/en/main-frontend.json @@ -357,6 +357,7 @@ "link-exercises": "Exercises", "link-export-completions": "Export completions as CSV", "link-export-course-instances": "Export course instances as CSV", + "link-export-course-user-consents": "Export course research consent form answers as CSV", "link-export-exercise-tasks": "Export exercise-tasks as CSV", "link-export-points": "Export points as CSV", "link-export-submissions": "Export submissions (exercise tasks) as CSV", diff --git a/shared-module/src/locales/fi/main-frontend.json b/shared-module/src/locales/fi/main-frontend.json index 0e10633a553f..84a7a4a00f45 100644 --- a/shared-module/src/locales/fi/main-frontend.json +++ b/shared-module/src/locales/fi/main-frontend.json @@ -361,6 +361,7 @@ "link-exercises": "Tehtävät", "link-export-completions": "Lataa suoritukset CSV-muodossa", "link-export-course-instances": "Lataa kurssiversiot CSV-muodossa", + "link-export-course-user-consents": "Lataa kurssin tutkimussuostumukset CSV-muodossa", "link-export-exercise-tasks": "Lataa tehtävät CSV-muodossa", "link-export-points": "Vie pisteet tiedostoon", "link-export-submissions": "Lataa palautukset (exercise tasks) CSV-muodossa",