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..ba2cab2ee763 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,8 +38,17 @@ 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) - console.log(courseInfo.data?.name) + const moduleIdByChapter = useQuery({ queryKey: [`course-modules-chapter-${chapterId}`], queryFn: () => fetchModuleIdByChapterId(assertNotNullOrUndefined(chapterId)), @@ -65,6 +75,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) { @@ -77,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, @@ -129,6 +138,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/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..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, @@ -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, 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