From 0ff8f31731d6224175133b7cccf6f40fba64c2ca Mon Sep 17 00:00:00 2001 From: Anastasia Diseth Date: Tue, 19 Mar 2024 09:55:56 +0200 Subject: [PATCH 1/8] add user variables to custom view --- .../CustomViewIframe.tsx | 95 ++++++++++++------- .../course-material/src/services/backend.ts | 1 - ...fa25c340c5c83d6710b80b98436a6cc4c608.json} | 4 +- ...16c9da8fb3728d99c6711dff808431d0d8d6.json} | 46 ++++----- services/headless-lms/models/src/exercises.rs | 2 +- .../src/library/custom_view_exercises.rs | 6 +- ...rse_instance_exercise_service_variables.rs | 25 +++++ .../course_material/course_modules.rs | 5 +- 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 +- 12 files changed, 125 insertions(+), 69 deletions(-) rename services/headless-lms/models/.sqlx/{query-2357564991d128aeeb5db65686276b24c3aff7c1914cd2d4ad614c3eed3c449e.json => query-58e5aac7861f558385ecf507e49afa25c340c5c83d6710b80b98436a6cc4c608.json} (81%) rename services/headless-lms/models/.sqlx/{query-277f66811c6e7fc3d4e7dcc6f8b0f37f6e1c3bace558d4b5a762297c22a60ba5.json => query-8c1f861182b005b8045c6d6d096216c9da8fb3728d99c6711dff808431d0d8d6.json} (51%) 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 161839043144..2641c3767641 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,52 +65,75 @@ 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 = { 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 a0dc8ae32bba..6e1d4be67ae2 100644 --- a/services/course-material/src/services/backend.ts +++ b/services/course-material/src/services/backend.ts @@ -584,7 +584,6 @@ export const fetchModuleIdByChapterId = async (chapter_id: string) => { } export const fetchDefaultModuleIdByCourseId = async (course_id: string) => { - console.log("in here") const res = await courseMaterialClient.get(`/course-modules/course/${course_id}`, { responseType: "json", }) 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-277f66811c6e7fc3d4e7dcc6f8b0f37f6e1c3bace558d4b5a762297c22a60ba5.json b/services/headless-lms/models/.sqlx/query-8c1f861182b005b8045c6d6d096216c9da8fb3728d99c6711dff808431d0d8d6.json similarity index 51% rename from services/headless-lms/models/.sqlx/query-277f66811c6e7fc3d4e7dcc6f8b0f37f6e1c3bace558d4b5a762297c22a60ba5.json rename to services/headless-lms/models/.sqlx/query-8c1f861182b005b8045c6d6d096216c9da8fb3728d99c6711dff808431d0d8d6.json index fed3ead3b412..d7b43f11c76e 100644 --- a/services/headless-lms/models/.sqlx/query-277f66811c6e7fc3d4e7dcc6f8b0f37f6e1c3bace558d4b5a762297c22a60ba5.json +++ b/services/headless-lms/models/.sqlx/query-8c1f861182b005b8045c6d6d096216c9da8fb3728d99c6711dff808431d0d8d6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nSELECT *\nFROM exercise_tasks\nWHERE exercise_slide_id = $1\n AND deleted_at IS NULL\n AND exercise_type = $2\n ", + "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": [ { @@ -20,54 +20,44 @@ }, { "ordinal": 3, - "name": "exercise_type", - "type_info": "Varchar" + "name": "deleted_at", + "type_info": "Timestamptz" }, { "ordinal": 4, - "name": "assignment", - "type_info": "Jsonb" + "name": "exercise_service_slug", + "type_info": "Varchar" }, { "ordinal": 5, - "name": "deleted_at", - "type_info": "Timestamptz" + "name": "user_id", + "type_info": "Uuid" }, { "ordinal": 6, - "name": "private_spec", - "type_info": "Jsonb" + "name": "course_instance_id", + "type_info": "Uuid" }, { "ordinal": 7, - "name": "public_spec", - "type_info": "Jsonb" + "name": "exam_id", + "type_info": "Uuid" }, { "ordinal": 8, - "name": "model_solution_spec", - "type_info": "Jsonb" + "name": "variable_key", + "type_info": "Varchar" }, { "ordinal": 9, - "name": "copied_from", - "type_info": "Uuid" - }, - { - "ordinal": 10, - "name": "exercise_slide_id", - "type_info": "Uuid" - }, - { - "ordinal": 11, - "name": "order_number", - "type_info": "Int4" + "name": "variable_value", + "type_info": "Jsonb" } ], "parameters": { - "Left": ["Uuid", "Text"] + "Left": ["Uuid", "Uuid", "Text"] }, - "nullable": [false, false, false, false, false, true, true, true, true, true, false, false] + "nullable": [false, false, false, true, false, false, true, true, false, false] }, - "hash": "277f66811c6e7fc3d4e7dcc6f8b0f37f6e1c3bace558d4b5a762297c22a60ba5" + "hash": "8c1f861182b005b8045c6d6d096216c9da8fb3728d99c6711dff808431d0d8d6" } 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/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..867cb8ddbeee 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,3 +1,4 @@ +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::*}; @@ -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/shared-module/src/bindings.guard.ts b/shared-module/src/bindings.guard.ts index 71c71caeaf3e..874108c968cf 100644 --- a/shared-module/src/bindings.guard.ts +++ b/shared-module/src/bindings.guard.ts @@ -1833,7 +1833,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 0d90b14ae1cf..2a248243c68f 100644 --- a/shared-module/src/bindings.ts +++ b/shared-module/src/bindings.ts @@ -954,6 +954,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 From 83641be119c64e44d21c80a69f197bbd3042cee2 Mon Sep 17 00:00:00 2001 From: Anastasia Diseth Date: Tue, 19 Mar 2024 11:23:41 +0200 Subject: [PATCH 2/8] fix Uuid from prev pr --- services/course-material/src/services/backend.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/services/course-material/src/services/backend.ts b/services/course-material/src/services/backend.ts index 6e1d4be67ae2..9f6955f840f4 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" @@ -580,12 +581,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) => { const res = await courseMaterialClient.get(`/course-modules/course/${course_id}`, { responseType: "json", }) - return validateResponse(res, isString) + return validateResponse(res, isUuid) } From ec023c695c8277c9c92674cf86373571ca50f37a Mon Sep 17 00:00:00 2001 From: Anastasia Diseth Date: Thu, 21 Mar 2024 11:29:01 +0200 Subject: [PATCH 3/8] Consent form ansers csv expert and fixes --- ...e592440904801cabf1b200ee96e7917a4ccd3.json | 48 ----------- ...cfa2c46cd4e24dfb1e5a064802d3351d72b85.json | 48 +++++++++++ ...f7befa51865d17fc22b855953be5a17ed5d8a.json | 28 ------- ...3e111e2adda9ef1b3be47205fc9086020a020.json | 28 +++++++ ...105ec2638893695484eb32b6159da9f0b2c34.json | 53 ++++++++++++ .../models/src/exercise_task_submissions.rs | 60 ++++++-------- .../headless-lms/models/src/exercise_tasks.rs | 30 +++---- .../headless-lms/models/src/research_forms.rs | 41 +++++++++ .../course_material/course_modules.rs | 6 +- .../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 ++ .../src/locales/en/main-frontend.json | 1 + .../src/locales/fi/main-frontend.json | 1 + 15 files changed, 345 insertions(+), 134 deletions(-) delete mode 100644 services/headless-lms/models/.sqlx/query-0646a9e32998f1c57327702deb4e592440904801cabf1b200ee96e7917a4ccd3.json create mode 100644 services/headless-lms/models/.sqlx/query-17c516418ede7b0240835c473c0cfa2c46cd4e24dfb1e5a064802d3351d72b85.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/models/.sqlx/query-f94add020ea8c8a03346f4516bb105ec2638893695484eb32b6159da9f0b2c34.json create mode 100644 services/headless-lms/server/src/domain/csv_export/course_research_form_questions_answers_export.rs 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-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/.sqlx/query-f94add020ea8c8a03346f4516bb105ec2638893695484eb32b6159da9f0b2c34.json b/services/headless-lms/models/.sqlx/query-f94add020ea8c8a03346f4516bb105ec2638893695484eb32b6159da9f0b2c34.json new file mode 100644 index 000000000000..6b4d639b6547 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-f94add020ea8c8a03346f4516bb105ec2638893695484eb32b6159da9f0b2c34.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": "f94add020ea8c8a03346f4516bb105ec2638893695484eb32b6159da9f0b2c34" +} diff --git a/services/headless-lms/models/src/exercise_task_submissions.rs b/services/headless-lms/models/src/exercise_task_submissions.rs index 34cae736cef2..34db10f9bf6b 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/research_forms.rs b/services/headless-lms/models/src/research_forms.rs index d16ddbf043bc..deed84d58eaf 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)] @@ -165,6 +167,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/server/src/controllers/course_material/course_modules.rs b/services/headless-lms/server/src/controllers/course_material/course_modules.rs index 867cb8ddbeee..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 @@ -4,7 +4,7 @@ use models::{course_modules, library::custom_view_exercises::CustomViewExerciseS 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. */ @@ -21,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. */ @@ -39,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. */ 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/locales/en/main-frontend.json b/shared-module/src/locales/en/main-frontend.json index 2c86db64f983..165f7c6bbec7 100644 --- a/shared-module/src/locales/en/main-frontend.json +++ b/shared-module/src/locales/en/main-frontend.json @@ -354,6 +354,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 637314d5a1b8..1c18a765dc12 100644 --- a/shared-module/src/locales/fi/main-frontend.json +++ b/shared-module/src/locales/fi/main-frontend.json @@ -358,6 +358,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", From 193b18303361e571a8c9af61736c15f37f855da1 Mon Sep 17 00:00:00 2001 From: Anastasia Diseth Date: Thu, 21 Mar 2024 13:14:35 +0200 Subject: [PATCH 4/8] small fix --- ...cd02bf62a5e8ec4e22fd2d87fecbcab0830a647ba5676cd75afc.json} | 4 ++-- services/headless-lms/models/src/research_forms.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename services/headless-lms/models/.sqlx/{query-f94add020ea8c8a03346f4516bb105ec2638893695484eb32b6159da9f0b2c34.json => query-bdf6c7a6ff6ecd02bf62a5e8ec4e22fd2d87fecbcab0830a647ba5676cd75afc.json} (60%) diff --git a/services/headless-lms/models/.sqlx/query-f94add020ea8c8a03346f4516bb105ec2638893695484eb32b6159da9f0b2c34.json b/services/headless-lms/models/.sqlx/query-bdf6c7a6ff6ecd02bf62a5e8ec4e22fd2d87fecbcab0830a647ba5676cd75afc.json similarity index 60% rename from services/headless-lms/models/.sqlx/query-f94add020ea8c8a03346f4516bb105ec2638893695484eb32b6159da9f0b2c34.json rename to services/headless-lms/models/.sqlx/query-bdf6c7a6ff6ecd02bf62a5e8ec4e22fd2d87fecbcab0830a647ba5676cd75afc.json index 6b4d639b6547..84aeeb76974e 100644 --- a/services/headless-lms/models/.sqlx/query-f94add020ea8c8a03346f4516bb105ec2638893695484eb32b6159da9f0b2c34.json +++ b/services/headless-lms/models/.sqlx/query-bdf6c7a6ff6ecd02bf62a5e8ec4e22fd2d87fecbcab0830a647ba5676cd75afc.json @@ -1,6 +1,6 @@ { "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 ", + "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": [ { @@ -49,5 +49,5 @@ }, "nullable": [false, false, false, false, false, false, false, false] }, - "hash": "f94add020ea8c8a03346f4516bb105ec2638893695484eb32b6159da9f0b2c34" + "hash": "bdf6c7a6ff6ecd02bf62a5e8ec4e22fd2d87fecbcab0830a647ba5676cd75afc" } diff --git a/services/headless-lms/models/src/research_forms.rs b/services/headless-lms/models/src/research_forms.rs index 9025857a8dd1..39a4e8938577 100644 --- a/services/headless-lms/models/src/research_forms.rs +++ b/services/headless-lms/models/src/research_forms.rs @@ -197,7 +197,7 @@ pub fn stream_course_research_form_user_answers( sqlx::query_as!( ExportedCourseResearchFormQustionAnswer, r#" - SELECT DISTINCT ON (a.research_form_question_id) + SELECT DISTINCT ON (a.research_form_question_id) q.course_id, q.research_consent_form_id, a.research_form_question_id, From 262fb6837c92775980264935a3e0912e4692516c Mon Sep 17 00:00:00 2001 From: Anastasia Diseth Date: Thu, 21 Mar 2024 13:59:57 +0200 Subject: [PATCH 5/8] remove console.log --- .../moocfi/ExerciseCustomViewBlock/CustomViewIframe.tsx | 1 - 1 file changed, 1 deletion(-) 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 bd50499146d1..add7047a4ed6 100644 --- a/services/course-material/src/components/ContentRenderer/moocfi/ExerciseCustomViewBlock/CustomViewIframe.tsx +++ b/services/course-material/src/components/ContentRenderer/moocfi/ExerciseCustomViewBlock/CustomViewIframe.tsx @@ -38,7 +38,6 @@ const CustomViewIframe: React.FC> 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)), From ba89cdc6b2ec0de8f3b39186a740b96bb8c363bc Mon Sep 17 00:00:00 2001 From: Anastasia Diseth Date: Tue, 26 Mar 2024 08:53:19 +0200 Subject: [PATCH 6/8] add module completion date to data sent for custom view exercise service --- .../CustomViewIframe.tsx | 15 ++++++++ .../course-material/src/services/backend.ts | 12 +++++++ .../course_material/course_instances.rs | 35 +++++++++++++++++++ .../exercise-service-protocol-types.guard.ts | 2 ++ .../src/exercise-service-protocol-types.ts | 1 + 5 files changed, 65 insertions(+) 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 add7047a4ed6..951bb7ffe0e0 100644 --- a/services/course-material/src/components/ContentRenderer/moocfi/ExerciseCustomViewBlock/CustomViewIframe.tsx +++ b/services/course-material/src/components/ContentRenderer/moocfi/ExerciseCustomViewBlock/CustomViewIframe.tsx @@ -9,6 +9,7 @@ import { fetchCourseModuleExercisesAndSubmissionsByType, fetchDefaultModuleIdByCourseId, fetchModuleIdByChapterId, + getAllCourseModuleCompletionsForUserAndCourseInstance, } from "../../../../services/backend" import ErrorBanner from "../../../../shared-module/components/ErrorBanner" import MessageChannelIFrame from "../../../../shared-module/components/MessageChannelIFrame" @@ -37,6 +38,15 @@ const CustomViewIframe: React.FC> const courseInstanceId = pageContext.instance?.id const courseId = pageContext.settings?.current_course_id + const courseModuleCompletionsQuery = useQuery({ + queryKey: [`${courseInstanceId}-course-module-completions-${userInfo.data?.user_id}`], + queryFn: () => + getAllCourseModuleCompletionsForUserAndCourseInstance( + assertNotNullOrUndefined(courseInstanceId), + assertNotNullOrUndefined(userInfo.data?.user_id), + ), + enabled: !!courseInstanceId && !!userInfo.data?.user_id, + }) const courseInfo = useCourseInfo(pageContext.settings?.current_course_id) const moduleIdByChapter = useQuery({ queryKey: [`course-modules-chapter-${chapterId}`], @@ -64,6 +74,10 @@ const CustomViewIframe: React.FC> enabled: !!moduleId && !!courseInstanceId, }) + const completionDate = courseModuleCompletionsQuery.data?.find( + (compl) => compl.course_module_id === moduleId, + )?.completion_date + const submission_data = submissions_by_exercise.data const subs_by_exercise = useMemo(() => { if (!submission_data) { @@ -128,6 +142,7 @@ const CustomViewIframe: React.FC> // eslint-disable-next-line i18next/no-literal-string view_type: "custom-view", course_name: courseInfo.data?.name, + module_completion_date: completionDate ? parseISO(completionDate).toLocaleDateString() : null, user_information: { user_id: userInfo.data.user_id, first_name: userInfo.data.first_name, diff --git a/services/course-material/src/services/backend.ts b/services/course-material/src/services/backend.ts index 78367ba7ad39..b15d1f12cbe1 100644 --- a/services/course-material/src/services/backend.ts +++ b/services/course-material/src/services/backend.ts @@ -9,6 +9,7 @@ import { CourseMaterialExercise, CourseMaterialPeerReviewDataWithToken, CourseMaterialPeerReviewSubmission, + CourseModuleCompletion, CoursePageWithUserData, CustomViewExerciseSubmissions, ExamData, @@ -50,6 +51,7 @@ import { isCourseInstance, isCourseMaterialExercise, isCourseMaterialPeerReviewDataWithToken, + isCourseModuleCompletion, isCoursePageWithUserData, isCustomViewExerciseSubmissions, isExamData, @@ -624,3 +626,13 @@ export const fetchDefaultModuleIdByCourseId = async (course_id: string) => { }) return validateResponse(res, isUuid) } + +export const getAllCourseModuleCompletionsForUserAndCourseInstance = async ( + courseInstanceId: string, + userId: string, +): Promise => { + const response = await courseMaterialClient.get( + `/course-instances/${courseInstanceId}/course-module-completions/${userId}`, + ) + return validateResponse(response, isArray(isCourseModuleCompletion)) +} diff --git a/services/headless-lms/server/src/controllers/course_material/course_instances.rs b/services/headless-lms/server/src/controllers/course_material/course_instances.rs index e6aa43089838..1054537d082d 100644 --- a/services/headless-lms/server/src/controllers/course_material/course_instances.rs +++ b/services/headless-lms/server/src/controllers/course_material/course_instances.rs @@ -6,6 +6,7 @@ use models::{ course_background_question_answers::NewCourseBackgroundQuestionAnswer, course_background_questions::CourseBackgroundQuestionsAndAnswers, course_instance_enrollments::CourseInstanceEnrollment, + course_module_completions::CourseModuleCompletion, library::progressing::UserModuleCompletionStatus, user_exercise_states::{UserCourseInstanceChapterExerciseProgress, UserCourseInstanceProgress}, }; @@ -148,6 +149,36 @@ async fn save_course_settings( token.authorized_ok(web::Json(enrollment)) } +/** +GET /course-instances/:id/course-module-completions/:user_id - Returns a list of all course module completions for a given user for this course instance. +*/ +#[instrument(skip(pool))] + +async fn get_all_get_all_course_module_completions_for_user_by_course_instance_id( + params: web::Path<(Uuid, Uuid)>, + pool: web::Data, + user: AuthUser, +) -> ControllerResult>> { + let (course_instance_id, user_id) = params.into_inner(); + let mut conn = pool.acquire().await?; + let token = authorize( + &mut conn, + Act::ViewUserProgressOrDetails, + Some(user.id), + Res::CourseInstance(course_instance_id), + ) + .await?; + + let res = models::course_module_completions::get_all_by_course_instance_and_user_id( + &mut conn, + course_instance_id, + user_id, + ) + .await?; + + token.authorized_ok(web::Json(res)) +} + /** GET /api/v0/course-material/course-instance/:course_instance_id/background-questions-and-answers - Gets background questions and answers for an course instance. */ @@ -190,6 +221,10 @@ pub fn _add_routes(cfg: &mut ServiceConfig) { "/{course_instance_id}/module-completions", web::get().to(get_module_completions_for_course_instance), ) + .route( + "/{course_instance_id}/course-module-completions/{user_id}", + web::get().to(get_all_get_all_course_module_completions_for_user_by_course_instance_id), + ) .route( "/{course_instance_id}/background-questions-and-answers", web::get().to(get_background_questions_and_answers), diff --git a/shared-module/src/exercise-service-protocol-types.guard.ts b/shared-module/src/exercise-service-protocol-types.guard.ts index 2e0cca0664a6..f873126bebcb 100644 --- a/shared-module/src/exercise-service-protocol-types.guard.ts +++ b/shared-module/src/exercise-service-protocol-types.guard.ts @@ -243,6 +243,8 @@ export function isCustomViewIframeState(obj: unknown): obj is CustomViewIframeSt typedObj["user_variables"] === null || (isUserVariablesMap(typedObj["user_variables"]) as boolean)) && typeof typedObj["course_name"] === "string" && + (typedObj["module_completion_date"] === null || + typeof typedObj["module_completion_date"] === "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 3d4468b6522d..a03cf2487c0a 100644 --- a/shared-module/src/exercise-service-protocol-types.ts +++ b/shared-module/src/exercise-service-protocol-types.ts @@ -124,6 +124,7 @@ export type CustomViewIframeState = { user_information: UserInfo user_variables?: UserVariablesMap | null course_name: string + module_completion_date: string | null data: { submissions_by_exercise: Array<{ exercise_id: string From b49fad85a335423c4181af61c487d5b21baa3120 Mon Sep 17 00:00:00 2001 From: Anastasia Diseth Date: Thu, 28 Mar 2024 12:56:22 +0200 Subject: [PATCH 7/8] attempt to fix undefined user_answer --- .../CustomViewIframe.tsx | 13 ++--- ...cfa2c46cd4e24dfb1e5a064802d3351d72b85.json | 48 ------------------- ...ada2542da08e62609213ef0cd7d124c45861e.json | 48 +++++++++++++++++++ .../models/src/exercise_task_submissions.rs | 4 +- 4 files changed, 55 insertions(+), 58 deletions(-) delete mode 100644 services/headless-lms/models/.sqlx/query-17c516418ede7b0240835c473c0cfa2c46cd4e24dfb1e5a064802d3351d72b85.json create mode 100644 services/headless-lms/models/.sqlx/query-84e45be02b8584531f60ee37a33ada2542da08e62609213ef0cd7d124c45861e.json 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 703795d8edaa..ba2cab2ee763 100644 --- a/services/course-material/src/components/ContentRenderer/moocfi/ExerciseCustomViewBlock/CustomViewIframe.tsx +++ b/services/course-material/src/components/ContentRenderer/moocfi/ExerciseCustomViewBlock/CustomViewIframe.tsx @@ -91,16 +91,11 @@ const CustomViewIframe: React.FC> 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 answer = submission_data.exercise_tasks.task_submissions.find( + (sub) => sub.exercise_task_grading_id === grading.id, + ) const publicSpec = submission_data.exercise_tasks.exercise_tasks.find( - (task) => task.id == grading.exercise_task_id, + (task) => task.id === grading.exercise_task_id, )?.public_spec return { task_id: grading.exercise_task_id, diff --git a/services/headless-lms/models/.sqlx/query-17c516418ede7b0240835c473c0cfa2c46cd4e24dfb1e5a064802d3351d72b85.json b/services/headless-lms/models/.sqlx/query-17c516418ede7b0240835c473c0cfa2c46cd4e24dfb1e5a064802d3351d72b85.json deleted file mode 100644 index 9ceb542f4c7c..000000000000 --- a/services/headless-lms/models/.sqlx/query-17c516418ede7b0240835c473c0cfa2c46cd4e24dfb1e5a064802d3351d72b85.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "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-84e45be02b8584531f60ee37a33ada2542da08e62609213ef0cd7d124c45861e.json b/services/headless-lms/models/.sqlx/query-84e45be02b8584531f60ee37a33ada2542da08e62609213ef0cd7d124c45861e.json new file mode 100644 index 000000000000..f5fe5b5e1ff3 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-84e45be02b8584531f60ee37a33ada2542da08e62609213ef0cd7d124c45861e.json @@ -0,0 +1,48 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT ON (g.exercise_task_id)\n 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 ORDER BY g.exercise_task_id, g.created_at DESC\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": "84e45be02b8584531f60ee37a33ada2542da08e62609213ef0cd7d124c45861e" +} diff --git a/services/headless-lms/models/src/exercise_task_submissions.rs b/services/headless-lms/models/src/exercise_task_submissions.rs index bbd045d5b768..90afb0103a6c 100644 --- a/services/headless-lms/models/src/exercise_task_submissions.rs +++ b/services/headless-lms/models/src/exercise_task_submissions.rs @@ -507,7 +507,8 @@ pub async fn get_user_exersice_task_submissions_by_course_module_and_exercise_ty let res: Vec = sqlx::query_as!( CustomViewExerciseTaskSubmission, r#" - SELECT g.id, + SELECT DISTINCT ON (g.exercise_task_id) + g.id, g.created_at, g.exercise_slide_submission_id, g.exercise_slide_id, @@ -528,6 +529,7 @@ pub async fn get_user_exersice_task_submissions_by_course_module_and_exercise_ty AND ess.deleted_at IS NULL AND e.deleted_at IS NULL AND c.deleted_at IS NULL + ORDER BY g.exercise_task_id, g.created_at DESC "#, user_id, course_instance_id, From 6816c80f19d2b8db90fd164177c5f9ab2fb253dc Mon Sep 17 00:00:00 2001 From: Anastasia Diseth Date: Thu, 28 Mar 2024 13:58:13 +0200 Subject: [PATCH 8/8] rename db function to more descriptive --- services/headless-lms/models/src/exercise_task_submissions.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/headless-lms/models/src/exercise_task_submissions.rs b/services/headless-lms/models/src/exercise_task_submissions.rs index 90afb0103a6c..3159cad63935 100644 --- a/services/headless-lms/models/src/exercise_task_submissions.rs +++ b/services/headless-lms/models/src/exercise_task_submissions.rs @@ -464,7 +464,7 @@ pub async fn get_user_custom_view_exercise_tasks_by_module_and_exercise_type( course_instance_id: Uuid, ) -> ModelResult { let task_submissions = - crate::exercise_task_submissions::get_user_exersice_task_submissions_by_course_module_and_exercise_type( + crate::exercise_task_submissions::get_user_latest_exercise_task_submissions_by_course_module_and_exercise_type( &mut *conn, user_id, exercise_type, @@ -497,7 +497,7 @@ pub async fn get_user_custom_view_exercise_tasks_by_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( +pub async fn get_user_latest_exercise_task_submissions_by_course_module_and_exercise_type( conn: &mut PgConnection, user_id: Uuid, exercise_type: &str,