diff --git a/client/src/components/dashboard/AssignmentProgressSummaryBox.vue b/client/src/components/dashboard/AssignmentProgressSummaryBox.vue new file mode 100644 index 00000000..b76e0b8b --- /dev/null +++ b/client/src/components/dashboard/AssignmentProgressSummaryBox.vue @@ -0,0 +1,48 @@ + + + + + {{ $t("a.Kompetenznachweis-Elemente") }} + + + + + {{ totalAssignments }} + + + + + + + + {{ + $t("a.VALUE von MAXIMUM", { + VALUE: props.achievedPointsCount, + MAXIMUM: props.maxPointsCount, + }) + }} + + + + + + + + diff --git a/client/src/components/dashboard/AssignmentSummaryBox.vue b/client/src/components/dashboard/AssignmentSummaryBox.vue new file mode 100644 index 00000000..4d9754e2 --- /dev/null +++ b/client/src/components/dashboard/AssignmentSummaryBox.vue @@ -0,0 +1,54 @@ + + + + + {{ $t("a.Kompetenznachweis-Elemente") }} + + + + + + {{ assignmentsCompleted }} + + + + + + + + + {{ `${Math.round(props.avgPassed)}%` }} + + + + + + + + diff --git a/client/src/components/dashboard/AttendanceSummaryBox.vue b/client/src/components/dashboard/AttendanceSummaryBox.vue new file mode 100644 index 00000000..ffd86316 --- /dev/null +++ b/client/src/components/dashboard/AttendanceSummaryBox.vue @@ -0,0 +1,51 @@ + + + + + {{ $t("a.Anwesenheit") }} + + + + + + {{ daysCompleted }} + + + + + + + + + {{ `${avgParticipantsPresent}%` }} + + + + + + + + diff --git a/client/src/components/dashboard/BaseBox.vue b/client/src/components/dashboard/BaseBox.vue new file mode 100644 index 00000000..e8c3aec3 --- /dev/null +++ b/client/src/components/dashboard/BaseBox.vue @@ -0,0 +1,22 @@ + + + + + + + + + + + + {{ $t("a.Details anschauen") }} + + + + + + diff --git a/client/src/components/dashboard/CompetenceSummaryBox.vue b/client/src/components/dashboard/CompetenceSummaryBox.vue new file mode 100644 index 00000000..6c2a7cca --- /dev/null +++ b/client/src/components/dashboard/CompetenceSummaryBox.vue @@ -0,0 +1,43 @@ + + + + + {{ $t("a.Selbsteinschätzungen") }} + + + + + + + {{ successCount }} + + + + + + + + + + {{ failCount }} + + + + + + + diff --git a/client/src/components/dashboard/CourseDetailDates.vue b/client/src/components/dashboard/CourseDetailDates.vue new file mode 100644 index 00000000..a06621dd --- /dev/null +++ b/client/src/components/dashboard/CourseDetailDates.vue @@ -0,0 +1,41 @@ + + + + + {{ $t("a.Aktueller Lehrgang") }} + + + {{ dashboardStore.currentDashboardConfig.name }} + + {{ $t("a.Cockpit anschauen") }} + + + + + + {{ $t("a.Alle Lehrgänge anzeigen") }} + + + {{ $t("a.AlleTermine") }} + + diff --git a/client/src/components/dashboard/CourseStatistics.vue b/client/src/components/dashboard/CourseStatistics.vue new file mode 100644 index 00000000..c4942c93 --- /dev/null +++ b/client/src/components/dashboard/CourseStatistics.vue @@ -0,0 +1,34 @@ + + + + + + + + {{ participantCount }} + + {{ $t("a.Teilnehmer") }} + + + + {{ expertCount }} + + {{ $t("a.Trainer") }} + + + + {{ sessionCount }} + + {{ $t("a.Durchführungen") }} + + + + + + diff --git a/client/src/components/dashboard/FeedbackSummaryBox.vue b/client/src/components/dashboard/FeedbackSummaryBox.vue new file mode 100644 index 00000000..0c80d5cf --- /dev/null +++ b/client/src/components/dashboard/FeedbackSummaryBox.vue @@ -0,0 +1,57 @@ + + + + + {{ $t("a.Feedback Teilnehmer") }} + + + + + + + + {{ props.statisfactionAvg.toFixed(1) }} + + + + {{ props.statisfactionMax }} + + + + + + {{ $t("a.Allgemeine Zufriedenheit") }} + + + + + {{ props.feedbackCount }} + + + + + + + + + diff --git a/client/src/components/dashboard/SimpleDates.vue b/client/src/components/dashboard/SimpleDates.vue new file mode 100644 index 00000000..09f1bd9a --- /dev/null +++ b/client/src/components/dashboard/SimpleDates.vue @@ -0,0 +1,19 @@ + + + + {{ $t("a.AlleTermine") }} + + diff --git a/client/src/components/dashboard/StatisticFilterList.vue b/client/src/components/dashboard/StatisticFilterList.vue new file mode 100644 index 00000000..7ec9b451 --- /dev/null +++ b/client/src/components/dashboard/StatisticFilterList.vue @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + diff --git a/client/src/components/learningPath/LearningPathDiagram.vue b/client/src/components/learningPath/LearningPathDiagram.vue index b939cab2..6fe0235d 100644 --- a/client/src/components/learningPath/LearningPathDiagram.vue +++ b/client/src/components/learningPath/LearningPathDiagram.vue @@ -36,23 +36,16 @@ const circles = computed(() => { }); const wrapperClasses = computed(() => { - let classes = "flex my-5"; + let classes = "flex"; if (props.diagramType === "horizontal") { - classes += " flex-row h-8"; + classes += " flex-row h-8 space-x-2"; } else if (props.diagramType === "horizontalSmall") { - classes += " flex-row h-5"; + classes += " flex-row h-5 space-x-1"; } else if (props.diagramType === "singleSmall") { classes += " h-8"; } return classes; }); - -const circleClasses = computed(() => { - if (props.diagramType === "horizontal" || props.diagramType === "horizontalSmall") { - return "pl-1"; - } - return ""; -}); @@ -60,7 +53,6 @@ const circleClasses = computed(() => { diff --git a/client/src/components/ui/LoadingSpinner.vue b/client/src/components/ui/LoadingSpinner.vue new file mode 100644 index 00000000..34c7ca21 --- /dev/null +++ b/client/src/components/ui/LoadingSpinner.vue @@ -0,0 +1,21 @@ + + + + + + + Loading... + + diff --git a/client/src/components/ui/RatingScale.vue b/client/src/components/ui/RatingScale.vue index 4afaa72d..07b0d9f3 100644 --- a/client/src/components/ui/RatingScale.vue +++ b/client/src/components/ui/RatingScale.vue @@ -77,15 +77,10 @@ import { Popover, PopoverButton, PopoverPanel } from "@headlessui/vue"; import { computed } from "vue"; import { useTranslation } from "i18next-vue"; import log from "loglevel"; +import { getBlendedColorForRating } from "@/utils/ratingToColor"; const { t } = useTranslation(); -type RGB = [number, number, number]; -const red: RGB = [221, 103, 81]; // red-600 -const yellow: RGB = [250, 200, 82]; // yellow-500 -const lightGreen: RGB = [120, 222, 163]; // green-500 -const darkGreen: RGB = [91, 183, 130]; // green-600 - const legends = [ { index: 1, label: t("feedback.veryUnhappy") }, { index: 2, label: t("feedback.unhappy") }, @@ -101,19 +96,11 @@ const props = defineProps<{ log.debug("RatingScale created", props); -const rating = computed((): number => { +const rating = computed(() => { const sum = props.ratings.reduce((a, b) => a + b, 0); return sum / props.ratings.length; }); -const weight = computed(() => { - return rating.value % 1; -}); - -const scale = computed(() => { - return Math.floor(rating.value); -}); - const answers = computed(() => props.ratings.length); const numberOfRatings = computed(() => { @@ -122,57 +109,14 @@ const numberOfRatings = computed(() => { ); }); -const colors = computed(() => { - switch (scale.value) { - case 1: - return [red, yellow]; - case 2: - return [yellow, lightGreen]; - case 3: - default: - return [lightGreen, darkGreen]; - } -}); - -const blendColorValue = (v1: number, v2: number, weight: number) => { - return v1 * (1 - weight) + v2 * weight; -}; - -const blendColors = (c1: RGB, c2: RGB, weight: number): RGB => { - const [r1, g1, b1] = c1; - const [r2, g2, b2] = c2; - return [ - blendColorValue(r1, r2, weight), - blendColorValue(g1, g2, weight), - blendColorValue(b1, b2, weight), - ]; -}; - -const getRGBString = ([r, g, b]: RGB) => { - return `rgb(${Math.floor(r)}, ${Math.floor(g)}, ${Math.floor(b)})`; -}; - -// const getRGBStyle = (c1: RGB, c2: RGB, weight: number) => { -// return getRGBString(blendColors(c1, c2, weight)); -// }; - const percent = computed(() => { - return (scale.value - 1) * 33.33 + weight.value * 33.33; + return (Math.floor(rating.value) - 1) * 33.33 + (rating.value % 1) * 33.33; }); -const leftPosition = computed(() => { - return `${percent.value.toPrecision(3)}%`; -}); +const leftPosition = computed(() => `${percent.value.toPrecision(3)}%`); +const rightClip = computed(() => `${Math.round(100 - percent.value)}%`); -const rightClip = computed(() => { - return `${Math.round(100 - percent.value)}%`; -}); - -const blendedColor = computed(() => { - return blendColors(colors.value[0], colors.value[1], weight.value); -}); - -const backgroundColor = getRGBString(blendedColor.value); +const backgroundColor = getBlendedColorForRating(rating.value); const circleStyle = { backgroundColor, diff --git a/client/src/composables.ts b/client/src/composables.ts index 295718f3..28924f1f 100644 --- a/client/src/composables.ts +++ b/client/src/composables.ts @@ -1,3 +1,4 @@ +import type { CourseStatisticsType } from "@/gql/graphql"; import { graphqlClient } from "@/graphql/client"; import { COURSE_QUERY, COURSE_SESSION_DETAIL_QUERY } from "@/graphql/queries"; import { @@ -7,6 +8,7 @@ import { } from "@/services/circle"; import { useCompletionStore } from "@/stores/completion"; import { useCourseSessionsStore } from "@/stores/courseSessions"; +import { useDashboardStore } from "@/stores/dashboard"; import { useUserStore } from "@/stores/user"; import type { ActionCompetence, @@ -411,3 +413,25 @@ export function useCourseDataWithCompletion( nextLearningContent, }; } + +export function useCourseStatistics() { + const dashboardStore = useDashboardStore(); + + const statistics = computed(() => { + return dashboardStore.currentDashBoardData as CourseStatisticsType; + }); + + const courseSessionName = (courseSessionId: string) => { + return statistics.value.course_session_properties.sessions.find( + (session) => session.id === courseSessionId + )?.name; + }; + + const circleMeta = (circleId: string) => { + return statistics.value.course_session_properties.circles.find( + (circle) => circle.id === circleId + ); + }; + + return { courseSessionName, circleMeta }; +} diff --git a/client/src/gql/gql.ts b/client/src/gql/gql.ts index 554937b8..31962994 100644 --- a/client/src/gql/gql.ts +++ b/client/src/gql/gql.ts @@ -21,6 +21,9 @@ const documents = { "\n query competenceCertificateQuery($courseSlug: String!, $courseSessionId: ID!) {\n competence_certificate_list(course_slug: $courseSlug) {\n ...CoursePageFields\n competence_certificates {\n ...CoursePageFields\n assignments {\n ...CoursePageFields\n assignment_type\n max_points\n completion(course_session_id: $courseSessionId) {\n id\n completion_status\n submitted_at\n evaluation_points\n evaluation_max_points\n evaluation_passed\n }\n learning_content {\n ...CoursePageFields\n circle {\n id\n title\n slug\n }\n }\n }\n }\n }\n }\n": types.CompetenceCertificateQueryDocument, "\n query courseSessionDetail($courseSessionId: ID!) {\n course_session(id: $courseSessionId) {\n id\n title\n course {\n id\n title\n slug\n }\n users {\n id\n user_id\n first_name\n last_name\n email\n avatar_url\n role\n circles {\n id\n title\n slug\n }\n }\n attendance_courses {\n id\n location\n trainer\n due_date {\n id\n start\n end\n }\n learning_content_id\n learning_content {\n id\n title\n circle {\n id\n title\n slug\n }\n }\n }\n assignments {\n id\n submission_deadline {\n id\n start\n }\n evaluation_deadline {\n id\n start\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n edoniq_tests {\n id\n deadline {\n id\n start\n end\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n }\n }\n": types.CourseSessionDetailDocument, "\n query courseQuery($slug: String!) {\n course(slug: $slug) {\n id\n title\n slug\n category_name\n action_competences {\n competence_id\n ...CoursePageFields\n performance_criteria {\n competence_id\n learning_unit {\n id\n slug\n evaluate_url\n }\n ...CoursePageFields\n }\n }\n learning_path {\n ...CoursePageFields\n topics {\n is_visible\n ...CoursePageFields\n circles {\n description\n goals\n ...CoursePageFields\n learning_sequences {\n icon\n ...CoursePageFields\n learning_units {\n evaluate_url\n ...CoursePageFields\n performance_criteria {\n ...CoursePageFields\n }\n learning_contents {\n can_user_self_toggle_course_completion\n content_url\n minutes\n description\n ...CoursePageFields\n ... on LearningContentAssignmentObjectType {\n assignment_type\n content_assignment {\n id\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentEdoniqTestObjectType {\n checkbox_text\n has_extended_time_test\n content_assignment {\n id\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentRichTextObjectType {\n text\n }\n }\n }\n }\n }\n }\n }\n }\n }\n": types.CourseQueryDocument, + "\n query dashboardConfig {\n dashboard_config {\n id\n slug\n name\n dashboard_type\n }\n }\n": types.DashboardConfigDocument, + "\n query dashboardProgress($courseId: ID!) {\n course_progress(course_id: $courseId) {\n _id\n course_id\n session_to_continue_id\n competence {\n _id\n total_count\n success_count\n fail_count\n }\n assignment {\n _id\n total_count\n points_max_count\n points_achieved_count\n }\n }\n }\n": types.DashboardProgressDocument, + "\n query courseStatistics($courseId: ID!) {\n course_statistics(course_id: $courseId) {\n _id\n course_id\n course_title\n course_slug\n course_session_properties {\n _id\n sessions {\n id\n name\n }\n generations\n circles {\n id\n name\n }\n }\n course_session_selection_ids\n course_session_selection_metrics {\n _id\n session_count\n participant_count\n expert_count\n }\n attendance_day_presences {\n _id\n records {\n _id\n course_session_id\n generation\n circle_id\n due_date\n participants_present\n participants_total\n details_url\n }\n summary {\n _id\n days_completed\n participants_present\n }\n }\n feedback_responses {\n _id\n records {\n _id\n course_session_id\n generation\n circle_id\n experts\n satisfaction_average\n satisfaction_max\n details_url\n }\n summary {\n _id\n satisfaction_average\n satisfaction_max\n total_responses\n }\n }\n assignments {\n _id\n summary {\n _id\n completed_count\n average_passed\n }\n records {\n _id\n course_session_id\n course_session_assignment_id\n circle_id\n generation\n assignment_title\n assignment_type_translation_key\n details_url\n deadline\n metrics {\n _id\n passed_count\n failed_count\n unranked_count\n ranking_completed\n average_passed\n }\n }\n }\n competences {\n _id\n summary {\n _id\n success_total\n fail_total\n }\n records {\n _id\n course_session_id\n generation\n circle_id\n title\n success_count\n fail_count\n details_url\n }\n }\n }\n }\n": types.CourseStatisticsDocument, "\n mutation SendFeedbackMutation(\n $courseSessionId: ID!\n $learningContentId: ID!\n $data: GenericScalar!\n $submitted: Boolean\n ) {\n send_feedback(\n course_session_id: $courseSessionId\n learning_content_page_id: $learningContentId\n data: $data\n submitted: $submitted\n ) {\n feedback_response {\n id\n data\n submitted\n }\n errors {\n field\n messages\n }\n }\n }\n": types.SendFeedbackMutationDocument, }; @@ -70,6 +73,18 @@ export function graphql(source: "\n query courseSessionDetail($courseSessionId: * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n query courseQuery($slug: String!) {\n course(slug: $slug) {\n id\n title\n slug\n category_name\n action_competences {\n competence_id\n ...CoursePageFields\n performance_criteria {\n competence_id\n learning_unit {\n id\n slug\n evaluate_url\n }\n ...CoursePageFields\n }\n }\n learning_path {\n ...CoursePageFields\n topics {\n is_visible\n ...CoursePageFields\n circles {\n description\n goals\n ...CoursePageFields\n learning_sequences {\n icon\n ...CoursePageFields\n learning_units {\n evaluate_url\n ...CoursePageFields\n performance_criteria {\n ...CoursePageFields\n }\n learning_contents {\n can_user_self_toggle_course_completion\n content_url\n minutes\n description\n ...CoursePageFields\n ... on LearningContentAssignmentObjectType {\n assignment_type\n content_assignment {\n id\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentEdoniqTestObjectType {\n checkbox_text\n has_extended_time_test\n content_assignment {\n id\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentRichTextObjectType {\n text\n }\n }\n }\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query courseQuery($slug: String!) {\n course(slug: $slug) {\n id\n title\n slug\n category_name\n action_competences {\n competence_id\n ...CoursePageFields\n performance_criteria {\n competence_id\n learning_unit {\n id\n slug\n evaluate_url\n }\n ...CoursePageFields\n }\n }\n learning_path {\n ...CoursePageFields\n topics {\n is_visible\n ...CoursePageFields\n circles {\n description\n goals\n ...CoursePageFields\n learning_sequences {\n icon\n ...CoursePageFields\n learning_units {\n evaluate_url\n ...CoursePageFields\n performance_criteria {\n ...CoursePageFields\n }\n learning_contents {\n can_user_self_toggle_course_completion\n content_url\n minutes\n description\n ...CoursePageFields\n ... on LearningContentAssignmentObjectType {\n assignment_type\n content_assignment {\n id\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentEdoniqTestObjectType {\n checkbox_text\n has_extended_time_test\n content_assignment {\n id\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentRichTextObjectType {\n text\n }\n }\n }\n }\n }\n }\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query dashboardConfig {\n dashboard_config {\n id\n slug\n name\n dashboard_type\n }\n }\n"): (typeof documents)["\n query dashboardConfig {\n dashboard_config {\n id\n slug\n name\n dashboard_type\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query dashboardProgress($courseId: ID!) {\n course_progress(course_id: $courseId) {\n _id\n course_id\n session_to_continue_id\n competence {\n _id\n total_count\n success_count\n fail_count\n }\n assignment {\n _id\n total_count\n points_max_count\n points_achieved_count\n }\n }\n }\n"): (typeof documents)["\n query dashboardProgress($courseId: ID!) {\n course_progress(course_id: $courseId) {\n _id\n course_id\n session_to_continue_id\n competence {\n _id\n total_count\n success_count\n fail_count\n }\n assignment {\n _id\n total_count\n points_max_count\n points_achieved_count\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query courseStatistics($courseId: ID!) {\n course_statistics(course_id: $courseId) {\n _id\n course_id\n course_title\n course_slug\n course_session_properties {\n _id\n sessions {\n id\n name\n }\n generations\n circles {\n id\n name\n }\n }\n course_session_selection_ids\n course_session_selection_metrics {\n _id\n session_count\n participant_count\n expert_count\n }\n attendance_day_presences {\n _id\n records {\n _id\n course_session_id\n generation\n circle_id\n due_date\n participants_present\n participants_total\n details_url\n }\n summary {\n _id\n days_completed\n participants_present\n }\n }\n feedback_responses {\n _id\n records {\n _id\n course_session_id\n generation\n circle_id\n experts\n satisfaction_average\n satisfaction_max\n details_url\n }\n summary {\n _id\n satisfaction_average\n satisfaction_max\n total_responses\n }\n }\n assignments {\n _id\n summary {\n _id\n completed_count\n average_passed\n }\n records {\n _id\n course_session_id\n course_session_assignment_id\n circle_id\n generation\n assignment_title\n assignment_type_translation_key\n details_url\n deadline\n metrics {\n _id\n passed_count\n failed_count\n unranked_count\n ranking_completed\n average_passed\n }\n }\n }\n competences {\n _id\n summary {\n _id\n success_total\n fail_total\n }\n records {\n _id\n course_session_id\n generation\n circle_id\n title\n success_count\n fail_count\n details_url\n }\n }\n }\n }\n"): (typeof documents)["\n query courseStatistics($courseId: ID!) {\n course_statistics(course_id: $courseId) {\n _id\n course_id\n course_title\n course_slug\n course_session_properties {\n _id\n sessions {\n id\n name\n }\n generations\n circles {\n id\n name\n }\n }\n course_session_selection_ids\n course_session_selection_metrics {\n _id\n session_count\n participant_count\n expert_count\n }\n attendance_day_presences {\n _id\n records {\n _id\n course_session_id\n generation\n circle_id\n due_date\n participants_present\n participants_total\n details_url\n }\n summary {\n _id\n days_completed\n participants_present\n }\n }\n feedback_responses {\n _id\n records {\n _id\n course_session_id\n generation\n circle_id\n experts\n satisfaction_average\n satisfaction_max\n details_url\n }\n summary {\n _id\n satisfaction_average\n satisfaction_max\n total_responses\n }\n }\n assignments {\n _id\n summary {\n _id\n completed_count\n average_passed\n }\n records {\n _id\n course_session_id\n course_session_assignment_id\n circle_id\n generation\n assignment_title\n assignment_type_translation_key\n details_url\n deadline\n metrics {\n _id\n passed_count\n failed_count\n unranked_count\n ranking_completed\n average_passed\n }\n }\n }\n competences {\n _id\n summary {\n _id\n success_total\n fail_total\n }\n records {\n _id\n course_session_id\n generation\n circle_id\n title\n success_count\n fail_count\n details_url\n }\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/client/src/gql/graphql.ts b/client/src/gql/graphql.ts index d2ac2e1c..db4ca4c6 100644 --- a/client/src/gql/graphql.ts +++ b/client/src/gql/graphql.ts @@ -85,6 +85,16 @@ export type AssignmentAssignmentCompletionCompletionStatusChoices = /** SUBMITTED */ | 'SUBMITTED'; +export type AssignmentCompletionMetricsType = { + __typename?: 'AssignmentCompletionMetricsType'; + _id: Scalars['ID']['output']; + average_passed: Scalars['Float']['output']; + failed_count: Scalars['Int']['output']; + passed_count: Scalars['Int']['output']; + ranking_completed: Scalars['Boolean']['output']; + unranked_count: Scalars['Int']['output']; +}; + export type AssignmentCompletionMutation = { __typename?: 'AssignmentCompletionMutation'; assignment_completion?: Maybe; @@ -156,11 +166,53 @@ export type AssignmentObjectTypeCompletionArgs = { learning_content_page_id?: InputMaybe; }; +export type AssignmentStatisticsRecordType = { + __typename?: 'AssignmentStatisticsRecordType'; + _id: Scalars['ID']['output']; + assignment_title: Scalars['String']['output']; + assignment_type_translation_key: Scalars['String']['output']; + circle_id: Scalars['ID']['output']; + course_session_assignment_id: Scalars['ID']['output']; + course_session_id: Scalars['ID']['output']; + deadline: Scalars['DateTime']['output']; + details_url: Scalars['String']['output']; + generation: Scalars['String']['output']; + metrics: AssignmentCompletionMetricsType; +}; + +export type AssignmentStatisticsSummaryType = { + __typename?: 'AssignmentStatisticsSummaryType'; + _id: Scalars['ID']['output']; + average_passed: Scalars['Float']['output']; + completed_count: Scalars['Int']['output']; +}; + +export type AssignmentsStatisticsType = { + __typename?: 'AssignmentsStatisticsType'; + _id: Scalars['ID']['output']; + records: Array; + summary: AssignmentStatisticsSummaryType; +}; + export type AttendanceCourseUserMutation = { __typename?: 'AttendanceCourseUserMutation'; course_session_attendance_course?: Maybe; }; +export type AttendanceDayPresencesStatisticsType = { + __typename?: 'AttendanceDayPresencesStatisticsType'; + _id: Scalars['ID']['output']; + records: Array; + summary: AttendanceSummaryStatisticsType; +}; + +export type AttendanceSummaryStatisticsType = { + __typename?: 'AttendanceSummaryStatisticsType'; + _id: Scalars['ID']['output']; + days_completed: Scalars['Int']['output']; + participants_present: Scalars['Int']['output']; +}; + export type AttendanceUserInputType = { status: AttendanceUserStatus; user_id: Scalars['UUID']['input']; @@ -228,6 +280,32 @@ export type CompetenceCertificateObjectType = CoursePageInterface & { translation_key: Scalars['String']['output']; }; +export type CompetencePerformanceStatisticsSummaryType = { + __typename?: 'CompetencePerformanceStatisticsSummaryType'; + _id: Scalars['ID']['output']; + fail_total: Scalars['Int']['output']; + success_total: Scalars['Int']['output']; +}; + +export type CompetenceRecordStatisticsType = { + __typename?: 'CompetenceRecordStatisticsType'; + _id: Scalars['ID']['output']; + circle_id: Scalars['ID']['output']; + course_session_id: Scalars['ID']['output']; + details_url: Scalars['String']['output']; + fail_count: Scalars['Int']['output']; + generation: Scalars['String']['output']; + success_count: Scalars['Int']['output']; + title: Scalars['String']['output']; +}; + +export type CompetencesStatisticsType = { + __typename?: 'CompetencesStatisticsType'; + _id: Scalars['ID']['output']; + records: Array; + summary: CompetencePerformanceStatisticsSummaryType; +}; + /** An enumeration. */ export type CoreUserLanguageChoices = /** Deutsch */ @@ -237,15 +315,6 @@ export type CoreUserLanguageChoices = /** Italiano */ | 'IT'; -/** An enumeration. */ -export type CourseCourseSessionUserRoleChoices = - /** Experte/Trainer */ - | 'EXPERT' - /** Teilnehmer */ - | 'MEMBER' - /** Lernbegleitung */ - | 'TUTOR'; - export type CourseObjectType = { __typename?: 'CourseObjectType'; action_competences: Array; @@ -267,6 +336,15 @@ export type CoursePageInterface = { translation_key: Scalars['String']['output']; }; +export type CourseProgressType = { + __typename?: 'CourseProgressType'; + _id: Scalars['ID']['output']; + assignment: ProgressDashboardAssignmentType; + competence: ProgressDashboardCompetenceType; + course_id: Scalars['ID']['output']; + session_to_continue_id?: Maybe; +}; + export type CourseSessionAssignmentObjectType = { __typename?: 'CourseSessionAssignmentObjectType'; course_session_id: Scalars['ID']['output']; @@ -320,18 +398,53 @@ export type CourseSessionUserExpertCircleType = { title: Scalars['String']['output']; }; +/** + * WORKAROUND: + * Why is this no DjangoObjectType? It's because we have to "inject" + * the supervisor into the list of users. This is done in the resolve_users + * of the CourseSessionObjectType. And there we have to be able to construct + * a CourseSessionUserObjectsType with the CIRCLES of the supervisor! + */ export type CourseSessionUserObjectsType = { __typename?: 'CourseSessionUserObjectsType'; avatar_url: Scalars['String']['output']; circles: Array; email: Scalars['String']['output']; first_name: Scalars['String']['output']; - id: Scalars['UUID']['output']; + id: Scalars['ID']['output']; last_name: Scalars['String']['output']; - role: CourseCourseSessionUserRoleChoices; + role: Scalars['String']['output']; user_id: Scalars['UUID']['output']; }; +export type CourseStatisticsType = { + __typename?: 'CourseStatisticsType'; + _id: Scalars['ID']['output']; + assignments: AssignmentsStatisticsType; + attendance_day_presences: AttendanceDayPresencesStatisticsType; + competences: CompetencesStatisticsType; + course_id: Scalars['ID']['output']; + course_session_properties: StatisticsCourseSessionPropertiesType; + course_session_selection_ids: Array>; + course_session_selection_metrics: StatisticsCourseSessionsSelectionMetricType; + course_slug: Scalars['String']['output']; + course_title: Scalars['String']['output']; + feedback_responses: FeedbackStatisticsResponsesType; +}; + +export type DashboardConfigType = { + __typename?: 'DashboardConfigType'; + dashboard_type: DashboardType; + id: Scalars['ID']['output']; + name: Scalars['String']['output']; + slug: Scalars['String']['output']; +}; + +export type DashboardType = + | 'PROGRESS_DASHBOARD' + | 'SIMPLE_DASHBOARD' + | 'STATISTICS_DASHBOARD'; + export type DueDateObjectType = { __typename?: 'DueDateObjectType'; /** Translation Key aus dem Frontend */ @@ -367,6 +480,33 @@ export type FeedbackResponseObjectType = { submitted: Scalars['Boolean']['output']; }; +export type FeedbackStatisticsRecordType = { + __typename?: 'FeedbackStatisticsRecordType'; + _id: Scalars['ID']['output']; + circle_id: Scalars['ID']['output']; + course_session_id: Scalars['ID']['output']; + details_url: Scalars['String']['output']; + experts: Scalars['String']['output']; + generation: Scalars['String']['output']; + satisfaction_average: Scalars['Float']['output']; + satisfaction_max: Scalars['Int']['output']; +}; + +export type FeedbackStatisticsResponsesType = { + __typename?: 'FeedbackStatisticsResponsesType'; + _id: Scalars['ID']['output']; + records: Array; + summary: FeedbackStatisticsSummaryType; +}; + +export type FeedbackStatisticsSummaryType = { + __typename?: 'FeedbackStatisticsSummaryType'; + _id: Scalars['ID']['output']; + satisfaction_average: Scalars['Float']['output']; + satisfaction_max: Scalars['Int']['output']; + total_responses: Scalars['Int']['output']; +}; + export type LearningContentAssignmentObjectType = CoursePageInterface & LearningContentInterface & { __typename?: 'LearningContentAssignmentObjectType'; assignment_type: LearnpathLearningContentAssignmentAssignmentTypeChoices; @@ -665,6 +805,34 @@ export type PerformanceCriteriaObjectType = CoursePageInterface & { translation_key: Scalars['String']['output']; }; +export type PresenceRecordStatisticsType = { + __typename?: 'PresenceRecordStatisticsType'; + _id: Scalars['ID']['output']; + circle_id: Scalars['ID']['output']; + course_session_id: Scalars['ID']['output']; + details_url: Scalars['String']['output']; + due_date: Scalars['DateTime']['output']; + generation: Scalars['String']['output']; + participants_present: Scalars['Int']['output']; + participants_total: Scalars['Int']['output']; +}; + +export type ProgressDashboardAssignmentType = { + __typename?: 'ProgressDashboardAssignmentType'; + _id: Scalars['ID']['output']; + points_achieved_count: Scalars['Int']['output']; + points_max_count: Scalars['Int']['output']; + total_count: Scalars['Int']['output']; +}; + +export type ProgressDashboardCompetenceType = { + __typename?: 'ProgressDashboardCompetenceType'; + _id: Scalars['ID']['output']; + fail_count: Scalars['Int']['output']; + success_count: Scalars['Int']['output']; + total_count: Scalars['Int']['output']; +}; + export type Query = { __typename?: 'Query'; assignment?: Maybe; @@ -672,8 +840,11 @@ export type Query = { competence_certificate?: Maybe; competence_certificate_list?: Maybe; course?: Maybe; + course_progress?: Maybe; course_session?: Maybe; course_session_attendance_course?: Maybe; + course_statistics?: Maybe; + dashboard_config: Array; learning_content_assignment?: Maybe; learning_content_attendance_course?: Maybe; learning_content_document_list?: Maybe; @@ -722,6 +893,11 @@ export type QueryCourseArgs = { }; +export type QueryCourseProgressArgs = { + course_id: Scalars['ID']['input']; +}; + + export type QueryCourseSessionArgs = { id?: InputMaybe; }; @@ -733,6 +909,11 @@ export type QueryCourseSessionAttendanceCourseArgs = { }; +export type QueryCourseStatisticsArgs = { + course_id: Scalars['ID']['input']; +}; + + export type QueryLearningPathArgs = { course_id?: InputMaybe; course_slug?: InputMaybe; @@ -747,6 +928,34 @@ export type SendFeedbackMutation = { feedback_response?: Maybe; }; +export type StatisticsCircleDataType = { + __typename?: 'StatisticsCircleDataType'; + id: Scalars['ID']['output']; + name: Scalars['String']['output']; +}; + +export type StatisticsCourseSessionDataType = { + __typename?: 'StatisticsCourseSessionDataType'; + id: Scalars['ID']['output']; + name: Scalars['String']['output']; +}; + +export type StatisticsCourseSessionPropertiesType = { + __typename?: 'StatisticsCourseSessionPropertiesType'; + _id: Scalars['ID']['output']; + circles: Array; + generations: Array; + sessions: Array; +}; + +export type StatisticsCourseSessionsSelectionMetricType = { + __typename?: 'StatisticsCourseSessionsSelectionMetricType'; + _id: Scalars['ID']['output']; + expert_count: Scalars['Int']['output']; + participant_count: Scalars['Int']['output']; + session_count: Scalars['Int']['output']; +}; + export type TopicObjectType = CoursePageInterface & { __typename?: 'TopicObjectType'; circles: Array; @@ -909,7 +1118,7 @@ export type CourseSessionDetailQueryVariables = Exact<{ }>; -export type CourseSessionDetailQuery = { __typename?: 'Query', course_session?: { __typename?: 'CourseSessionObjectType', id: string, title: string, course: { __typename?: 'CourseObjectType', id: string, title: string, slug: string }, users: Array<{ __typename?: 'CourseSessionUserObjectsType', id: string, user_id: string, first_name: string, last_name: string, email: string, avatar_url: string, role: CourseCourseSessionUserRoleChoices, circles: Array<{ __typename?: 'CourseSessionUserExpertCircleType', id: string, title: string, slug: string }> }>, attendance_courses: Array<{ __typename?: 'CourseSessionAttendanceCourseObjectType', id: string, location: string, trainer: string, learning_content_id?: string | null, due_date?: { __typename?: 'DueDateObjectType', id: string, start?: string | null, end?: string | null } | null, learning_content: { __typename?: 'LearningContentAttendanceCourseObjectType', id: string, title: string, circle?: { __typename?: 'CircleLightObjectType', id: string, title: string, slug: string } | null } }>, assignments: Array<{ __typename?: 'CourseSessionAssignmentObjectType', id: string, submission_deadline?: { __typename?: 'DueDateObjectType', id: string, start?: string | null } | null, evaluation_deadline?: { __typename?: 'DueDateObjectType', id: string, start?: string | null } | null, learning_content: { __typename?: 'LearningContentAssignmentObjectType', id: string, title: string, content_assignment: { __typename?: 'AssignmentObjectType', id: string, title: string, assignment_type: AssignmentAssignmentAssignmentTypeChoices } } }>, edoniq_tests: Array<{ __typename?: 'CourseSessionEdoniqTestObjectType', id: string, deadline?: { __typename?: 'DueDateObjectType', id: string, start?: string | null, end?: string | null } | null, learning_content: { __typename?: 'LearningContentEdoniqTestObjectType', id: string, title: string, content_assignment: { __typename?: 'AssignmentObjectType', id: string, title: string, assignment_type: AssignmentAssignmentAssignmentTypeChoices } } }> } | null }; +export type CourseSessionDetailQuery = { __typename?: 'Query', course_session?: { __typename?: 'CourseSessionObjectType', id: string, title: string, course: { __typename?: 'CourseObjectType', id: string, title: string, slug: string }, users: Array<{ __typename?: 'CourseSessionUserObjectsType', id: string, user_id: string, first_name: string, last_name: string, email: string, avatar_url: string, role: string, circles: Array<{ __typename?: 'CourseSessionUserExpertCircleType', id: string, title: string, slug: string }> }>, attendance_courses: Array<{ __typename?: 'CourseSessionAttendanceCourseObjectType', id: string, location: string, trainer: string, learning_content_id?: string | null, due_date?: { __typename?: 'DueDateObjectType', id: string, start?: string | null, end?: string | null } | null, learning_content: { __typename?: 'LearningContentAttendanceCourseObjectType', id: string, title: string, circle?: { __typename?: 'CircleLightObjectType', id: string, title: string, slug: string } | null } }>, assignments: Array<{ __typename?: 'CourseSessionAssignmentObjectType', id: string, submission_deadline?: { __typename?: 'DueDateObjectType', id: string, start?: string | null } | null, evaluation_deadline?: { __typename?: 'DueDateObjectType', id: string, start?: string | null } | null, learning_content: { __typename?: 'LearningContentAssignmentObjectType', id: string, title: string, content_assignment: { __typename?: 'AssignmentObjectType', id: string, title: string, assignment_type: AssignmentAssignmentAssignmentTypeChoices } } }>, edoniq_tests: Array<{ __typename?: 'CourseSessionEdoniqTestObjectType', id: string, deadline?: { __typename?: 'DueDateObjectType', id: string, start?: string | null, end?: string | null } | null, learning_content: { __typename?: 'LearningContentEdoniqTestObjectType', id: string, title: string, content_assignment: { __typename?: 'AssignmentObjectType', id: string, title: string, assignment_type: AssignmentAssignmentAssignmentTypeChoices } } }> } | null }; export type CourseQueryQueryVariables = Exact<{ slug: Scalars['String']['input']; @@ -978,6 +1187,25 @@ export type CourseQueryQuery = { __typename?: 'Query', course?: { __typename?: ' & { ' $fragmentRefs'?: { 'CoursePageFieldsLearningPathObjectTypeFragment': CoursePageFieldsLearningPathObjectTypeFragment } } ) } | null }; +export type DashboardConfigQueryVariables = Exact<{ [key: string]: never; }>; + + +export type DashboardConfigQuery = { __typename?: 'Query', dashboard_config: Array<{ __typename?: 'DashboardConfigType', id: string, slug: string, name: string, dashboard_type: DashboardType }> }; + +export type DashboardProgressQueryVariables = Exact<{ + courseId: Scalars['ID']['input']; +}>; + + +export type DashboardProgressQuery = { __typename?: 'Query', course_progress?: { __typename?: 'CourseProgressType', _id: string, course_id: string, session_to_continue_id?: string | null, competence: { __typename?: 'ProgressDashboardCompetenceType', _id: string, total_count: number, success_count: number, fail_count: number }, assignment: { __typename?: 'ProgressDashboardAssignmentType', _id: string, total_count: number, points_max_count: number, points_achieved_count: number } } | null }; + +export type CourseStatisticsQueryVariables = Exact<{ + courseId: Scalars['ID']['input']; +}>; + + +export type CourseStatisticsQuery = { __typename?: 'Query', course_statistics?: { __typename?: 'CourseStatisticsType', _id: string, course_id: string, course_title: string, course_slug: string, course_session_selection_ids: Array, course_session_properties: { __typename?: 'StatisticsCourseSessionPropertiesType', _id: string, generations: Array, sessions: Array<{ __typename?: 'StatisticsCourseSessionDataType', id: string, name: string }>, circles: Array<{ __typename?: 'StatisticsCircleDataType', id: string, name: string }> }, course_session_selection_metrics: { __typename?: 'StatisticsCourseSessionsSelectionMetricType', _id: string, session_count: number, participant_count: number, expert_count: number }, attendance_day_presences: { __typename?: 'AttendanceDayPresencesStatisticsType', _id: string, records: Array<{ __typename?: 'PresenceRecordStatisticsType', _id: string, course_session_id: string, generation: string, circle_id: string, due_date: string, participants_present: number, participants_total: number, details_url: string }>, summary: { __typename?: 'AttendanceSummaryStatisticsType', _id: string, days_completed: number, participants_present: number } }, feedback_responses: { __typename?: 'FeedbackStatisticsResponsesType', _id: string, records: Array<{ __typename?: 'FeedbackStatisticsRecordType', _id: string, course_session_id: string, generation: string, circle_id: string, experts: string, satisfaction_average: number, satisfaction_max: number, details_url: string }>, summary: { __typename?: 'FeedbackStatisticsSummaryType', _id: string, satisfaction_average: number, satisfaction_max: number, total_responses: number } }, assignments: { __typename?: 'AssignmentsStatisticsType', _id: string, summary: { __typename?: 'AssignmentStatisticsSummaryType', _id: string, completed_count: number, average_passed: number }, records: Array<{ __typename?: 'AssignmentStatisticsRecordType', _id: string, course_session_id: string, course_session_assignment_id: string, circle_id: string, generation: string, assignment_title: string, assignment_type_translation_key: string, details_url: string, deadline: string, metrics: { __typename?: 'AssignmentCompletionMetricsType', _id: string, passed_count: number, failed_count: number, unranked_count: number, ranking_completed: boolean, average_passed: number } }> }, competences: { __typename?: 'CompetencesStatisticsType', _id: string, summary: { __typename?: 'CompetencePerformanceStatisticsSummaryType', _id: string, success_total: number, fail_total: number }, records: Array<{ __typename?: 'CompetenceRecordStatisticsType', _id: string, course_session_id: string, generation: string, circle_id: string, title: string, success_count: number, fail_count: number, details_url: string }> } } | null }; + export type SendFeedbackMutationMutationVariables = Exact<{ courseSessionId: Scalars['ID']['input']; learningContentId: Scalars['ID']['input']; @@ -996,4 +1224,7 @@ export const AssignmentCompletionQueryDocument = {"kind":"Document","definitions export const CompetenceCertificateQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"competenceCertificateQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"courseSlug"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"courseSessionId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"competence_certificate_list"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"course_slug"},"value":{"kind":"Variable","name":{"kind":"Name","value":"courseSlug"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CoursePageFields"}},{"kind":"Field","name":{"kind":"Name","value":"competence_certificates"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CoursePageFields"}},{"kind":"Field","name":{"kind":"Name","value":"assignments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CoursePageFields"}},{"kind":"Field","name":{"kind":"Name","value":"assignment_type"}},{"kind":"Field","name":{"kind":"Name","value":"max_points"}},{"kind":"Field","name":{"kind":"Name","value":"completion"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"course_session_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"courseSessionId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"completion_status"}},{"kind":"Field","name":{"kind":"Name","value":"submitted_at"}},{"kind":"Field","name":{"kind":"Name","value":"evaluation_points"}},{"kind":"Field","name":{"kind":"Name","value":"evaluation_max_points"}},{"kind":"Field","name":{"kind":"Name","value":"evaluation_passed"}}]}},{"kind":"Field","name":{"kind":"Name","value":"learning_content"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CoursePageFields"}},{"kind":"Field","name":{"kind":"Name","value":"circle"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}}]}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CoursePageFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CoursePageInterface"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"content_type"}},{"kind":"Field","name":{"kind":"Name","value":"frontend_url"}}]}}]} as unknown as DocumentNode; export const CourseSessionDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"courseSessionDetail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"courseSessionId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"course_session"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"courseSessionId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"course"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}},{"kind":"Field","name":{"kind":"Name","value":"users"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user_id"}},{"kind":"Field","name":{"kind":"Name","value":"first_name"}},{"kind":"Field","name":{"kind":"Name","value":"last_name"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"avatar_url"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"circles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"attendance_courses"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"location"}},{"kind":"Field","name":{"kind":"Name","value":"trainer"}},{"kind":"Field","name":{"kind":"Name","value":"due_date"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"start"}},{"kind":"Field","name":{"kind":"Name","value":"end"}}]}},{"kind":"Field","name":{"kind":"Name","value":"learning_content_id"}},{"kind":"Field","name":{"kind":"Name","value":"learning_content"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"circle"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"submission_deadline"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"start"}}]}},{"kind":"Field","name":{"kind":"Name","value":"evaluation_deadline"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"start"}}]}},{"kind":"Field","name":{"kind":"Name","value":"learning_content"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"content_assignment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"assignment_type"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"edoniq_tests"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"deadline"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"start"}},{"kind":"Field","name":{"kind":"Name","value":"end"}}]}},{"kind":"Field","name":{"kind":"Name","value":"learning_content"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"content_assignment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"assignment_type"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const CourseQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"courseQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"slug"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"course"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"slug"},"value":{"kind":"Variable","name":{"kind":"Name","value":"slug"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"category_name"}},{"kind":"Field","name":{"kind":"Name","value":"action_competences"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"competence_id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"CoursePageFields"}},{"kind":"Field","name":{"kind":"Name","value":"performance_criteria"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"competence_id"}},{"kind":"Field","name":{"kind":"Name","value":"learning_unit"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"evaluate_url"}}]}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"CoursePageFields"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"learning_path"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CoursePageFields"}},{"kind":"Field","name":{"kind":"Name","value":"topics"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"is_visible"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"CoursePageFields"}},{"kind":"Field","name":{"kind":"Name","value":"circles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"goals"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"CoursePageFields"}},{"kind":"Field","name":{"kind":"Name","value":"learning_sequences"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"CoursePageFields"}},{"kind":"Field","name":{"kind":"Name","value":"learning_units"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"evaluate_url"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"CoursePageFields"}},{"kind":"Field","name":{"kind":"Name","value":"performance_criteria"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CoursePageFields"}}]}},{"kind":"Field","name":{"kind":"Name","value":"learning_contents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"can_user_self_toggle_course_completion"}},{"kind":"Field","name":{"kind":"Name","value":"content_url"}},{"kind":"Field","name":{"kind":"Name","value":"minutes"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"CoursePageFields"}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LearningContentAssignmentObjectType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"assignment_type"}},{"kind":"Field","name":{"kind":"Name","value":"content_assignment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"competence_certificate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CoursePageFields"}}]}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LearningContentEdoniqTestObjectType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"checkbox_text"}},{"kind":"Field","name":{"kind":"Name","value":"has_extended_time_test"}},{"kind":"Field","name":{"kind":"Name","value":"content_assignment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"competence_certificate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CoursePageFields"}}]}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LearningContentRichTextObjectType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"text"}}]}}]}}]}}]}}]}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CoursePageFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CoursePageInterface"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"content_type"}},{"kind":"Field","name":{"kind":"Name","value":"frontend_url"}}]}}]} as unknown as DocumentNode; +export const DashboardConfigDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"dashboardConfig"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dashboard_config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"dashboard_type"}}]}}]}}]} as unknown as DocumentNode; +export const DashboardProgressDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"dashboardProgress"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"courseId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"course_progress"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"course_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"courseId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_id"}},{"kind":"Field","name":{"kind":"Name","value":"course_id"}},{"kind":"Field","name":{"kind":"Name","value":"session_to_continue_id"}},{"kind":"Field","name":{"kind":"Name","value":"competence"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_id"}},{"kind":"Field","name":{"kind":"Name","value":"total_count"}},{"kind":"Field","name":{"kind":"Name","value":"success_count"}},{"kind":"Field","name":{"kind":"Name","value":"fail_count"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_id"}},{"kind":"Field","name":{"kind":"Name","value":"total_count"}},{"kind":"Field","name":{"kind":"Name","value":"points_max_count"}},{"kind":"Field","name":{"kind":"Name","value":"points_achieved_count"}}]}}]}}]}}]} as unknown as DocumentNode; +export const CourseStatisticsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"courseStatistics"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"courseId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"course_statistics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"course_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"courseId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_id"}},{"kind":"Field","name":{"kind":"Name","value":"course_id"}},{"kind":"Field","name":{"kind":"Name","value":"course_title"}},{"kind":"Field","name":{"kind":"Name","value":"course_slug"}},{"kind":"Field","name":{"kind":"Name","value":"course_session_properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_id"}},{"kind":"Field","name":{"kind":"Name","value":"sessions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"generations"}},{"kind":"Field","name":{"kind":"Name","value":"circles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"course_session_selection_ids"}},{"kind":"Field","name":{"kind":"Name","value":"course_session_selection_metrics"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_id"}},{"kind":"Field","name":{"kind":"Name","value":"session_count"}},{"kind":"Field","name":{"kind":"Name","value":"participant_count"}},{"kind":"Field","name":{"kind":"Name","value":"expert_count"}}]}},{"kind":"Field","name":{"kind":"Name","value":"attendance_day_presences"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_id"}},{"kind":"Field","name":{"kind":"Name","value":"records"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_id"}},{"kind":"Field","name":{"kind":"Name","value":"course_session_id"}},{"kind":"Field","name":{"kind":"Name","value":"generation"}},{"kind":"Field","name":{"kind":"Name","value":"circle_id"}},{"kind":"Field","name":{"kind":"Name","value":"due_date"}},{"kind":"Field","name":{"kind":"Name","value":"participants_present"}},{"kind":"Field","name":{"kind":"Name","value":"participants_total"}},{"kind":"Field","name":{"kind":"Name","value":"details_url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"summary"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_id"}},{"kind":"Field","name":{"kind":"Name","value":"days_completed"}},{"kind":"Field","name":{"kind":"Name","value":"participants_present"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"feedback_responses"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_id"}},{"kind":"Field","name":{"kind":"Name","value":"records"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_id"}},{"kind":"Field","name":{"kind":"Name","value":"course_session_id"}},{"kind":"Field","name":{"kind":"Name","value":"generation"}},{"kind":"Field","name":{"kind":"Name","value":"circle_id"}},{"kind":"Field","name":{"kind":"Name","value":"experts"}},{"kind":"Field","name":{"kind":"Name","value":"satisfaction_average"}},{"kind":"Field","name":{"kind":"Name","value":"satisfaction_max"}},{"kind":"Field","name":{"kind":"Name","value":"details_url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"summary"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_id"}},{"kind":"Field","name":{"kind":"Name","value":"satisfaction_average"}},{"kind":"Field","name":{"kind":"Name","value":"satisfaction_max"}},{"kind":"Field","name":{"kind":"Name","value":"total_responses"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_id"}},{"kind":"Field","name":{"kind":"Name","value":"summary"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_id"}},{"kind":"Field","name":{"kind":"Name","value":"completed_count"}},{"kind":"Field","name":{"kind":"Name","value":"average_passed"}}]}},{"kind":"Field","name":{"kind":"Name","value":"records"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_id"}},{"kind":"Field","name":{"kind":"Name","value":"course_session_id"}},{"kind":"Field","name":{"kind":"Name","value":"course_session_assignment_id"}},{"kind":"Field","name":{"kind":"Name","value":"circle_id"}},{"kind":"Field","name":{"kind":"Name","value":"generation"}},{"kind":"Field","name":{"kind":"Name","value":"assignment_title"}},{"kind":"Field","name":{"kind":"Name","value":"assignment_type_translation_key"}},{"kind":"Field","name":{"kind":"Name","value":"details_url"}},{"kind":"Field","name":{"kind":"Name","value":"deadline"}},{"kind":"Field","name":{"kind":"Name","value":"metrics"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_id"}},{"kind":"Field","name":{"kind":"Name","value":"passed_count"}},{"kind":"Field","name":{"kind":"Name","value":"failed_count"}},{"kind":"Field","name":{"kind":"Name","value":"unranked_count"}},{"kind":"Field","name":{"kind":"Name","value":"ranking_completed"}},{"kind":"Field","name":{"kind":"Name","value":"average_passed"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"competences"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_id"}},{"kind":"Field","name":{"kind":"Name","value":"summary"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_id"}},{"kind":"Field","name":{"kind":"Name","value":"success_total"}},{"kind":"Field","name":{"kind":"Name","value":"fail_total"}}]}},{"kind":"Field","name":{"kind":"Name","value":"records"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_id"}},{"kind":"Field","name":{"kind":"Name","value":"course_session_id"}},{"kind":"Field","name":{"kind":"Name","value":"generation"}},{"kind":"Field","name":{"kind":"Name","value":"circle_id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"success_count"}},{"kind":"Field","name":{"kind":"Name","value":"fail_count"}},{"kind":"Field","name":{"kind":"Name","value":"details_url"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const SendFeedbackMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SendFeedbackMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"courseSessionId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"learningContentId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"data"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"GenericScalar"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"submitted"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"send_feedback"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"course_session_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"courseSessionId"}}},{"kind":"Argument","name":{"kind":"Name","value":"learning_content_page_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"learningContentId"}}},{"kind":"Argument","name":{"kind":"Name","value":"data"},"value":{"kind":"Variable","name":{"kind":"Name","value":"data"}}},{"kind":"Argument","name":{"kind":"Name","value":"submitted"},"value":{"kind":"Variable","name":{"kind":"Name","value":"submitted"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"feedback_response"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"data"}},{"kind":"Field","name":{"kind":"Name","value":"submitted"}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"field"}},{"kind":"Field","name":{"kind":"Name","value":"messages"}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/client/src/gql/schema.graphql b/client/src/gql/schema.graphql index d443746f..d3ee2b9a 100644 --- a/client/src/gql/schema.graphql +++ b/client/src/gql/schema.graphql @@ -1,4 +1,7 @@ type Query { + course_statistics(course_id: ID!): CourseStatisticsType + course_progress(course_id: ID!): CourseProgressType + dashboard_config: [DashboardConfigType!]! learning_path(id: ID, slug: String, course_id: ID, course_slug: String): LearningPathObjectType course_session_attendance_course(id: ID!, assignment_user_id: ID): CourseSessionAttendanceCourseObjectType course(id: ID, slug: String): CourseObjectType @@ -19,6 +22,190 @@ type Query { assignment_completion(assignment_id: ID!, course_session_id: ID!, learning_content_page_id: ID, assignment_user_id: UUID): AssignmentCompletionObjectType } +type CourseStatisticsType { + _id: ID! + course_id: ID! + course_title: String! + course_slug: String! + course_session_properties: StatisticsCourseSessionPropertiesType! + course_session_selection_ids: [ID]! + course_session_selection_metrics: StatisticsCourseSessionsSelectionMetricType! + attendance_day_presences: AttendanceDayPresencesStatisticsType! + feedback_responses: FeedbackStatisticsResponsesType! + assignments: AssignmentsStatisticsType! + competences: CompetencesStatisticsType! +} + +type StatisticsCourseSessionPropertiesType { + _id: ID! + sessions: [StatisticsCourseSessionDataType!]! + generations: [String!]! + circles: [StatisticsCircleDataType!]! +} + +type StatisticsCourseSessionDataType { + id: ID! + name: String! +} + +type StatisticsCircleDataType { + id: ID! + name: String! +} + +type StatisticsCourseSessionsSelectionMetricType { + _id: ID! + session_count: Int! + participant_count: Int! + expert_count: Int! +} + +type AttendanceDayPresencesStatisticsType { + _id: ID! + records: [PresenceRecordStatisticsType!]! + summary: AttendanceSummaryStatisticsType! +} + +type PresenceRecordStatisticsType { + _id: ID! + course_session_id: ID! + generation: String! + circle_id: ID! + due_date: DateTime! + participants_present: Int! + participants_total: Int! + details_url: String! +} + +""" +The `DateTime` scalar type represents a DateTime +value as specified by +[iso8601](https://en.wikipedia.org/wiki/ISO_8601). +""" +scalar DateTime + +type AttendanceSummaryStatisticsType { + _id: ID! + days_completed: Int! + participants_present: Int! +} + +type FeedbackStatisticsResponsesType { + _id: ID! + records: [FeedbackStatisticsRecordType!]! + summary: FeedbackStatisticsSummaryType! +} + +type FeedbackStatisticsRecordType { + _id: ID! + course_session_id: ID! + generation: String! + circle_id: ID! + satisfaction_average: Float! + satisfaction_max: Int! + details_url: String! + experts: String! +} + +type FeedbackStatisticsSummaryType { + _id: ID! + satisfaction_average: Float! + satisfaction_max: Int! + total_responses: Int! +} + +type AssignmentsStatisticsType { + _id: ID! + records: [AssignmentStatisticsRecordType!]! + summary: AssignmentStatisticsSummaryType! +} + +type AssignmentStatisticsRecordType { + _id: ID! + course_session_id: ID! + course_session_assignment_id: ID! + circle_id: ID! + generation: String! + assignment_type_translation_key: String! + assignment_title: String! + deadline: DateTime! + metrics: AssignmentCompletionMetricsType! + details_url: String! +} + +type AssignmentCompletionMetricsType { + _id: ID! + passed_count: Int! + failed_count: Int! + unranked_count: Int! + ranking_completed: Boolean! + average_passed: Float! +} + +type AssignmentStatisticsSummaryType { + _id: ID! + completed_count: Int! + average_passed: Float! +} + +type CompetencesStatisticsType { + _id: ID! + summary: CompetencePerformanceStatisticsSummaryType! + records: [CompetenceRecordStatisticsType!]! +} + +type CompetencePerformanceStatisticsSummaryType { + _id: ID! + success_total: Int! + fail_total: Int! +} + +type CompetenceRecordStatisticsType { + _id: ID! + course_session_id: ID! + generation: String! + title: String! + circle_id: ID! + success_count: Int! + fail_count: Int! + details_url: String! +} + +type CourseProgressType { + _id: ID! + course_id: ID! + session_to_continue_id: ID + competence: ProgressDashboardCompetenceType! + assignment: ProgressDashboardAssignmentType! +} + +type ProgressDashboardCompetenceType { + _id: ID! + total_count: Int! + success_count: Int! + fail_count: Int! +} + +type ProgressDashboardAssignmentType { + _id: ID! + total_count: Int! + points_max_count: Int! + points_achieved_count: Int! +} + +type DashboardConfigType { + id: ID! + name: String! + slug: String! + dashboard_type: DashboardType! +} + +enum DashboardType { + STATISTICS_DASHBOARD + PROGRESS_DASHBOARD + SIMPLE_DASHBOARD +} + type LearningPathObjectType implements CoursePageInterface { id: ID! title: String! @@ -214,13 +401,6 @@ type DueDateObjectType { course_session: CourseSessionObjectType! } -""" -The `DateTime` scalar type represents a DateTime -value as specified by -[iso8601](https://en.wikipedia.org/wiki/ISO_8601). -""" -scalar DateTime - type CourseSessionObjectType { id: ID! created_at: DateTime! @@ -466,29 +646,24 @@ type LearningContentEdoniqTestObjectType implements CoursePageInterface & Learni has_extended_time_test: Boolean! } +""" +WORKAROUND: +Why is this no DjangoObjectType? It's because we have to "inject" +the supervisor into the list of users. This is done in the resolve_users +of the CourseSessionObjectType. And there we have to be able to construct +a CourseSessionUserObjectsType with the CIRCLES of the supervisor! +""" type CourseSessionUserObjectsType { - id: UUID! - role: CourseCourseSessionUserRoleChoices! + id: ID! user_id: UUID! first_name: String! last_name: String! email: String! avatar_url: String! + role: String! circles: [CourseSessionUserExpertCircleType!]! } -"""An enumeration.""" -enum CourseCourseSessionUserRoleChoices { - """Teilnehmer""" - MEMBER - - """Experte/Trainer""" - EXPERT - - """Lernbegleitung""" - TUTOR -} - type CourseSessionUserExpertCircleType { id: ID! title: String! diff --git a/client/src/gql/typenames.ts b/client/src/gql/typenames.ts index 2302eac2..b057f7d2 100644 --- a/client/src/gql/typenames.ts +++ b/client/src/gql/typenames.ts @@ -1,11 +1,17 @@ export const ActionCompetenceObjectType = "ActionCompetenceObjectType"; export const AssignmentAssignmentAssignmentTypeChoices = "AssignmentAssignmentAssignmentTypeChoices"; export const AssignmentAssignmentCompletionCompletionStatusChoices = "AssignmentAssignmentCompletionCompletionStatusChoices"; +export const AssignmentCompletionMetricsType = "AssignmentCompletionMetricsType"; export const AssignmentCompletionMutation = "AssignmentCompletionMutation"; export const AssignmentCompletionObjectType = "AssignmentCompletionObjectType"; export const AssignmentCompletionStatus = "AssignmentCompletionStatus"; export const AssignmentObjectType = "AssignmentObjectType"; +export const AssignmentStatisticsRecordType = "AssignmentStatisticsRecordType"; +export const AssignmentStatisticsSummaryType = "AssignmentStatisticsSummaryType"; +export const AssignmentsStatisticsType = "AssignmentsStatisticsType"; export const AttendanceCourseUserMutation = "AttendanceCourseUserMutation"; +export const AttendanceDayPresencesStatisticsType = "AttendanceDayPresencesStatisticsType"; +export const AttendanceSummaryStatisticsType = "AttendanceSummaryStatisticsType"; export const AttendanceUserInputType = "AttendanceUserInputType"; export const AttendanceUserObjectType = "AttendanceUserObjectType"; export const AttendanceUserStatus = "AttendanceUserStatus"; @@ -14,21 +20,30 @@ export const CircleLightObjectType = "CircleLightObjectType"; export const CircleObjectType = "CircleObjectType"; export const CompetenceCertificateListObjectType = "CompetenceCertificateListObjectType"; export const CompetenceCertificateObjectType = "CompetenceCertificateObjectType"; +export const CompetencePerformanceStatisticsSummaryType = "CompetencePerformanceStatisticsSummaryType"; +export const CompetenceRecordStatisticsType = "CompetenceRecordStatisticsType"; +export const CompetencesStatisticsType = "CompetencesStatisticsType"; export const CoreUserLanguageChoices = "CoreUserLanguageChoices"; -export const CourseCourseSessionUserRoleChoices = "CourseCourseSessionUserRoleChoices"; export const CourseObjectType = "CourseObjectType"; export const CoursePageInterface = "CoursePageInterface"; +export const CourseProgressType = "CourseProgressType"; export const CourseSessionAssignmentObjectType = "CourseSessionAssignmentObjectType"; export const CourseSessionAttendanceCourseObjectType = "CourseSessionAttendanceCourseObjectType"; export const CourseSessionEdoniqTestObjectType = "CourseSessionEdoniqTestObjectType"; export const CourseSessionObjectType = "CourseSessionObjectType"; export const CourseSessionUserExpertCircleType = "CourseSessionUserExpertCircleType"; export const CourseSessionUserObjectsType = "CourseSessionUserObjectsType"; +export const CourseStatisticsType = "CourseStatisticsType"; +export const DashboardConfigType = "DashboardConfigType"; +export const DashboardType = "DashboardType"; export const Date = "Date"; export const DateTime = "DateTime"; export const DueDateObjectType = "DueDateObjectType"; export const ErrorType = "ErrorType"; export const FeedbackResponseObjectType = "FeedbackResponseObjectType"; +export const FeedbackStatisticsRecordType = "FeedbackStatisticsRecordType"; +export const FeedbackStatisticsResponsesType = "FeedbackStatisticsResponsesType"; +export const FeedbackStatisticsSummaryType = "FeedbackStatisticsSummaryType"; export const Float = "Float"; export const GenericScalar = "GenericScalar"; export const ID = "ID"; @@ -52,8 +67,15 @@ export const LearningUnitObjectType = "LearningUnitObjectType"; export const LearnpathLearningContentAssignmentAssignmentTypeChoices = "LearnpathLearningContentAssignmentAssignmentTypeChoices"; export const Mutation = "Mutation"; export const PerformanceCriteriaObjectType = "PerformanceCriteriaObjectType"; +export const PresenceRecordStatisticsType = "PresenceRecordStatisticsType"; +export const ProgressDashboardAssignmentType = "ProgressDashboardAssignmentType"; +export const ProgressDashboardCompetenceType = "ProgressDashboardCompetenceType"; export const Query = "Query"; export const SendFeedbackMutation = "SendFeedbackMutation"; +export const StatisticsCircleDataType = "StatisticsCircleDataType"; +export const StatisticsCourseSessionDataType = "StatisticsCourseSessionDataType"; +export const StatisticsCourseSessionPropertiesType = "StatisticsCourseSessionPropertiesType"; +export const StatisticsCourseSessionsSelectionMetricType = "StatisticsCourseSessionsSelectionMetricType"; export const String = "String"; export const TopicObjectType = "TopicObjectType"; export const UUID = "UUID"; diff --git a/client/src/graphql/client.ts b/client/src/graphql/client.ts index 2cfbd4aa..78c1901b 100644 --- a/client/src/graphql/client.ts +++ b/client/src/graphql/client.ts @@ -1,7 +1,7 @@ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore import { cacheExchange } from "@urql/exchange-graphcache"; import { Client, fetchExchange } from "@urql/vue"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore import schema from "../gql/dist/minifiedSchema.json"; import { AssignmentCompletionMutation, diff --git a/client/src/graphql/queries.ts b/client/src/graphql/queries.ts index d995e4f3..92ff219d 100644 --- a/client/src/graphql/queries.ts +++ b/client/src/graphql/queries.ts @@ -268,3 +268,148 @@ export const COURSE_QUERY = graphql(` } } `); + +export const DASHBOARD_CONFIG = graphql(` + query dashboardConfig { + dashboard_config { + id + slug + name + dashboard_type + } + } +`); + +export const DASHBOARD_COURSE_SESSION_PROGRESS = graphql(` + query dashboardProgress($courseId: ID!) { + course_progress(course_id: $courseId) { + _id + course_id + session_to_continue_id + competence { + _id + total_count + success_count + fail_count + } + assignment { + _id + total_count + points_max_count + points_achieved_count + } + } + } +`); + +export const DASHBOARD_COURSE_STATISTICS = graphql(` + query courseStatistics($courseId: ID!) { + course_statistics(course_id: $courseId) { + _id + course_id + course_title + course_slug + course_session_properties { + _id + sessions { + id + name + } + generations + circles { + id + name + } + } + course_session_selection_ids + course_session_selection_metrics { + _id + session_count + participant_count + expert_count + } + attendance_day_presences { + _id + records { + _id + course_session_id + generation + circle_id + due_date + participants_present + participants_total + details_url + } + summary { + _id + days_completed + participants_present + } + } + feedback_responses { + _id + records { + _id + course_session_id + generation + circle_id + experts + satisfaction_average + satisfaction_max + details_url + } + summary { + _id + satisfaction_average + satisfaction_max + total_responses + } + } + assignments { + _id + summary { + _id + completed_count + average_passed + } + records { + _id + course_session_id + course_session_assignment_id + circle_id + generation + assignment_title + assignment_type_translation_key + details_url + deadline + metrics { + _id + passed_count + failed_count + unranked_count + ranking_completed + average_passed + } + } + } + competences { + _id + summary { + _id + success_total + fail_total + } + records { + _id + course_session_id + generation + circle_id + title + success_count + fail_count + details_url + } + } + } + } +`); diff --git a/client/src/pages/dashboard/CourseListPage.vue b/client/src/pages/dashboard/CourseListPage.vue new file mode 100644 index 00000000..fe76aa9e --- /dev/null +++ b/client/src/pages/dashboard/CourseListPage.vue @@ -0,0 +1,30 @@ + + + + + + + {{ config.name }} + + + {{ $t("a.Cockpit anschauen") }} + + + + + + diff --git a/client/src/pages/dashboard/DashboardPage.vue b/client/src/pages/dashboard/DashboardPage.vue new file mode 100644 index 00000000..4e9fd417 --- /dev/null +++ b/client/src/pages/dashboard/DashboardPage.vue @@ -0,0 +1,61 @@ + + + + + + + + + + + Dashboard + + + + + + + + + diff --git a/client/src/pages/dashboard/ProgressPage.vue b/client/src/pages/dashboard/ProgressPage.vue new file mode 100644 index 00000000..f607264b --- /dev/null +++ b/client/src/pages/dashboard/ProgressPage.vue @@ -0,0 +1,89 @@ + + + + + + {{ courseName }} + + + + {{ $t("general.nextStep") }} + + + + + + + + + diff --git a/client/src/pages/dashboard/SimpleCoursePage.vue b/client/src/pages/dashboard/SimpleCoursePage.vue new file mode 100644 index 00000000..e8aedb50 --- /dev/null +++ b/client/src/pages/dashboard/SimpleCoursePage.vue @@ -0,0 +1,25 @@ + + + + + + + {{ dashboardStore.currentDashboardConfig.name }} + + + {{ $t("a.Cockpit anschauen") }} + + + + + + diff --git a/client/src/pages/dashboard/StatisticPage.vue b/client/src/pages/dashboard/StatisticPage.vue new file mode 100644 index 00000000..0e1d29af --- /dev/null +++ b/client/src/pages/dashboard/StatisticPage.vue @@ -0,0 +1,66 @@ + + + + + + + + + + + + + diff --git a/client/src/pages/dashboard/statistic/AssignmentList.vue b/client/src/pages/dashboard/statistic/AssignmentList.vue new file mode 100644 index 00000000..47c47063 --- /dev/null +++ b/client/src/pages/dashboard/statistic/AssignmentList.vue @@ -0,0 +1,108 @@ + + + + + + {{ $t("a.Arbeiten") }} + + + + + + + + + {{ (item as AssignmentStatisticsRecordType).assignment_title }} + + + {{ $t("a.Durchfuehrung") }} «{{ + courseSessionName(item.course_session_id) + }}» - Circle «{{ circleMeta(item.circle_id)?.name }}» + + + {{ $t("a.Abgabetermin") }}: + {{ + getDateString( + dayjs((item as AssignmentStatisticsRecordType).deadline) + ) + }} + + + + + {{ (item as AssignmentStatisticsRecordType).metrics.passed_count }} von + {{ total((item as AssignmentStatisticsRecordType).metrics) }} + bestanden + + Noch nicht bestätigt + + + {{ $t("a.Details anschauen") }} + + + + + + + + diff --git a/client/src/pages/dashboard/statistic/AttendanceList.vue b/client/src/pages/dashboard/statistic/AttendanceList.vue new file mode 100644 index 00000000..89846c32 --- /dev/null +++ b/client/src/pages/dashboard/statistic/AttendanceList.vue @@ -0,0 +1,93 @@ + + + + + + {{ $t("Anwesenheit") }} + + + + + + + + + {{ $t("a.Präsenztag") }}: Circle «{{ + circleMeta(item.circle_id)?.name + }}» + + + {{ $t("a.Durchfuehrung") }} «{{ + courseSessionName(item.course_session_id) + }}» + + + {{ $t("a.Termin") }}: + {{ + getDateString(dayjs((item as PresenceRecordStatisticsType).due_date)) + }} + + + + + {{ + $t("a.present von total Teilnehmenden anwesend", { + present: (item as PresenceRecordStatisticsType) + .participants_present, + total: (item as PresenceRecordStatisticsType).participants_total, + }) + }} + + + + {{ $t("a.Details anschauen") }} + + + + + + + + diff --git a/client/src/pages/dashboard/statistic/CompetenceList.vue b/client/src/pages/dashboard/statistic/CompetenceList.vue new file mode 100644 index 00000000..660d9f91 --- /dev/null +++ b/client/src/pages/dashboard/statistic/CompetenceList.vue @@ -0,0 +1,71 @@ + + + + + + {{ $t("a.Selbsteinschätzung") }} + + + + + + + + + {{ $t("a.Selbsteinschätzung") }}: + {{ (item as CompetenceRecordStatisticsType).title }} + + + Durchführung «{{ courseSessionName(item.course_session_id) }}» - Circle + «{{ circleMeta(item.circle_id)?.name }}» + + + + + + + {{ (item as CompetenceRecordStatisticsType).success_count }} + + + {{ (item as CompetenceRecordStatisticsType).fail_count }} + + + {{ $t("a.Details anschauen") }} + + + + + + + + diff --git a/client/src/pages/dashboard/statistic/FeedbackList.vue b/client/src/pages/dashboard/statistic/FeedbackList.vue new file mode 100644 index 00000000..581538c7 --- /dev/null +++ b/client/src/pages/dashboard/statistic/FeedbackList.vue @@ -0,0 +1,91 @@ + + + + + + {{ $t("a.Feedback Teilnehmer") }} + + + + + + + + + Feedback: Circle «{{ circleMeta(item.circle_id)?.name }}» + + + {{ $t("a.Durchfuehrung") }} «{{ + courseSessionName(item.course_session_id) + }}» - Trainer: {{ (item as FeedbackStatisticsRecordType).experts }} + + + + + + + + + {{ + ( + item as FeedbackStatisticsRecordType + ).satisfaction_average.toFixed(1) + }} + + + + + {{ (item as FeedbackStatisticsRecordType).satisfaction_max }} + + + + + + {{ $t("a.Allgemeine Zufriedenheit") }} + + + + {{ $t("a.Details anschauen") }} + + + + + + + + diff --git a/client/src/pages/dashboard/statistic/StatisticParentPage.vue b/client/src/pages/dashboard/statistic/StatisticParentPage.vue new file mode 100644 index 00000000..db9f5724 --- /dev/null +++ b/client/src/pages/dashboard/statistic/StatisticParentPage.vue @@ -0,0 +1,23 @@ + + + + + + + + + + + {{ $t("general.back") }} + + + + + diff --git a/client/src/pages/learningPath/learningPathPage/LearningPathCircle.vue b/client/src/pages/learningPath/learningPathPage/LearningPathCircle.vue index 12f31916..7f497972 100644 --- a/client/src/pages/learningPath/learningPathPage/LearningPathCircle.vue +++ b/client/src/pages/learningPath/learningPathPage/LearningPathCircle.vue @@ -130,7 +130,7 @@ function render() { - + {{ pieData }} {{ render() }} diff --git a/client/src/router/index.ts b/client/src/router/index.ts index 805a1e9c..a9bb8728 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -1,4 +1,4 @@ -import DashboardPage from "@/pages/DashboardPage.vue"; +import DashboardPage from "@/pages/dashboard/DashboardPage.vue"; import LoginPage from "@/pages/LoginPage.vue"; import { handleCourseSessionAsQueryParam, @@ -173,6 +173,38 @@ const router = createRouter({ }, ], }, + { + path: "/statistic", + props: true, + component: () => import("@/pages/dashboard/statistic/StatisticParentPage.vue"), + children: [ + { + path: "attendance", + props: true, + component: () => import("@/pages/dashboard/statistic/AttendanceList.vue"), + }, + { + path: "assignment", + props: true, + component: () => import("@/pages/dashboard/statistic/AssignmentList.vue"), + }, + { + path: "competence", + props: true, + component: () => import("@/pages/dashboard/statistic/CompetenceList.vue"), + }, + { + path: "feedback", + props: true, + component: () => import("@/pages/dashboard/statistic/FeedbackList.vue"), + }, + { + path: "list", + props: true, + component: () => import("@/pages/dashboard/CourseListPage.vue"), + }, + ], + }, { path: "/shop", component: () => import("@/pages/ShopPage.vue"), diff --git a/client/src/services/dashboard.ts b/client/src/services/dashboard.ts new file mode 100644 index 00000000..a320774f --- /dev/null +++ b/client/src/services/dashboard.ts @@ -0,0 +1,63 @@ +import { graphqlClient } from "@/graphql/client"; +import { + DASHBOARD_CONFIG, + DASHBOARD_COURSE_SESSION_PROGRESS, + DASHBOARD_COURSE_STATISTICS, +} from "@/graphql/queries"; + +import type { + CourseProgressType, + CourseStatisticsType, + DashboardConfigType, +} from "@/gql/graphql"; + +export const fetchStatisticData = async ( + courseId: string +): Promise => { + try { + console.log("fetching statistics for course ID: ", courseId); + const res = await graphqlClient.query(DASHBOARD_COURSE_STATISTICS, { courseId }); + + if (res.error) { + console.error("Error fetching statistics for course ID:", courseId, res.error); + } + + return res.data?.course_statistics || null; + } catch (error) { + console.error(`Error fetching statistics for course ID: ${courseId}`, error); + return null; + } +}; + +export const fetchProgressData = async ( + courseId: string +): Promise => { + try { + const res = await graphqlClient.query(DASHBOARD_COURSE_SESSION_PROGRESS, { + courseId, + }); + + if (res.error) { + console.error("Error fetching progress for course ID:", courseId, res.error); + } + + return res.data?.course_progress || null; + } catch (error) { + console.error(`Error fetching progress for course ID: ${courseId}`, error); + return null; + } +}; +export const fetchDashboardConfig = async (): Promise => { + try { + const res = await graphqlClient.query(DASHBOARD_CONFIG, {}); + + if (res.error) { + console.error("Error fetching dashboard config:", res.error); + } + + return res.data?.dashboard_config || null; + } catch (error) { + console.error("Error fetching dashboard config:", error); + return null; + } +}; diff --git a/client/src/stores/dashboard.ts b/client/src/stores/dashboard.ts new file mode 100644 index 00000000..7286eec6 --- /dev/null +++ b/client/src/stores/dashboard.ts @@ -0,0 +1,84 @@ +import type { + CourseProgressType, + CourseStatisticsType, + DashboardConfigType, + DashboardType, +} from "@/gql/graphql"; +import { + fetchDashboardConfig, + fetchProgressData, + fetchStatisticData, +} from "@/services/dashboard"; +import { defineStore } from "pinia"; +import type { Ref } from "vue"; +import { ref } from "vue"; + +export const useDashboardStore = defineStore("dashboard", () => { + const dashboardConfigs: Ref = ref([]); + const currentDashboardConfig: Ref = ref(); + const dashBoardDataCache: Record< + string, + CourseStatisticsType | CourseProgressType | null + > = {}; + const currentDashBoardData: Ref = + ref(null); + const loading = ref(false); + + const loadDashboardData = async (type: DashboardType, id: string) => { + let data; + switch (type) { + case "STATISTICS_DASHBOARD": + data = await fetchStatisticData(id); + break; + case "PROGRESS_DASHBOARD": + data = await fetchProgressData(id); + break; + default: + return; + } + dashBoardDataCache[id] = data; + currentDashBoardData.value = data; + }; + + const switchAndLoadDashboardConfig = async (config: DashboardConfigType) => { + currentDashboardConfig.value = config; + await loadDashboardDetails(); + }; + + const loadDashboardConfig = async () => { + if (dashboardConfigs.value.length > 0) return; + const configData = await fetchDashboardConfig(); + if (configData) { + dashboardConfigs.value = configData; + await switchAndLoadDashboardConfig(configData[0]); + } + }; + + const loadDashboardDetails = async () => { + loading.value = true; + try { + if (!currentDashboardConfig.value) { + await loadDashboardConfig(); + return; + } + const { id, dashboard_type } = currentDashboardConfig.value; + if (dashBoardDataCache[id]) { + currentDashBoardData.value = dashBoardDataCache[id]; + return; + } + await loadDashboardData(dashboard_type, id); + } finally { + loading.value = false; + } + }; + + return { + dashboardConfigs, + currentDashboardConfig, + switchAndLoadDashboardConfig, + loadDashboardConfig, + loadDashboardDetails, + currentDashBoardData, + loading, + }; +}); diff --git a/client/src/types.ts b/client/src/types.ts index f1cb63c9..38443f8d 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -5,7 +5,6 @@ import type { AssignmentCompletionStatus as AssignmentCompletionStatusGenerated, AssignmentObjectType, CircleObjectType, - CourseCourseSessionUserRoleChoices, CourseSessionObjectType, CourseSessionUserObjectsType, LearningContentAssignmentObjectType, @@ -435,8 +434,6 @@ export interface CourseSession { due_dates: DueDate[]; } -export type Role = CourseCourseSessionUserRoleChoices; - export type CourseSessionUser = CourseSessionUserObjectsType; export interface ExpertSessionUser extends CourseSessionUser { diff --git a/client/src/utils/__tests__/ratingToColor.spec.ts b/client/src/utils/__tests__/ratingToColor.spec.ts new file mode 100644 index 00000000..7bbef770 --- /dev/null +++ b/client/src/utils/__tests__/ratingToColor.spec.ts @@ -0,0 +1,43 @@ +import { getBlendedColorForRating } from "@/utils/ratingToColor"; +import { describe, expect, it } from "vitest"; + +describe("getBlendedColorForRating", () => { + // Normal cases + it("should return red for rating 1", () => { + expect(getBlendedColorForRating(1)).toBe("rgb(221, 103, 81)"); + }); + + it("should return yellow for rating 2", () => { + expect(getBlendedColorForRating(2)).toBe("rgb(250, 200, 82)"); + }); + + it("should return light green for rating 3", () => { + expect(getBlendedColorForRating(3)).toBe("rgb(120, 222, 163)"); + }); + + it("should return dark green for rating 4", () => { + expect(getBlendedColorForRating(4)).toBe("rgb(91, 183, 130)"); + }); + + // Edge cases + it("should blend red and yellow for a rating between 1 and 2", () => { + expect(getBlendedColorForRating(1.5)).toBe("rgb(235, 151, 81)"); + }); + + it("should blend yellow and light green for a rating between 2 and 3", () => { + expect(getBlendedColorForRating(2.5)).toBe("rgb(185, 211, 122)"); + }); + + it("should blend light green and dark green for a rating between 3 and 4", () => { + expect(getBlendedColorForRating(3.5)).toBe("rgb(105, 202, 146)"); + }); + + // Ratings beyond the scale + it("should return dark green for ratings above 4", () => { + expect(getBlendedColorForRating(4.5)).toBe("rgb(91, 183, 130)"); + }); + + it("should return grey for ratings below 1", () => { + expect(getBlendedColorForRating(0.5)).toBe("rgb(237, 242, 246)"); + }); +}); diff --git a/client/src/utils/ratingToColor.ts b/client/src/utils/ratingToColor.ts new file mode 100644 index 00000000..dc5a2917 --- /dev/null +++ b/client/src/utils/ratingToColor.ts @@ -0,0 +1,52 @@ +type RGB = [number, number, number]; + +const grey: RGB = [237, 242, 246]; // grey-200 +const red: RGB = [221, 103, 81]; // red-600 +const yellow: RGB = [250, 200, 82]; // yellow-500 +const lightGreen: RGB = [120, 222, 163]; // green-500 +const darkGreen: RGB = [91, 183, 130]; // green-600 + +const blendColorValue = (v1: number, v2: number, weight: number): number => { + return v1 * (1 - weight) + v2 * weight; +}; + +const blendColors = (c1: RGB, c2: RGB, weight: number): RGB => { + const [r1, g1, b1] = c1; + const [r2, g2, b2] = c2; + return [ + blendColorValue(r1, r2, weight), + blendColorValue(g1, g2, weight), + blendColorValue(b1, b2, weight), + ]; +}; + +const getRGBString = (color: RGB): string => { + const [r, g, b] = color; + return `rgb(${Math.floor(r)}, ${Math.floor(g)}, ${Math.floor(b)})`; +}; + +export const getBlendedColorForRating = (rating: number): string => { + const scale = Math.floor(rating); + const weight = rating % 1; + let colors: [RGB, RGB]; + + switch (scale) { + case 0: + return getRGBString(grey); + case 1: + colors = [red, yellow]; + break; + case 2: + colors = [yellow, lightGreen]; + break; + case 3: + colors = [lightGreen, darkGreen]; + break; + case 4: + default: + return getRGBString(darkGreen); + } + + const blendedColor = blendColors(colors[0], colors[1], weight); + return getRGBString(blendedColor); +}; diff --git a/cypress/e2e/dashboard/dashboardSupervisor.cy.js b/cypress/e2e/dashboard/dashboardSupervisor.cy.js new file mode 100644 index 00000000..14fd2bae --- /dev/null +++ b/cypress/e2e/dashboard/dashboardSupervisor.cy.js @@ -0,0 +1,101 @@ +import {login} from "../helpers"; + +const getDashboardStatistics = (what) => { + return cy.get(`[data-cy="dashboard.stats.${what}"]`); +}; + +const clickOnDetailsLink = (within) => { + cy.get(`[data-cy="dashboard.stats.${within}"]`).within(() => { + cy.get('[data-cy="basebox.detailsLink"]').click(); + }) +}; + +describe("dashboardSupervisor.cy.js", () => { + beforeEach(() => { + cy.manageCommand("cypress_reset --create-assignment-evaluation --create-feedback-responses --create-course-completion-performance-criteria --create-attendance-days"); + login("test-supervisor1@example.com", "test"); + cy.visit("/"); + }); + + describe("assignment summary box", () => { + it("contains correct numbers", () => { + // we have no completed assignments, but some are in progress + // -> makes sure that the numbers are correct + getDashboardStatistics("assignments.completed").should("have.text", "0"); + getDashboardStatistics("assignments.passed").should("have.text", "0%"); + }); + + it("contains correct details link", () => { + clickOnDetailsLink("assignments"); + + // might be improved: roughly check + // that the correct data is displayed + cy.contains("Noch nicht bestätigt"); + cy.contains("Fahrzeug - Mein erstes Auto"); + cy.contains("Test Bern 2022 a"); + }); + }); + + describe("attendance day summary box", () => { + it("contains correct numbers", () => { + getDashboardStatistics("attendance.dayCompleted").should("have.text", "1"); + getDashboardStatistics("attendance.participantsPresent").should("have.text", "34%"); + }); + it("contains correct details link", () => { + clickOnDetailsLink("attendance"); + cy.url().should("contain", "/statistic/attendance"); + + // might be improved: roughly check + // that the correct data is displayed + cy.contains("Durchführung «Test Bern 2022 a»"); + cy.contains("Circle «Fahrzeug»"); + cy.contains("Termin: 31. Oktober 2000"); + }); + }); + + + describe("overall summary box", () => { + it("contains correct numbers (members, experts etc.)", () => { + getDashboardStatistics("participant.count").should("have.text", "4"); + getDashboardStatistics("expert.count").should("have.text", "2"); + getDashboardStatistics("session.count").should("have.text", "2"); + }); + }); + + describe("feedback summary box", () => { + it("contains correct numbers", () => { + getDashboardStatistics("feedback.average").should("have.text", "3.3"); + getDashboardStatistics("feedback.count").should("have.text", "3"); + + }); + it("contains correct details link", () => { + clickOnDetailsLink("feedback"); + cy.url().should("contain", "/statistic/feedback"); + + // might be improved: roughly check + // that the correct data is displayed + cy.contains("3.3 von 4"); + cy.contains("Test Trainer1"); + cy.contains("Durchführung «Test Bern 2022 a»") + cy.contains("Circle «Fahrzeug»") + }); + }); + + describe("competence summary box", () => { + it("contains correct numbers", () => { + getDashboardStatistics("competence.success").should("have.text", "1"); + getDashboardStatistics("competence.fail").should("have.text", "0"); + }); + it("contains correct details link", () => { + clickOnDetailsLink("competence"); + cy.url().should("contain", "/statistic/competence"); + + // might be improved: roughly check + // that the correct data is displayed + cy.contains("Selbsteinschätzung: Vorbereitung"); + cy.contains("Durchführung «Test Bern 2022 a»"); + cy.contains("Circle «Fahrzeug»") + }); + }); + +}); diff --git a/cypress/e2e/login.cy.js b/cypress/e2e/login.cy.js index 9846b735..1fdc3e2c 100644 --- a/cypress/e2e/login.cy.js +++ b/cypress/e2e/login.cy.js @@ -19,14 +19,14 @@ describe("login.cy.js", () => { cy.get('[data-cy="login-button"]').click(); cy.request("/api/core/me").its("status").should("eq", 200); - cy.get('[data-cy="welcome-message"]').should("contain", "Willkommen, Test"); + cy.get('[data-cy="dashboard-title"]').should("contain", "Dashboard"); }); it("can login with helper function", () => { login("test-student1@example.com", "test"); cy.visit("/"); cy.request("/api/core/me").its("status").should("eq", 200); - cy.get('[data-cy="welcome-message"]').should("contain", "Willkommen, Test"); + cy.get('[data-cy="dashboard-title"]').should("contain", "Dashboard"); }); it("login will redirect to requestet page", () => { @@ -40,7 +40,7 @@ describe("login.cy.js", () => { cy.get('[data-cy="learning-path-title"]').should( "contain", - "Test Lehrgang" + "Test Lehrgang", ); }); }); diff --git a/server/config/settings/base.py b/server/config/settings/base.py index a1f174d0..7e80cd01 100644 --- a/server/config/settings/base.py +++ b/server/config/settings/base.py @@ -129,6 +129,7 @@ LOCAL_APPS = [ "vbv_lernwelt.duedate", "vbv_lernwelt.importer", "vbv_lernwelt.edoniq_test", + "vbv_lernwelt.course_session_group", ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS diff --git a/server/vbv_lernwelt/assignment/graphql/mutations.py b/server/vbv_lernwelt/assignment/graphql/mutations.py index 73b6de7f..2d9394ca 100644 --- a/server/vbv_lernwelt/assignment/graphql/mutations.py +++ b/server/vbv_lernwelt/assignment/graphql/mutations.py @@ -10,7 +10,7 @@ from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletionStatu from vbv_lernwelt.assignment.services import update_assignment_completion from vbv_lernwelt.core.models import User from vbv_lernwelt.course.models import CourseSession, CourseSessionUser -from vbv_lernwelt.course.permissions import has_course_access, is_course_session_expert +from vbv_lernwelt.iam.permissions import has_course_access, is_course_session_expert logger = structlog.get_logger(__name__) diff --git a/server/vbv_lernwelt/assignment/graphql/types.py b/server/vbv_lernwelt/assignment/graphql/types.py index 33687377..021a2226 100644 --- a/server/vbv_lernwelt/assignment/graphql/types.py +++ b/server/vbv_lernwelt/assignment/graphql/types.py @@ -7,7 +7,7 @@ from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletion from vbv_lernwelt.core.graphql.types import JSONStreamField from vbv_lernwelt.course.graphql.interfaces import CoursePageInterface from vbv_lernwelt.course.models import CourseSession -from vbv_lernwelt.course.permissions import has_course_access, is_course_session_expert +from vbv_lernwelt.iam.permissions import has_course_access, is_course_session_expert from vbv_lernwelt.learnpath.graphql.types import LearningContentInterface diff --git a/server/vbv_lernwelt/assignment/views.py b/server/vbv_lernwelt/assignment/views.py index 205c1321..f686ae51 100644 --- a/server/vbv_lernwelt/assignment/views.py +++ b/server/vbv_lernwelt/assignment/views.py @@ -4,7 +4,7 @@ from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response from vbv_lernwelt.assignment.models import AssignmentCompletion -from vbv_lernwelt.course.permissions import is_course_session_expert +from vbv_lernwelt.iam.permissions import is_course_session_expert logger = structlog.get_logger(__name__) diff --git a/server/vbv_lernwelt/core/constants.py b/server/vbv_lernwelt/core/constants.py index 137f3dc0..243e544a 100644 --- a/server/vbv_lernwelt/core/constants.py +++ b/server/vbv_lernwelt/core/constants.py @@ -18,6 +18,7 @@ DEFAULT_RICH_TEXT_FEATURES_WITH_HEADER = [ # ids for cypress test data ADMIN_USER_ID = "872efd96-3bd7-4a1e-a239-2d72cad9f604" +TEST_SUPERVISOR1_USER_ID = "a9a8b741-f115-4521-af2d-7dfef673b8c5" TEST_TRAINER1_USER_ID = "b9e71f59-c44f-4290-b93a-9b3151e9a2fc" TEST_TRAINER2_USER_ID = "299941ae-1e4b-4f45-8180-876c3ad340b4" TEST_STUDENT1_USER_ID = "65c73ad0-6d53-43a9-a4a4-64143f27b03a" diff --git a/server/vbv_lernwelt/core/create_default_users.py b/server/vbv_lernwelt/core/create_default_users.py index a3cbc6c8..b4aae634 100644 --- a/server/vbv_lernwelt/core/create_default_users.py +++ b/server/vbv_lernwelt/core/create_default_users.py @@ -10,6 +10,7 @@ from vbv_lernwelt.core.constants import ( TEST_STUDENT1_USER_ID, TEST_STUDENT2_USER_ID, TEST_STUDENT3_USER_ID, + TEST_SUPERVISOR1_USER_ID, TEST_TRAINER1_USER_ID, TEST_TRAINER2_USER_ID, ) @@ -67,15 +68,33 @@ default_users = [ ] -def create_default_users(user_model=User, group_model=Group, default_password=None): - if default_password is None: - default_password = "test" - - admin_group, created = group_model.objects.get_or_create(name="admin_group") - _content_creator_grop, _created = group_model.objects.get_or_create( +def create_default_users(default_password="test"): + admin_group, created = Group.objects.get_or_create(name="admin_group") + _content_creator_grop, _created = Group.objects.get_or_create( name="content_creator_grop" ) - student_group, created = group_model.objects.get_or_create(name="student_group") + student_group, created = Group.objects.get_or_create(name="student_group") + + def _create_user( + _id, + email, + first_name, + last_name, + avatar_url, + language, + password, + ): + user, _ = User.objects.get_or_create( + id=_id, + username=email, + email=email, + language=language, + first_name=first_name, + last_name=last_name, + avatar_url=avatar_url, + password=make_password(password), + ) + return user def _create_student_user( email, @@ -86,43 +105,52 @@ def create_default_users(user_model=User, group_model=Group, default_password=No language="de", id=None, ): - student_user, created = _get_or_create_user( - user_model=user_model, - username=email, - password=password, + student_user = _create_user( + email=email, + first_name=first_name, + last_name=last_name, + avatar_url=avatar_url, language=language, - id=id, + password=password, + _id=id, ) - student_user.first_name = first_name - student_user.last_name = last_name - student_user.avatar_url = avatar_url + student_user.groups.add(student_group) student_user.save() def _create_admin_user( email, first_name, last_name, avatar_url="", id=None, password=default_password ): - admin_user, created = _get_or_create_user( - user_model=user_model, username=email, password=password, id=id + admin_user = _create_user( + email=email, + first_name=first_name, + last_name=last_name, + avatar_url=avatar_url, + password=password, + language="de", + _id=id, ) + + admin_user.groups.add(admin_group) admin_user.is_superuser = True admin_user.is_staff = True - admin_user.first_name = first_name - admin_user.last_name = last_name - admin_user.avatar_url = avatar_url - admin_user.groups.add(admin_group) admin_user.save() def _create_staff_user( email, first_name, last_name, id=None, password=default_password ): - staff_user, created = _get_or_create_user( - user_model=user_model, username=email, password=password, id=id + staff_user = _create_user( + _id=id, + email=email, + first_name=first_name, + last_name=last_name, + avatar_url="", + language="de", + password=password, ) - staff_user.is_staff = True - staff_user.first_name = first_name - staff_user.last_name = last_name + staff_user.groups.add(_get_or_create_vbv_staff_group()) + staff_user.is_staff = True staff_user.save() _create_admin_user( @@ -305,6 +333,15 @@ def create_default_users(user_model=User, group_model=Group, default_password=No first_name="Matthias", last_name="Wirth", ) + _create_user( + _id=TEST_SUPERVISOR1_USER_ID, + email="test-supervisor1@example.com", + first_name="[Supervisor]", + last_name="Regionalleiter", + password=default_password, + language="de", + avatar_url="", + ) def _get_or_create_user(user_model, *args, **kwargs): @@ -319,7 +356,6 @@ def _get_or_create_user(user_model, *args, **kwargs): if not user: user = user_model.objects.create( username=username, - password=make_password(password), email=username, language=language, id=id, diff --git a/server/vbv_lernwelt/core/management/commands/cypress_reset.py b/server/vbv_lernwelt/core/management/commands/cypress_reset.py index 12fc46b6..f45a9211 100644 --- a/server/vbv_lernwelt/core/management/commands/cypress_reset.py +++ b/server/vbv_lernwelt/core/management/commands/cypress_reset.py @@ -1,6 +1,10 @@ +from datetime import datetime + import djclick as click +from django.utils import timezone from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletion +from vbv_lernwelt.competence.models import PerformanceCriteria from vbv_lernwelt.core.constants import ( TEST_COURSE_SESSION_BERN_ID, TEST_STUDENT1_USER_ID, @@ -15,9 +19,19 @@ from vbv_lernwelt.course.creators.test_course import ( create_test_assignment_evaluation_data, create_test_assignment_submitted_data, ) -from vbv_lernwelt.course.models import CourseCompletion, CourseSession +from vbv_lernwelt.course.models import ( + CourseCompletion, + CourseCompletionStatus, + CourseSession, +) +from vbv_lernwelt.course.services import mark_course_completion +from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse +from vbv_lernwelt.course_session.services.attendance import AttendanceUserStatus from vbv_lernwelt.feedback.models import FeedbackResponse -from vbv_lernwelt.learnpath.models import LearningContentFeedback +from vbv_lernwelt.learnpath.models import ( + LearningContentAttendanceCourse, + LearningContentFeedback, +) from vbv_lernwelt.notify.models import Notification @@ -49,18 +63,32 @@ from vbv_lernwelt.notify.models import Notification default=False, help="will create feedback response data", ) +@click.option( + "--create-course-completion-performance-criteria/--no-create-course-completion-performance-criteria", + default=False, + help="will create course completion performance criteria data", +) +@click.option( + "--create-attendance-days/--no-create-attendance-days", + default=False, + help="will create attendance days data", +) def command( create_assignment_completion, create_assignment_evaluation, assignment_evaluation_scores, create_edoniq_test_results, create_feedback_responses, + create_course_completion_performance_criteria, + create_attendance_days, ): print("cypress reset data") CourseCompletion.objects.all().delete() Notification.objects.all().delete() AssignmentCompletion.objects.all().delete() FeedbackResponse.objects.all().delete() + CourseSessionAttendanceCourse.objects.all().update(attendance_user_list=[]) + User.objects.all().update(language="de") User.objects.all().update(additional_json_data={}) @@ -171,3 +199,47 @@ def command( "course_positive_feedback": "Die Präsentation war super", }, ) + + if create_course_completion_performance_criteria: + member = User.objects.get(id=TEST_STUDENT1_USER_ID) + course_session = CourseSession.objects.get(id=TEST_COURSE_SESSION_BERN_ID) + page = PerformanceCriteria.objects.get( + slug="test-lehrgang-competencenavi-competences-crit-x11-allgemein" + ) + + mark_course_completion( + page=page, + user=member, + course_session=course_session, + completion_status=CourseCompletionStatus.SUCCESS.value, + ) + + if create_attendance_days: + course_session = CourseSession.objects.get(id=TEST_COURSE_SESSION_BERN_ID) + + attendance_user_list = [ + { + "user_id": TEST_STUDENT1_USER_ID, + "status": AttendanceUserStatus.PRESENT.value, + }, + { + "user_id": TEST_STUDENT2_USER_ID, + "status": AttendanceUserStatus.ABSENT.value, + }, + ] + + attendance_course = CourseSessionAttendanceCourse.objects.get( + course_session=course_session, + learning_content=LearningContentAttendanceCourse.objects.get( + slug="test-lehrgang-lp-circle-fahrzeug-lc-präsenzkurs-fahrzeug" + ), + ) + + attendance_course.attendance_user_list = attendance_user_list + attendance_course.due_date.start = timezone.make_aware( + datetime(year=2000, month=10, day=31, hour=8) + ) + attendance_course.due_date.end = timezone.make_aware( + datetime(year=2000, month=10, day=31, hour=11) + ) + attendance_course.save() diff --git a/server/vbv_lernwelt/core/schema.py b/server/vbv_lernwelt/core/schema.py index c336e533..be4de6f0 100644 --- a/server/vbv_lernwelt/core/schema.py +++ b/server/vbv_lernwelt/core/schema.py @@ -6,6 +6,7 @@ from vbv_lernwelt.competence.graphql.queries import CompetenceCertificateQuery from vbv_lernwelt.course.graphql.queries import CourseQuery from vbv_lernwelt.course_session.graphql.mutations import CourseSessionMutation from vbv_lernwelt.course_session.graphql.queries import CourseSessionQuery +from vbv_lernwelt.dashboard.graphql.queries import DashboardQuery from vbv_lernwelt.feedback.graphql.mutations import FeedbackMutation from vbv_lernwelt.learnpath.graphql.queries import LearningPathQuery @@ -16,6 +17,7 @@ class Query( CourseQuery, CourseSessionQuery, LearningPathQuery, + DashboardQuery, graphene.ObjectType, ): pass diff --git a/server/vbv_lernwelt/core/serializers.py b/server/vbv_lernwelt/core/serializers.py index 76363c88..523d70ec 100644 --- a/server/vbv_lernwelt/core/serializers.py +++ b/server/vbv_lernwelt/core/serializers.py @@ -1,8 +1,11 @@ +from typing import List + from rest_framework import serializers from rest_framework.renderers import JSONRenderer from vbv_lernwelt.core.models import User from vbv_lernwelt.course.models import CourseSessionUser +from vbv_lernwelt.course_session_group.models import CourseSessionGroup def create_json_from_objects(objects, serializer_class, many=True) -> str: @@ -35,9 +38,17 @@ class UserSerializer(serializers.ModelSerializer): "username", ] - def get_course_session_experts(self, obj): - qs = CourseSessionUser.objects.filter( - role=CourseSessionUser.Role.EXPERT, user=obj + def get_course_session_experts(self, obj: User) -> List[str]: + supervisor_in_session_ids = set( + CourseSessionGroup.objects.filter(supervisor=obj).values_list( + "course_session__id", flat=True + ) ) - return [str(csu.course_session.id) for csu in qs] + expert_in_session_ids = set( + CourseSessionUser.objects.filter( + role=CourseSessionUser.Role.EXPERT, user=obj + ).values_list("course_session__id", flat=True) + ) + + return [str(_id) for _id in (supervisor_in_session_ids | expert_in_session_ids)] diff --git a/server/vbv_lernwelt/core/views.py b/server/vbv_lernwelt/core/views.py index 08584092..64bb11c4 100644 --- a/server/vbv_lernwelt/core/views.py +++ b/server/vbv_lernwelt/core/views.py @@ -153,11 +153,9 @@ def cypress_reset_view(request): if assignment_evaluation_scores: options["assignment_evaluation_scores"] = assignment_evaluation_scores - create_feedback_responses = ( + options["create_feedback_responses"] = ( request.data.get("create_feedback_responses") == "true" ) - if create_feedback_responses: - options["create_feedback_responses"] = create_feedback_responses # edoniq test results edoniq_test_user_points = request.data.get("edoniq_test_user_points") @@ -168,6 +166,14 @@ def cypress_reset_view(request): int(edoniq_test_max_points), ) + options["create_course_completion_performance_criteria"] = ( + request.data.get("create_course_completion_performance_criteria") == "true" + ) + + options["create_attendance_days"] = ( + request.data.get("create_attendance_days") == "true" + ) + call_command( "cypress_reset", **options, diff --git a/server/vbv_lernwelt/course/creators/test_course.py b/server/vbv_lernwelt/course/creators/test_course.py index da40d742..0f7dbaf2 100644 --- a/server/vbv_lernwelt/course/creators/test_course.py +++ b/server/vbv_lernwelt/course/creators/test_course.py @@ -34,6 +34,11 @@ from vbv_lernwelt.competence.models import ActionCompetence from vbv_lernwelt.core.constants import ( TEST_COURSE_SESSION_BERN_ID, TEST_COURSE_SESSION_ZURICH_ID, + TEST_STUDENT1_USER_ID, + TEST_STUDENT2_USER_ID, + TEST_STUDENT3_USER_ID, + TEST_SUPERVISOR1_USER_ID, + TEST_TRAINER1_USER_ID, ) from vbv_lernwelt.core.models import User from vbv_lernwelt.course.consts import COURSE_TEST_ID @@ -51,6 +56,7 @@ from vbv_lernwelt.course_session.models import ( CourseSessionAttendanceCourse, CourseSessionEdoniqTest, ) +from vbv_lernwelt.course_session_group.models import CourseSessionGroup from vbv_lernwelt.feedback.services import update_feedback_response from vbv_lernwelt.learnpath.models import ( Circle, @@ -191,10 +197,18 @@ def create_test_course(include_uk=True, include_vv=True, with_sessions=False): start_date=now, ) - trainer1 = User.objects.get(email="test-trainer1@example.com") + region1 = CourseSessionGroup.objects.create( + name="Region 1", + course=course, + ) + + region1.course_session.add(cs_bern) + region1.course_session.add(cs_zurich) + region1.supervisor.set([User.objects.get(id=TEST_SUPERVISOR1_USER_ID)]) + csu = CourseSessionUser.objects.create( course_session=cs_bern, - user=trainer1, + user=User.objects.get(id=TEST_TRAINER1_USER_ID), role=CourseSessionUser.Role.EXPERT, ) csu.expert.add(Circle.objects.get(slug="test-lehrgang-lp-circle-fahrzeug")) @@ -207,27 +221,18 @@ def create_test_course(include_uk=True, include_vv=True, with_sessions=False): ) csu.expert.add(Circle.objects.get(slug="test-lehrgang-lp-circle-fahrzeug")) - student1 = User.objects.get(email="test-student1@example.com") - _csu = CourseSessionUser.objects.create( - course_session=cs_bern, - user=student1, + CourseSessionUser.objects.create( + course_session=cs_bern, user=User.objects.get(id=TEST_STUDENT1_USER_ID) ) - student2 = User.objects.get(email="test-student2@example.com") - _csu = CourseSessionUser.objects.create( - course_session=cs_bern, - user=student2, - ) - student2 = User.objects.get(email="test-student2@example.com") - _csu = CourseSessionUser.objects.create( - course_session=cs_zurich, - user=student2, - ) + # in both sessions (BE and ZH) + test_student_2 = User.objects.get(id=TEST_STUDENT2_USER_ID) + CourseSessionUser.objects.create(course_session=cs_bern, user=test_student_2) + CourseSessionUser.objects.create(course_session=cs_zurich, user=test_student_2) - student3 = User.objects.get(email="test-student3@example.com") - _csu = CourseSessionUser.objects.create( + CourseSessionUser.objects.create( course_session=cs_bern, - user=student3, + user=User.objects.get(id=TEST_STUDENT3_USER_ID), ) return course diff --git a/server/vbv_lernwelt/course/creators/test_utils.py b/server/vbv_lernwelt/course/creators/test_utils.py new file mode 100644 index 00000000..67df0fd4 --- /dev/null +++ b/server/vbv_lernwelt/course/creators/test_utils.py @@ -0,0 +1,301 @@ +from datetime import datetime, timedelta +from typing import List, Tuple + +from django.contrib.auth.hashers import make_password +from django.utils import timezone + +from vbv_lernwelt.assignment.models import ( + Assignment, + AssignmentCompletion, + AssignmentCompletionStatus, + AssignmentType, +) +from vbv_lernwelt.assignment.tests.assignment_factories import ( + AssignmentFactory, + AssignmentListPageFactory, +) +from vbv_lernwelt.competence.factories import ( + ActionCompetenceFactory, + ActionCompetenceListPageFactory, + CompetenceNaviPageFactory, + PerformanceCriteriaFactory, +) +from vbv_lernwelt.competence.models import PerformanceCriteria +from vbv_lernwelt.core.models import User +from vbv_lernwelt.course.factories import CoursePageFactory +from vbv_lernwelt.course.models import ( + Course, + CourseCategory, + CoursePage, + CourseSession, + CourseSessionUser, +) +from vbv_lernwelt.course.utils import get_wagtail_default_site +from vbv_lernwelt.course_session.models import ( + CourseSessionAssignment, + CourseSessionAttendanceCourse, + CourseSessionEdoniqTest, +) +from vbv_lernwelt.course_session_group.models import CourseSessionGroup +from vbv_lernwelt.duedate.models import DueDate +from vbv_lernwelt.learnpath.models import ( + Circle, + LearningContentAssignment, + LearningContentEdoniqTest, + LearningPath, +) +from vbv_lernwelt.learnpath.tests.learning_path_factories import ( + CircleFactory, + LearningContentAssignmentFactory, + LearningContentAttendanceCourseFactory, + LearningContentEdoniqTestFactory, + LearningPathFactory, + LearningUnitFactory, + TopicFactory, +) + + +def create_course(title: str) -> Tuple[Course, CoursePage]: + course = Course.objects.create(title=title, category_name="Handlungsfeld") + + course_page = CoursePageFactory( + title="Test Lehrgang", + parent=get_wagtail_default_site().root_page, + course=course, + ) + course.slug = course_page.slug + course.save() + + return course, course_page + + +def create_user(username: str) -> User: + return User.objects.create_user( + username=username, + password=make_password("test"), + email=f"{username}@example.com", + language="de", + first_name="Test", + last_name=username.capitalize(), + ) + + +def create_course_session( + course: Course, title: str, generation: str = "2023" +) -> CourseSession: + return CourseSession.objects.create( + course=course, + title=title, + import_id=title, + generation=generation, + start_date=timezone.now(), + ) + + +def add_course_session_user( + course_session: CourseSession, user: User, role: CourseSessionUser.Role +) -> CourseSessionUser: + return CourseSessionUser.objects.create( + course_session=course_session, + user=user, + role=role, + ) + + +def create_course_session_group(course_session: CourseSession) -> CourseSessionGroup: + group = CourseSessionGroup.objects.create( + course=course_session.course, + ) + + group.course_session.add(course_session) + + return group + + +def add_course_session_group_supervisor(group: CourseSessionGroup, user: User): + group.supervisor.add(user) + + +def add_course_session_group_course_session( + group: CourseSessionGroup, course_session: CourseSession +): + group.course_session.add(course_session) + + +def create_circle( + title: str, course_page: CoursePage, learning_path: LearningPath | None = None +) -> Tuple[Circle, LearningPath]: + if not learning_path: + learning_path = LearningPathFactory(title="Test Lernpfad", parent=course_page) + + TopicFactory(title="Circle Test Topic", is_visible=False, parent=learning_path) + + circle = CircleFactory( + title=title, parent=learning_path, description="Circle Description" + ) + + return circle, learning_path + + +def create_attendance_course( + course_session: CourseSession, + circle: Circle, + attendance_user_list: List, + due_date_end: datetime, +) -> CourseSessionAttendanceCourse: + learning_content_dummy = LearningContentAttendanceCourseFactory( + title="Lerninhalt Dummy", + parent=circle, + ) + + return CourseSessionAttendanceCourse.objects.create( + course_session=course_session, + learning_content=learning_content_dummy, + attendance_user_list=attendance_user_list, + due_date=DueDate.objects.create( + course_session=course_session, + start=due_date_end - timedelta(hours=8), + end=due_date_end, + ), + ) + + +def create_assignment( + course: Course, + assignment_type: AssignmentType, +) -> Assignment: + return AssignmentFactory( + parent=AssignmentListPageFactory( + parent=course.coursepage, + ), + assignment_type=assignment_type.name, + title=f"Dummy Assignment ({assignment_type.name})", + effort_required=":)", + intro_text=":)", + performance_objectives=[], + ) + + +def create_assignment_completion( + user: User, + assignment: Assignment, + course_session: CourseSession, + has_passed: bool | None = None, + max_points: int = 0, + achieved_points: int = 0, + status: AssignmentCompletionStatus = AssignmentCompletionStatus.EVALUATION_SUBMITTED, +) -> AssignmentCompletion: + return AssignmentCompletion.objects.create( + completion_status=status.value, + assignment_user=user, + assignment=assignment, + evaluation_passed=has_passed, + course_session=course_session, + completion_data={}, + evaluation_max_points=max_points, + evaluation_points=achieved_points, + ) + + +def create_assignment_learning_content( + circle: Circle, + assignment: Assignment, +) -> LearningContentAssignment | LearningContentEdoniqTest: + if AssignmentType(assignment.assignment_type) == AssignmentType.EDONIQ_TEST: + return LearningContentEdoniqTestFactory( + title="Learning Content (EDONIQ_TEST)", + parent=circle, + content_assignment=assignment, + ) + + return LearningContentAssignmentFactory( + title=f"Learning Content ({assignment.assignment_type})", + parent=circle, + content_assignment=assignment, + ) + + +def create_course_session_assignment( + course_session: CourseSession, + learning_content_assignment: LearningContentAssignment, + deadline_at: datetime | None = None, +) -> CourseSessionAssignment: + cas = CourseSessionAssignment.objects.create( + course_session=course_session, + learning_content=learning_content_assignment, + ) + + if deadline_at: + # the save on the course_session_assignment already sets a lot + # of due date fields, so it's easier to just overwrite the this + cas.submission_deadline.start = timezone.make_aware(deadline_at) + cas.submission_deadline.save() + + return cas + + +def create_course_session_edoniq_test( + course_session: CourseSession, + learning_content_edoniq_test: LearningContentEdoniqTest, + deadline_at: datetime, +) -> CourseSessionEdoniqTest: + cset = CourseSessionEdoniqTest.objects.create( + course_session=course_session, + learning_content=learning_content_edoniq_test, + ) + + # same as above (see create_course_session_assignment) + cset.deadline.start = timezone.make_aware(deadline_at) + cset.deadline.save() + + return cset + + +def create_performance_criteria_page( + course: Course, + course_page: CoursePage, + circle: Circle, +) -> PerformanceCriteria: + competence_navi_page = CompetenceNaviPageFactory( + title="Competence Navi", + parent=course_page, + ) + + competence_profile_page = ActionCompetenceListPageFactory( + title="Action Competence Page", + parent=competence_navi_page, + ) + + action_competence = ActionCompetenceFactory( + parent=competence_profile_page, + competence_id="X1", + title="Action Competence", + items=[("item", "Action Competence Item")], + ) + + cat, _ = CourseCategory.objects.get_or_create( + course=course, title="Course Category" + ) + + lu = LearningUnitFactory(title="Learning Unit", parent=circle, course_category=cat) + + return PerformanceCriteriaFactory( + parent=action_competence, + competence_id="X1.1", + title="Performance Criteria", + learning_unit=lu, + ) + + +def create_circle_expert( + course_session: CourseSession, circle: Circle, username: str +) -> CourseSessionUser: + expert_user = create_user(username) + course_session_expert_user = add_course_session_user( + course_session=course_session, + user=expert_user, + role=CourseSessionUser.Role.EXPERT, + ) + course_session_expert_user.expert.add(circle) + + return course_session_expert_user diff --git a/server/vbv_lernwelt/course/graphql/queries.py b/server/vbv_lernwelt/course/graphql/queries.py index b12b45e8..c81e0567 100644 --- a/server/vbv_lernwelt/course/graphql/queries.py +++ b/server/vbv_lernwelt/course/graphql/queries.py @@ -4,7 +4,7 @@ from graphql import GraphQLError from vbv_lernwelt.course.graphql.types import CourseObjectType, CourseSessionObjectType from vbv_lernwelt.course.models import Course, CourseSession -from vbv_lernwelt.course.permissions import has_course_access +from vbv_lernwelt.iam.permissions import has_course_access from vbv_lernwelt.learnpath.graphql.types import ( LearningContentAssignmentObjectType, LearningContentAttendanceCourseObjectType, diff --git a/server/vbv_lernwelt/course/graphql/types.py b/server/vbv_lernwelt/course/graphql/types.py index cb3883b2..190f2f99 100644 --- a/server/vbv_lernwelt/course/graphql/types.py +++ b/server/vbv_lernwelt/course/graphql/types.py @@ -1,4 +1,4 @@ -from typing import Type +from typing import List, Type import graphene import structlog @@ -16,7 +16,6 @@ from vbv_lernwelt.course.models import ( CourseSession, CourseSessionUser, ) -from vbv_lernwelt.course.permissions import has_course_access from vbv_lernwelt.course_session.graphql.types import ( CourseSessionAssignmentObjectType, CourseSessionAttendanceCourseObjectType, @@ -27,7 +26,10 @@ from vbv_lernwelt.course_session.models import ( CourseSessionAttendanceCourse, CourseSessionEdoniqTest, ) +from vbv_lernwelt.course_session_group.models import CourseSessionGroup +from vbv_lernwelt.iam.permissions import has_course_access from vbv_lernwelt.learnpath.graphql.types import LearningPathObjectType +from vbv_lernwelt.learnpath.models import Circle logger = structlog.get_logger(__name__) @@ -109,50 +111,26 @@ class CourseSessionUserExpertCircleType(ObjectType): slug = graphene.String(required=True) -class CourseSessionUserObjectsType(DjangoObjectType): +class CourseSessionUserObjectsType(ObjectType): + """ + WORKAROUND: + Why is this no DjangoObjectType? It's because we have to "inject" + the supervisor into the list of users. This is done in the resolve_users + of the CourseSessionObjectType. And there we have to be able to construct + a CourseSessionUserObjectsType with the CIRCLES of the supervisor! + """ + + id = graphene.ID(required=True) user_id = graphene.UUID(required=True) first_name = graphene.String(required=True) last_name = graphene.String(required=True) email = graphene.String(required=True) avatar_url = graphene.String(required=True) - # role = graphene.String(required=True) + role = graphene.String(required=True) circles = graphene.List( graphene.NonNull(CourseSessionUserExpertCircleType), required=True ) - class Meta: - model = CourseSessionUser - fields = ( - "id", - "user_id", - "first_name", - "last_name", - "email", - "avatar_url", - "role", - ) - - def resolve_user_id(self, info): - return self.user.id - - def resolve_first_name(self, info): - return self.user.first_name - - def resolve_last_name(self, info): - return self.user.last_name - - def resolve_email(self, info): - return self.user.email - - def resolve_avatar_url(self, info): - return self.user.avatar_url - - def resolve_role(self, info): - return self.role - - def resolve_circles(self, info): - return self.expert.all().values("id", "title", "slug", "translation_key") - class CircleDocumentObjectType(DjangoObjectType): file_name = graphene.String() @@ -211,4 +189,69 @@ class CourseSessionObjectType(DjangoObjectType): return CourseSessionEdoniqTest.objects.filter(course_session=self) def resolve_users(self, info): - return CourseSessionUser.objects.filter(course_session_id=self.id).distinct() + course_session_users_resolved: List[CourseSessionUserObjectsType] = [] + + # happy path, members and experts + for course_session_user in CourseSessionUser.objects.filter( + course_session_id=self.id + ).distinct(): + course_session_users_resolved.append( + CourseSessionUserObjectsType( + id=course_session_user.id, # noqa + user_id=course_session_user.user.id, # noqa + first_name=course_session_user.user.first_name, # noqa + last_name=course_session_user.user.last_name, # noqa + email=course_session_user.user.email, # noqa + avatar_url=course_session_user.user.avatar_url, # noqa + role=course_session_user.role, # noqa + circles=[ # noqa + CourseSessionUserExpertCircleType( # noqa + id=circle.id, # noqa + title=circle.title, # noqa + slug=circle.slug, # noqa + ) + for circle in course_session_user.expert.all() # noqa + ], + ) + ) + + # workaround for supervisor + # add supervisor to the list of users (as expert) + course_session_id = self.id # noqa + user = info.context.user # noqa + + if CourseSessionGroup.objects.filter( + course_session=course_session_id, supervisor=user + ).exists(): + if course_session := CourseSession.objects.filter( + id=course_session_id + ).first(): + circles = ( + course_session.course.get_learning_path() + .get_descendants() + .live() + .specific() + .exact_type(Circle) + ) + + course_session_users_resolved.append( + CourseSessionUserObjectsType( + id=f"{user.id}-as-ephemeral-supervisor", # noqa + user_id=user.id, # noqa + first_name=user.first_name, # noqa + last_name=user.last_name, # noqa + email=user.email, # noqa + avatar_url=user.avatar_url, # noqa + role=CourseSessionUser.Role.EXPERT, # noqa + circles=[ # noqa + CourseSessionUserExpertCircleType( # noqa + id=circle.id, # noqa + title=circle.title, # noqa + slug=circle.slug, # noqa + ) + for circle in circles + ], + ) + ) + + return course_session_users_resolved diff --git a/server/vbv_lernwelt/course/views.py b/server/vbv_lernwelt/course/views.py index 3fce8b6b..b5a084c8 100644 --- a/server/vbv_lernwelt/course/views.py +++ b/server/vbv_lernwelt/course/views.py @@ -6,19 +6,7 @@ from rest_framework.response import Response from wagtail.models import Page from vbv_lernwelt.core.utils import get_django_content_type -from vbv_lernwelt.course.models import ( - CircleDocument, - CourseCompletion, - CourseSession, - CourseSessionUser, -) -from vbv_lernwelt.course.permissions import ( - course_sessions_for_user_qs, - has_course_access, - has_course_access_by_page_request, - is_circle_expert, - is_course_session_expert, -) +from vbv_lernwelt.course.models import CircleDocument, CourseCompletion, CourseSession from vbv_lernwelt.course.serializers import ( CourseCompletionSerializer, CourseSessionSerializer, @@ -26,8 +14,16 @@ from vbv_lernwelt.course.serializers import ( DocumentUploadStartInputSerializer, ) from vbv_lernwelt.course.services import mark_course_completion +from vbv_lernwelt.course_session_group.models import CourseSessionGroup from vbv_lernwelt.files.models import UploadFile from vbv_lernwelt.files.services import FileDirectUploadService +from vbv_lernwelt.iam.permissions import ( + course_sessions_for_user_qs, + has_course_access, + has_course_access_by_page_request, + is_circle_expert, + is_course_session_expert, +) logger = structlog.get_logger(__name__) @@ -134,11 +130,24 @@ def mark_course_completion_view(request): @api_view(["GET"]) def get_course_sessions(request): try: - course_sessions = course_sessions_for_user_qs(request.user).prefetch_related( - "course" - ) + # participant/member/expert course sessions + regular_course_sessions = course_sessions_for_user_qs( + request.user + ).prefetch_related("course") + + # enrich with supervisor course sessions + supervisor_course_sessions = CourseSession.objects.filter( + id__in=CourseSessionGroup.objects.filter( + supervisor=request.user + ).values_list("course_session", flat=True) + ).prefetch_related("course") + + all_to_serialize = ( + regular_course_sessions | supervisor_course_sessions + ).distinct() + return Response( - status=200, data=CourseSessionSerializer(course_sessions, many=True).data + status=200, data=CourseSessionSerializer(all_to_serialize, many=True).data ) except PermissionDenied as e: raise e diff --git a/server/vbv_lernwelt/course_session/graphql/mutations.py b/server/vbv_lernwelt/course_session/graphql/mutations.py index 189817db..1d00013b 100644 --- a/server/vbv_lernwelt/course_session/graphql/mutations.py +++ b/server/vbv_lernwelt/course_session/graphql/mutations.py @@ -2,7 +2,6 @@ import graphene import structlog from rest_framework.exceptions import PermissionDenied -from vbv_lernwelt.course.permissions import has_course_access from vbv_lernwelt.course_session.graphql.types import ( CourseSessionAttendanceCourseObjectType, ) @@ -11,6 +10,7 @@ from vbv_lernwelt.course_session.services.attendance import ( AttendanceUserStatus, update_attendance_list, ) +from vbv_lernwelt.iam.permissions import has_course_access logger = structlog.get_logger(__name__) diff --git a/server/vbv_lernwelt/course_session/graphql/queries.py b/server/vbv_lernwelt/course_session/graphql/queries.py index 899cd93a..33c544e4 100644 --- a/server/vbv_lernwelt/course_session/graphql/queries.py +++ b/server/vbv_lernwelt/course_session/graphql/queries.py @@ -2,11 +2,11 @@ import graphene from rest_framework.exceptions import PermissionDenied from vbv_lernwelt.course.models import CourseSession -from vbv_lernwelt.course.permissions import has_course_access, is_course_session_expert from vbv_lernwelt.course_session.graphql.types import ( CourseSessionAttendanceCourseObjectType, ) from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse +from vbv_lernwelt.iam.permissions import has_course_access, is_course_session_expert class CourseSessionQuery(object): diff --git a/server/vbv_lernwelt/course_session/graphql/types.py b/server/vbv_lernwelt/course_session/graphql/types.py index 56360a23..d4f4d3ac 100644 --- a/server/vbv_lernwelt/course_session/graphql/types.py +++ b/server/vbv_lernwelt/course_session/graphql/types.py @@ -1,7 +1,6 @@ import graphene from graphene_django import DjangoObjectType -from vbv_lernwelt.course.permissions import is_course_session_expert from vbv_lernwelt.course_session.models import ( CourseSessionAssignment, CourseSessionAttendanceCourse, @@ -9,6 +8,7 @@ from vbv_lernwelt.course_session.models import ( ) from vbv_lernwelt.course_session.services.attendance import AttendanceUserStatus from vbv_lernwelt.duedate.graphql.types import DueDateObjectType +from vbv_lernwelt.iam.permissions import is_course_session_expert from vbv_lernwelt.learnpath.graphql.types import ( LearningContentAssignmentObjectType, LearningContentAttendanceCourseObjectType, diff --git a/server/vbv_lernwelt/course_session/views.py b/server/vbv_lernwelt/course_session/views.py index 6cf3dbfc..ac4af629 100644 --- a/server/vbv_lernwelt/course_session/views.py +++ b/server/vbv_lernwelt/course_session/views.py @@ -3,8 +3,8 @@ from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response from vbv_lernwelt.course.models import CircleDocument -from vbv_lernwelt.course.permissions import has_course_session_access from vbv_lernwelt.course.serializers import CircleDocumentSerializer +from vbv_lernwelt.iam.permissions import has_course_session_access @api_view(["GET"]) diff --git a/server/vbv_lernwelt/course_session_group/__init__.py b/server/vbv_lernwelt/course_session_group/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/course_session_group/admin.py b/server/vbv_lernwelt/course_session_group/admin.py new file mode 100644 index 00000000..5bda3780 --- /dev/null +++ b/server/vbv_lernwelt/course_session_group/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin + +from vbv_lernwelt.course_session_group.models import CourseSessionGroup + + +@admin.register(CourseSessionGroup) +class CourseSessionAssignmentAdmin(admin.ModelAdmin): + ... diff --git a/server/vbv_lernwelt/course_session_group/apps.py b/server/vbv_lernwelt/course_session_group/apps.py new file mode 100644 index 00000000..5283ad81 --- /dev/null +++ b/server/vbv_lernwelt/course_session_group/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class CourseSessionGroupConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "vbv_lernwelt.course_session_group" + + def ready(self): + import vbv_lernwelt.course_session_group.signals # noqa F401 diff --git a/server/vbv_lernwelt/course_session_group/migrations/0001_initial.py b/server/vbv_lernwelt/course_session_group/migrations/0001_initial.py new file mode 100644 index 00000000..ca230768 --- /dev/null +++ b/server/vbv_lernwelt/course_session_group/migrations/0001_initial.py @@ -0,0 +1,49 @@ +# Generated by Django 3.2.20 on 2023-10-23 14:53 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("course", "0004_auto_20230823_1744"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="CourseSessionGroup", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ( + "course", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="course.course" + ), + ), + ( + "course_session", + models.ManyToManyField(blank=True, to="course.CourseSession"), + ), + ( + "supervisor", + models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL), + ), + ], + options={ + "ordering": ["name"], + }, + ), + ] diff --git a/server/vbv_lernwelt/course_session_group/migrations/__init__.py b/server/vbv_lernwelt/course_session_group/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/course_session_group/models.py b/server/vbv_lernwelt/course_session_group/models.py new file mode 100644 index 00000000..9ae705d3 --- /dev/null +++ b/server/vbv_lernwelt/course_session_group/models.py @@ -0,0 +1,25 @@ +from django.db import models + +from vbv_lernwelt.core.models import User + + +class CourseSessionGroup(models.Model): + name = models.CharField(max_length=255) + + course = models.ForeignKey("course.Course", on_delete=models.CASCADE) + + course_session = models.ManyToManyField( + "course.CourseSession", + blank=True, + ) + + supervisor = models.ManyToManyField( + User, + blank=True, + ) + + class Meta: + ordering = ["name"] + + def __str__(self): + return self.name diff --git a/server/vbv_lernwelt/course_session_group/signals.py b/server/vbv_lernwelt/course_session_group/signals.py new file mode 100644 index 00000000..3443e828 --- /dev/null +++ b/server/vbv_lernwelt/course_session_group/signals.py @@ -0,0 +1,16 @@ +from django.core.exceptions import ValidationError +from django.db.models.signals import m2m_changed +from django.dispatch import receiver + +from .models import CourseSessionGroup + + +@receiver(m2m_changed, sender=CourseSessionGroup.course_session.through) +def validate_course(sender, instance, action, reverse, model, pk_set, **kwargs): + if action == "pre_add": + course_sessions = model.objects.filter(pk__in=pk_set) + for session in course_sessions: + if session.course != instance.course: + raise ValidationError( + "CourseSession does not match the Course of this Group." + ) diff --git a/server/vbv_lernwelt/course_session_group/tests.py b/server/vbv_lernwelt/course_session_group/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/server/vbv_lernwelt/course_session_group/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/server/vbv_lernwelt/dashboard/__init__.py b/server/vbv_lernwelt/dashboard/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/dashboard/graphql/__init__.py b/server/vbv_lernwelt/dashboard/graphql/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/dashboard/graphql/queries.py b/server/vbv_lernwelt/dashboard/graphql/queries.py new file mode 100644 index 00000000..ca278d15 --- /dev/null +++ b/server/vbv_lernwelt/dashboard/graphql/queries.py @@ -0,0 +1,221 @@ +from typing import Dict, List, Set, Tuple + +import graphene + +from vbv_lernwelt.assignment.models import ( + AssignmentCompletion, + AssignmentCompletionStatus, +) +from vbv_lernwelt.core.admin import User +from vbv_lernwelt.course.models import Course, CourseSession, CourseSessionUser +from vbv_lernwelt.course_session_group.models import CourseSessionGroup +from vbv_lernwelt.dashboard.graphql.types.competence import competences +from vbv_lernwelt.dashboard.graphql.types.dashboard import ( + CourseProgressType, + CourseStatisticsType, + DashboardConfigType, + DashboardType, + ProgressDashboardAssignmentType, + ProgressDashboardCompetenceType, +) +from vbv_lernwelt.iam.permissions import ( + can_view_course_session, + can_view_course_session_group_statistics, + can_view_course_session_progress, +) + + +class DashboardQuery(graphene.ObjectType): + course_statistics = graphene.Field( + CourseStatisticsType, course_id=graphene.ID(required=True) + ) + + course_progress = graphene.Field( + CourseProgressType, course_id=graphene.ID(required=True) + ) + + dashboard_config = graphene.List( + graphene.NonNull(DashboardConfigType), required=True + ) + + def resolve_course_statistics(root, info, course_id: str): # noqa + user = info.context.user + course = Course.objects.get(id=course_id) + + course_session_ids = set() + + for group in CourseSessionGroup.objects.filter(course=course): + if can_view_course_session_group_statistics(user=user, group=group): + course_session_ids.update( + group.course_session.all().values_list("id", flat=True) + ) + + if not course_session_ids: + return None + + return CourseStatisticsType( + _id=course.id, # noqa + course_id=course.id, # noqa + course_title=course.title, # noqa + course_slug=course.slug, # noqa + course_session_selection_ids=list(course_session_ids), # noqa + ) + + def resolve_dashboard_config(root, info): # noqa + user = info.context.user + + if user.is_superuser: + courses = Course.objects.all().values("id", "title", "slug") + return [ + { + "id": c["id"], + "course_id": c["id"], + "name": c["title"], + "slug": c["slug"], + "dashboard_type": DashboardType.SIMPLE_DASHBOARD, + } + for c in courses + ] + + ( + statistic_dashboards, + statistics_dashboard_course_ids, + ) = get_user_statistics_dashboards(user=user) + + course_session_dashboards = get_user_course_session_dashboards( + user=user, exclude_course_ids=statistics_dashboard_course_ids + ) + + return statistic_dashboards + course_session_dashboards + + def resolve_course_progress(root, info, course_id: str): # noqa + """ + Slightly fragile but could be good enough: most only have one + course session per course anyway but if there are multiple, we + just pick the newest one (by generation) as best guess. + """ + + user = info.context.user + course = Course.objects.get(id=course_id) + + newest: CourseSession | None = None + course_session_for_user: List[str] = [] + + # generation + for course_session in CourseSession.objects.filter(course_id=course_id): + if can_view_course_session_progress( + user=user, course_session=course_session + ): + course_session_for_user.append(course_session) + generation_newest = newest.generation if newest else None + if ( + generation_newest is None + or course_session.generation > generation_newest + ): + newest = course_session + + # competence + _, success_total, fail_total = competences( + course_slug=str(course.slug), + course_session_selection_ids=course_session_for_user, + ) + + # assignment + evaluation_results = AssignmentCompletion.objects.filter( + completion_status=AssignmentCompletionStatus.EVALUATION_SUBMITTED.value, + assignment_user=user, + course_session__course=course, + ).values("evaluation_max_points", "evaluation_points") + + evaluation_results = list(evaluation_results) + points_max_count = sum( + [result.get("evaluation_max_points", 0) for result in evaluation_results] + ) + points_achieved_count = sum( + [result.get("evaluation_points", 0) for result in evaluation_results] + ) + + return CourseProgressType( + _id=course_id, # noqa + course_id=course_id, # noqa + session_to_continue_id=newest.id if newest else None, # noqa + competence=ProgressDashboardCompetenceType( # noqa + _id=course_id, # noqa + total_count=success_total + fail_total, # noqa + success_count=success_total, # noqa + fail_count=fail_total, # noqa + ), + assignment=ProgressDashboardAssignmentType( # noqa + _id=course_id, # noqa + total_count=len(evaluation_results), # noqa + points_max_count=points_max_count, # noqa + points_achieved_count=points_achieved_count, # noqa + ), + ) + + +def get_user_statistics_dashboards(user: User) -> Tuple[List[Dict[str, str]], Set[int]]: + course_index = set() + dashboards = [] + + for group in CourseSessionGroup.objects.all(): + if can_view_course_session_group_statistics(user=user, group=group): + course = group.course + course_index.add(course) + dashboards.append( + { + "id": str(course.id), + "name": course.title, + "slug": course.slug, + "dashboard_type": DashboardType.STATISTICS_DASHBOARD, + } + ) + + return dashboards, course_index + + +def get_user_course_session_dashboards( + user: User, exclude_course_ids: Set[int] +) -> List[Dict[str, str]]: + """ + Edge case: what do we show to users with access to multiple + sessions of a course, but with varying permissions? + -> We just show the simple list dashboard for now. + """ + + dashboards = [] + + course_sessions = CourseSession.objects.exclude(course__in=exclude_course_ids) + roles_by_course: Dict[Course, Set[DashboardType]] = {} + + for course_session in course_sessions: + if can_view_course_session(user=user, course_session=course_session): + role = CourseSessionUser.objects.get( + course_session=course_session, user=user + ).role + roles_by_course.setdefault(course_session.course, set()) + roles_by_course[course_session.course].add(role) + + for course, roles in roles_by_course.items(): + resolved_dashboard_type = None + + if len(roles) == 1: + course_role = roles.pop() + if course_role == CourseSessionUser.Role.EXPERT: + resolved_dashboard_type = DashboardType.SIMPLE_DASHBOARD + elif course_role == CourseSessionUser.Role.MEMBER: + resolved_dashboard_type = DashboardType.PROGRESS_DASHBOARD + else: + # fallback: just go with simple list dashboard + resolved_dashboard_type = DashboardType.SIMPLE_DASHBOARD + + dashboards.append( + { + "id": str(course.id), + "name": course.title, + "slug": course.slug, + "dashboard_type": resolved_dashboard_type, + } + ) + + return dashboards diff --git a/server/vbv_lernwelt/dashboard/graphql/types/__init__.py b/server/vbv_lernwelt/dashboard/graphql/types/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/dashboard/graphql/types/assignment.py b/server/vbv_lernwelt/dashboard/graphql/types/assignment.py new file mode 100644 index 00000000..f95fdcac --- /dev/null +++ b/server/vbv_lernwelt/dashboard/graphql/types/assignment.py @@ -0,0 +1,172 @@ +import math +from typing import List + +import graphene + +import vbv_lernwelt.assignment.models +from vbv_lernwelt.assignment.models import ( + AssignmentCompletion, + AssignmentCompletionStatus, + AssignmentType, +) +from vbv_lernwelt.course.models import CourseSession, CourseSessionUser +from vbv_lernwelt.course_session.models import ( + CourseSessionAssignment, + CourseSessionEdoniqTest, +) + + +class AssignmentCompletionMetricsType(graphene.ObjectType): + _id = graphene.ID(required=True) + passed_count = graphene.Int(required=True) + failed_count = graphene.Int(required=True) + unranked_count = graphene.Int(required=True) + ranking_completed = graphene.Boolean(required=True) + average_passed = graphene.Float(required=True) + + +class AssignmentStatisticsRecordType(graphene.ObjectType): + _id = graphene.ID(required=True) + course_session_id = graphene.ID(required=True) + course_session_assignment_id = graphene.ID(required=True) + circle_id = graphene.ID(required=True) + generation = graphene.String(required=True) + assignment_type_translation_key = graphene.String(required=True) + assignment_title = graphene.String(required=True) + deadline = graphene.DateTime(required=True) + metrics = graphene.Field(AssignmentCompletionMetricsType, required=True) + details_url = graphene.String(required=True) + + +class AssignmentStatisticsSummaryType(graphene.ObjectType): + _id = graphene.ID(required=True) + completed_count = graphene.Int(required=True) + average_passed = graphene.Float(required=True) + + +class AssignmentsStatisticsType(graphene.ObjectType): + _id = graphene.ID(required=True) + records = graphene.List( + graphene.NonNull(AssignmentStatisticsRecordType), required=True + ) + summary = graphene.Field(AssignmentStatisticsSummaryType, required=True) + + +def create_assignment_summary(course_id, metrics) -> AssignmentStatisticsSummaryType: + completed_metrics = [m for m in metrics if m.ranking_completed] + + if not completed_metrics: + return AssignmentStatisticsSummaryType( + _id=course_id, completed_count=0, average_passed=0 # noqa + ) + + completed_count = len(completed_metrics) + + average_passed_completed = ( + sum([m.average_passed for m in completed_metrics]) / completed_count + ) + + return AssignmentStatisticsSummaryType( + _id=course_id, # noqa + completed_count=completed_count, # noqa + average_passed=average_passed_completed, # noqa + ) + + +def get_assignment_completion_metrics( + course_session: CourseSession, assignment: vbv_lernwelt.assignment.models.Assignment +) -> AssignmentCompletionMetricsType: + course_session_users = CourseSessionUser.objects.filter( + course_session=course_session, + role=CourseSessionUser.Role.MEMBER, + ).values_list("user", flat=True) + + evaluation_results = AssignmentCompletion.objects.filter( + completion_status=AssignmentCompletionStatus.EVALUATION_SUBMITTED.value, + assignment_user__in=course_session_users, + course_session=course_session, + assignment=assignment, + ).values_list("evaluation_passed", flat=True) + + passed_count = len([passed for passed in evaluation_results if passed]) + failed_count = len(evaluation_results) - passed_count + + participants_count = len(course_session_users) + unranked_count = participants_count - passed_count - failed_count + + if participants_count == 0: + average_passed = 0 + else: + average_passed = math.ceil(passed_count / participants_count * 100) + + return AssignmentCompletionMetricsType( + _id=assignment.id, # noqa + passed_count=passed_count, # noqa + failed_count=failed_count, # noqa + unranked_count=unranked_count, # noqa + ranking_completed=unranked_count == 0, # noqa + average_passed=average_passed, # noqa + ) + + +def create_record( + course_session_assignment: CourseSessionAssignment | CourseSessionEdoniqTest, +) -> AssignmentStatisticsRecordType: + if isinstance(course_session_assignment, CourseSessionAssignment): + due_date = course_session_assignment.submission_deadline + else: + due_date = course_session_assignment.deadline + + learning_content = course_session_assignment.learning_content + + return AssignmentStatisticsRecordType( + # make sure it's unique, across all types of assignments! + _id=f"{course_session_assignment._meta.model_name}#{course_session_assignment.id}", # noqa + course_session_id=str(course_session_assignment.course_session.id), # noqa + circle_id=learning_content.get_circle().id, # noqa + course_session_assignment_id=str(course_session_assignment.id), # noqa + generation=course_session_assignment.course_session.generation, # noqa + assignment_type_translation_key=due_date.assignment_type_translation_key, # noqa + assignment_title=learning_content.content_assignment.title, # noqa + metrics=get_assignment_completion_metrics( # noqa + course_session=course_session_assignment.course_session, # noqa + assignment=learning_content.content_assignment, # noqa + ), + details_url=due_date.url_expert, # noqa + deadline=due_date.start, # noqa + ) + + +def assignments( + course_id: graphene.ID(required=True), + course_session_selection_ids: graphene.List(graphene.ID), +) -> AssignmentsStatisticsType: + course_sessions = CourseSession.objects.filter( + id__in=course_session_selection_ids, + ) + records: List[AssignmentStatisticsRecordType] = [] + + for course_session in course_sessions: + for csa in CourseSessionAssignment.objects.filter( + course_session=course_session, + learning_content__assignment_type__in=[ + AssignmentType.CASEWORK.value, + AssignmentType.PREP_ASSIGNMENT.value, + ], + ): + record = create_record(course_session_assignment=csa) + records.append(record) + + for cset in CourseSessionEdoniqTest.objects.filter( + course_session=course_session + ): + record = create_record(course_session_assignment=cset) + records.append(record) + + return AssignmentsStatisticsType( + _id=course_id, # noqa + records=sorted(records, key=lambda r: r.deadline), # noqa + summary=create_assignment_summary( # noqa + course_id=course_id, metrics=[r.metrics for r in records] # noqa + ), + ) diff --git a/server/vbv_lernwelt/dashboard/graphql/types/attendance.py b/server/vbv_lernwelt/dashboard/graphql/types/attendance.py new file mode 100644 index 00000000..0ef5de9a --- /dev/null +++ b/server/vbv_lernwelt/dashboard/graphql/types/attendance.py @@ -0,0 +1,109 @@ +import math +from typing import List + +import graphene +from django.utils import timezone + +from vbv_lernwelt.course.models import CourseSessionUser +from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse +from vbv_lernwelt.course_session.services.attendance import AttendanceUserStatus + + +class AttendanceSummaryStatisticsType(graphene.ObjectType): + _id = graphene.ID(required=True) + days_completed = graphene.Int(required=True) + participants_present = graphene.Int(required=True) + + +class PresenceRecordStatisticsType(graphene.ObjectType): + _id = graphene.ID(required=True) + course_session_id = graphene.ID(required=True) + generation = graphene.String(required=True) + circle_id = graphene.ID(required=True) + due_date = graphene.DateTime(required=True) + participants_present = graphene.Int(required=True) + participants_total = graphene.Int(required=True) + details_url = graphene.String(required=True) + + +class AttendanceDayPresencesStatisticsType(graphene.ObjectType): + _id = graphene.ID(required=True) + records = graphene.List( + graphene.NonNull(PresenceRecordStatisticsType), required=True + ) + summary = graphene.Field(AttendanceSummaryStatisticsType, required=True) + + +def attendance_day_presences( + course_id: graphene.ID, + course_session_selection_ids: graphene.List(graphene.ID), +) -> AttendanceDayPresencesStatisticsType: + completed = CourseSessionAttendanceCourse.objects.filter( + course_session_id__in=course_session_selection_ids, + due_date__end__lt=timezone.now(), + ).order_by("-due_date__end") + + records = [] + + for attendance_day in completed: + circle = attendance_day.learning_content.get_parent_circle() + + course_session = attendance_day.course_session + + url = f"/course/{course_session.course.slug}/cockpit/attendance?id={attendance_day.learning_content.id}&courseSessionId={course_session.id}" + + participant_user_ids = [ + str(user_id) + for user_id in CourseSessionUser.objects.filter( + course_session=course_session, role=CourseSessionUser.Role.MEMBER + ).values_list("user_id", flat=True) + ] + participants_total = len(participant_user_ids) + participants_present = len( + [ + participant + for participant in attendance_day.attendance_user_list + if participant["status"] == AttendanceUserStatus.PRESENT.value + # in the `attendance_user_list` are users present, which are not + # (anymore) in the course session -> so we need to filter them out + and participant["user_id"] in participant_user_ids + ] + ) + + records.append( + PresenceRecordStatisticsType( + _id=f"attendance_day:{attendance_day.id}", # noqa + course_session_id=course_session.id, # noqa + generation=course_session.generation, # noqa + circle_id=circle.id, # noqa + due_date=attendance_day.due_date.end, # noqa + participants_present=participants_present, # noqa + participants_total=participants_total, # noqa + details_url=url, # noqa + ) + ) + + summary = AttendanceSummaryStatisticsType( + _id=course_id, # noqa + days_completed=completed.count(), # noqa + participants_present=calculate_avg_participation(records), # noqa + ) + + return AttendanceDayPresencesStatisticsType( + summary=summary, records=records, _id=course_id # noqa + ) + + +def calculate_avg_participation(records: List[PresenceRecordStatisticsType]) -> float: + if not records: + return 0.0 + + total_ratio = 0.0 + for record in records: + if record.participants_total == 0: + continue + total_ratio += float(record.participants_present) / float( + record.participants_total + ) + + return math.ceil(total_ratio / len(records) * 100) diff --git a/server/vbv_lernwelt/dashboard/graphql/types/competence.py b/server/vbv_lernwelt/dashboard/graphql/types/competence.py new file mode 100644 index 00000000..295485c8 --- /dev/null +++ b/server/vbv_lernwelt/dashboard/graphql/types/competence.py @@ -0,0 +1,91 @@ +from typing import List, Tuple + +import graphene +from wagtail.models import Page + +from vbv_lernwelt.course.models import CourseCompletion, CourseCompletionStatus + + +class CompetencePerformanceStatisticsSummaryType(graphene.ObjectType): + _id = graphene.ID(required=True) + success_total = graphene.Int(required=True) + fail_total = graphene.Int(required=True) + + +class CompetenceRecordStatisticsType(graphene.ObjectType): + _id = graphene.ID(required=True) + course_session_id = graphene.ID(required=True) + generation = graphene.String(required=True) + title = graphene.String(required=True) + circle_id = graphene.ID(required=True) + success_count = graphene.Int(required=True) + fail_count = graphene.Int(required=True) + details_url = graphene.String(required=True) + + +class CompetencesStatisticsType(graphene.ObjectType): + _id = graphene.ID(required=True) + summary = graphene.Field(CompetencePerformanceStatisticsSummaryType, required=True) + records = graphene.List( + graphene.NonNull(CompetenceRecordStatisticsType), required=True + ) + + +def competences( + course_session_selection_ids: List[str], + course_slug: str, + user_selection_ids: List[str] | None = None, +) -> Tuple[List[CompetenceRecordStatisticsType], int, int]: + completions = CourseCompletion.objects.filter( + course_session_id__in=course_session_selection_ids, + page_type="competence.PerformanceCriteria", + ).prefetch_related("course_session", "page") + + if user_selection_ids is not None: + completions = completions.filter(user_id__in=user_selection_ids) + + competence_records = {} + + unique_page_ids = {completion.page.id for completion in completions} + learning_units = { + page_id: Page.objects.get(id=page_id).specific.learning_unit + for page_id in unique_page_ids + } + circles = {lu.id: lu.get_circle() for lu in learning_units.values()} + + for completion in completions: + learning_unit = learning_units.get(completion.page.id) + circle = circles.get(learning_unit.id) + + combined_id = f"{circle.id}-{completion.course_session.id}" + + competence_records.setdefault(combined_id, {}).setdefault( + learning_unit, + CompetenceRecordStatisticsType( + _id=combined_id, # noqa + title=learning_unit.title, # noqa + course_session_id=completion.course_session.id, # noqa + generation=completion.course_session.generation, # noqa + circle_id=circle.id, # noqa + success_count=0, # noqa + fail_count=0, # noqa + details_url=f"/course/{course_slug}/cockpit?courseSessionId={completion.course_session.id}", + # noqa + ), + ) + + if completion.completion_status == CourseCompletionStatus.SUCCESS.value: + competence_records[combined_id][learning_unit].success_count += 1 + elif completion.completion_status == CourseCompletionStatus.FAIL.value: + competence_records[combined_id][learning_unit].fail_count += 1 + + values = [ + record + for circle_records in competence_records.values() + for record in circle_records.values() + ] + + success_count = sum([c.success_count for c in values]) + fail_count = sum([c.fail_count for c in values]) + + return values, success_count, fail_count diff --git a/server/vbv_lernwelt/dashboard/graphql/types/dashboard.py b/server/vbv_lernwelt/dashboard/graphql/types/dashboard.py new file mode 100644 index 00000000..f354c5fc --- /dev/null +++ b/server/vbv_lernwelt/dashboard/graphql/types/dashboard.py @@ -0,0 +1,209 @@ +import graphene +from graphene import Enum + +from vbv_lernwelt.course.models import CourseSession, CourseSessionUser +from vbv_lernwelt.dashboard.graphql.types.assignment import ( + assignments, + AssignmentsStatisticsType, +) +from vbv_lernwelt.dashboard.graphql.types.attendance import ( + attendance_day_presences, + AttendanceDayPresencesStatisticsType, +) +from vbv_lernwelt.dashboard.graphql.types.competence import ( + CompetencePerformanceStatisticsSummaryType, + competences, + CompetencesStatisticsType, +) +from vbv_lernwelt.dashboard.graphql.types.feedback import ( + feedback_responses, + FeedbackStatisticsResponsesType, +) +from vbv_lernwelt.learnpath.models import Circle + + +class StatisticsCourseSessionDataType(graphene.ObjectType): + id = graphene.ID(required=True) + name = graphene.String(required=True) + + +class StatisticsCircleDataType(graphene.ObjectType): + id = graphene.ID(required=True) + name = graphene.String(required=True) + + +class StatisticsCourseSessionsSelectionMetricType(graphene.ObjectType): + _id = graphene.ID(required=True) + session_count = graphene.Int(required=True) + participant_count = graphene.Int(required=True) + expert_count = graphene.Int(required=True) + + +class StatisticsCourseSessionPropertiesType(graphene.ObjectType): + _id = graphene.ID(required=True) + sessions = graphene.List( + graphene.NonNull(StatisticsCourseSessionDataType), required=True + ) + generations = graphene.List(graphene.NonNull(graphene.String), required=True) + circles = graphene.List(graphene.NonNull(StatisticsCircleDataType), required=True) + + +class DashboardType(Enum): + STATISTICS_DASHBOARD = "StatisticsDashboard" + PROGRESS_DASHBOARD = "ProgressDashboard" + SIMPLE_DASHBOARD = "SimpleDashboard" + + +class DashboardConfigType(graphene.ObjectType): + id = graphene.ID(required=True) + name = graphene.String(required=True) + slug = graphene.String(required=True) + dashboard_type = graphene.Field(DashboardType, required=True) + + +class ProgressDashboardCompetenceType(graphene.ObjectType): + _id = graphene.ID(required=True) + total_count = graphene.Int(required=True) + success_count = graphene.Int(required=True) + fail_count = graphene.Int(required=True) + + +class ProgressDashboardAssignmentType(graphene.ObjectType): + _id = graphene.ID(required=True) + total_count = graphene.Int(required=True) + points_max_count = graphene.Int(required=True) + points_achieved_count = graphene.Int(required=True) + + +class CourseProgressType(graphene.ObjectType): + _id = graphene.ID(required=True) + course_id = graphene.ID(required=True) + session_to_continue_id = graphene.ID(required=False) + competence = graphene.Field(ProgressDashboardCompetenceType, required=True) + assignment = graphene.Field(ProgressDashboardAssignmentType, required=True) + + +class CourseStatisticsType(graphene.ObjectType): + _id = graphene.ID(required=True) + course_id = graphene.ID(required=True) + course_title = graphene.String(required=True) + course_slug = graphene.String(required=True) + course_session_properties = graphene.Field( + StatisticsCourseSessionPropertiesType, required=True + ) + course_session_selection_ids = graphene.List(graphene.ID, required=True) + course_session_selection_metrics = graphene.Field( + StatisticsCourseSessionsSelectionMetricType, required=True + ) + attendance_day_presences = graphene.Field( + AttendanceDayPresencesStatisticsType, required=True + ) + feedback_responses = graphene.Field(FeedbackStatisticsResponsesType, required=True) + assignments = graphene.Field(AssignmentsStatisticsType, required=True) + competences = graphene.Field(CompetencesStatisticsType, required=True) + + def resolve_attendance_day_presences( + root, info + ) -> AttendanceDayPresencesStatisticsType: + return attendance_day_presences( + course_id=root.course_id, + course_session_selection_ids=root.course_session_selection_ids, + ) + + def resolve_feedback_responses(root, info) -> FeedbackStatisticsResponsesType: + return feedback_responses( + course_session_selection_ids=root.course_session_selection_ids, + course_id=root.course_id, + course_slug=root.course_slug, + ) + + def resolve_competences(root, info) -> CompetencesStatisticsType: + records, success_total, fail_total = competences( + course_slug=str(root.course_slug), + course_session_selection_ids=[ + str(cs) for cs in root.course_session_selection_ids # noqa + ], + ) + return CompetencesStatisticsType( + _id=root._id, # noqa + records=records, # noqa + summary=CompetencePerformanceStatisticsSummaryType( # noqa + _id=root._id, # noqa + success_total=success_total, # noqa + fail_total=fail_total, # noqa + ), + ) + + def resolve_assignments(root, info) -> AssignmentsStatisticsType: + return assignments( + course_id=root.course_id, + course_session_selection_ids=root.course_session_selection_ids, + ) + + def resolve_course_session_selection_metrics( + root, info + ) -> StatisticsCourseSessionsSelectionMetricType: + course_session_count = CourseSession.objects.filter( + id__in=root.course_session_selection_ids, + course_id=root.course_id, + ).count() + + expert_count = CourseSessionUser.objects.filter( + course_session_id__in=root.course_session_selection_ids, + role=CourseSessionUser.Role.EXPERT, + ).count() + + participant_count = CourseSessionUser.objects.filter( + course_session_id__in=root.course_session_selection_ids, + role=CourseSessionUser.Role.MEMBER, + ).count() + + return StatisticsCourseSessionsSelectionMetricType( + _id=root._id, # noqa + session_count=course_session_count, # noqa + participant_count=participant_count, # noqa + expert_count=expert_count, # noqa + ) + + def resolve_course_session_properties(root, info): + course_session_data = [] + circle_data = [] + generations = set() + + course_sessions = CourseSession.objects.filter( + id__in=root.course_session_selection_ids, + course_id=root.course_id, + ) + + for course_session in course_sessions: + course_session_data.append( + StatisticsCourseSessionDataType( + id=course_session.id, # noqa + name=course_session.title, # noqa + ) + ) + generations.add(course_session.generation) + + circles = ( + course_session.course.get_learning_path() + .get_descendants() + .live() + .specific() + .exact_type(Circle) + ) + + for circle in circles: + if not any(c.id == circle.id for c in circle_data): + circle_data.append( + StatisticsCircleDataType( + id=circle.id, # noqa + name=circle.title, # noqa + ) + ) + + return StatisticsCourseSessionPropertiesType( + _id=root._id, # noqa + sessions=course_session_data, # noqa + generations=list(generations), # noqa + circles=circle_data, # noqa + ) diff --git a/server/vbv_lernwelt/dashboard/graphql/types/feedback.py b/server/vbv_lernwelt/dashboard/graphql/types/feedback.py new file mode 100644 index 00000000..f3e4a841 --- /dev/null +++ b/server/vbv_lernwelt/dashboard/graphql/types/feedback.py @@ -0,0 +1,129 @@ +from typing import List + +import graphene + +from vbv_lernwelt.course.models import CourseSession, CourseSessionUser +from vbv_lernwelt.feedback.models import FeedbackResponse +from vbv_lernwelt.feedback.utils import feedback_users + + +class FeedbackStatisticsSummaryType(graphene.ObjectType): + _id = graphene.ID(required=True) + satisfaction_average = graphene.Float(required=True) + satisfaction_max = graphene.Int(required=True) + total_responses = graphene.Int(required=True) + + +class FeedbackStatisticsRecordType(graphene.ObjectType): + _id = graphene.ID(required=True) + course_session_id = graphene.ID(required=True) + generation = graphene.String(required=True) + circle_id = graphene.ID(required=True) + satisfaction_average = graphene.Float(required=True) + satisfaction_max = graphene.Int(required=True) + details_url = graphene.String(required=True) + experts = graphene.String(required=True) + + +class FeedbackStatisticsResponsesType(graphene.ObjectType): + _id = graphene.ID(required=True) + records = graphene.List( + graphene.NonNull(FeedbackStatisticsRecordType), required=True + ) + summary = graphene.Field(FeedbackStatisticsSummaryType, required=True) + + +def feedback_responses( + course_session_selection_ids: graphene.List(graphene.ID), + course_id: graphene.ID, + course_slug: graphene.String, +) -> FeedbackStatisticsResponsesType: + # Get all course sessions for this user in the given course + course_sessions = CourseSession.objects.filter( + id__in=course_session_selection_ids, + ) + + circle_feedbacks = [] + total_responses = 0 + + for course_session in course_sessions: + fbs = FeedbackResponse.objects.filter( + submitted=True, + course_session=course_session, + feedback_user__in=feedback_users(course_session.id), + ) + + total_responses += len(fbs) + + circle_feedbacks.extend( + circle_feedback_average( + feedbacks=fbs, + course_session_id=course_session.id, + generation=course_session.generation, + course_slug=str(course_slug), + ) + ) + + if len(circle_feedbacks): + avg = sum([fb.satisfaction_average for fb in circle_feedbacks]) / len( + circle_feedbacks + ) + else: + avg = 0 + + return FeedbackStatisticsResponsesType( + _id=course_id, # noqa + records=circle_feedbacks, # noqa + summary=FeedbackStatisticsSummaryType( # noqa + _id=course_id, # noqa + satisfaction_average=avg, # noqa + satisfaction_max=4, # noqa + total_responses=total_responses, # noqa + ), + ) + + +def circle_feedback_average( + feedbacks: List[FeedbackResponse], + course_session_id, + generation: str, + course_slug: str, +): + circle_data = {} + records = [] + + for fb in feedbacks: + circle_id = fb.circle.id + satisfaction = fb.data.get("satisfaction", None) + + if satisfaction is None: + continue + + circle_data.setdefault(circle_id, {"total": 0, "count": 0, "experts": []}) + circle_data[circle_id]["total"] += satisfaction + circle_data[circle_id]["count"] += 1 + + for circle_id, data in circle_data.items(): + details_url = f"/course/{course_slug}/cockpit/feedback/{circle_id}?courseSessionId={course_session_id}" + + experts = CourseSessionUser.objects.filter( + role="EXPERT", + course_session_id=course_session_id, + expert__id__in=[circle_id], + ).values_list("user__first_name", "user__last_name") + experts_names = [f"{first} {last}" for first, last in experts] + + records.append( + FeedbackStatisticsRecordType( + _id=f"circle:{circle_id}-course_session:{course_session_id}", # noqa + course_session_id=course_session_id, # noqa + generation=generation, # noqa + circle_id=circle_id, # noqa + satisfaction_average=data["total"] / data["count"], # noqa + satisfaction_max=4, # noqa + details_url=details_url, # noqa + experts=", ".join(experts_names), # noqa + ) + ) + + return records diff --git a/server/vbv_lernwelt/dashboard/tests/__init__.py b/server/vbv_lernwelt/dashboard/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/dashboard/tests/graphql/__init__.py b/server/vbv_lernwelt/dashboard/tests/graphql/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/dashboard/tests/graphql/test_assignment.py b/server/vbv_lernwelt/dashboard/tests/graphql/test_assignment.py new file mode 100644 index 00000000..b02cfc7a --- /dev/null +++ b/server/vbv_lernwelt/dashboard/tests/graphql/test_assignment.py @@ -0,0 +1,363 @@ +from datetime import datetime +from typing import Tuple + +from graphene_django.utils import GraphQLTestCase + +from vbv_lernwelt.assignment.models import Assignment, AssignmentType +from vbv_lernwelt.course.creators.test_utils import ( + add_course_session_group_supervisor, + add_course_session_user, + create_assignment, + create_assignment_completion, + create_assignment_learning_content, + create_circle, + create_course, + create_course_session, + create_course_session_assignment, + create_course_session_edoniq_test, + create_course_session_group, + create_user, +) +from vbv_lernwelt.course.models import CourseSession, CourseSessionUser +from vbv_lernwelt.course_session.models import ( + CourseSessionAssignment, + CourseSessionEdoniqTest, +) +from vbv_lernwelt.learnpath.models import Circle + + +class AssignmentTestCase(GraphQLTestCase): + GRAPHQL_URL = "/server/graphql/" + GRAPHQL_QUERY = f"""query($course_id: ID!) {{ + course_statistics(course_id: $course_id) {{ + assignments{{ + summary{{ + completed_count + average_passed + }} + records{{ + course_session_id + course_session_assignment_id + circle_id + generation + assignment_title + assignment_type_translation_key + details_url + deadline + metrics {{ + passed_count + failed_count + unranked_count + ranking_completed + average_passed + }} + }} + }} + }} + }}""" + + def setUp(self): + self.course, self.course_page = create_course("Test Course") + self.course_session = create_course_session(course=self.course, title=":)") + self.circle, _ = create_circle(title="Circle", course_page=self.course_page) + + self.supervisor = create_user("supervisor") + + group = create_course_session_group(course_session=self.course_session) + add_course_session_group_supervisor(group=group, user=self.supervisor) + + self.m1 = create_user("member_1") + add_course_session_user( + course_session=self.course_session, + user=self.m1, + role=CourseSessionUser.Role.MEMBER, + ) + + self.m2 = create_user("member_2") + add_course_session_user( + course_session=self.course_session, + user=self.m2, + role=CourseSessionUser.Role.MEMBER, + ) + + self.m3 = create_user("member_3") + add_course_session_user( + course_session=self.course_session, + user=self.m3, + role=CourseSessionUser.Role.MEMBER, + ) + + self.e1 = create_user("expert_1") + add_course_session_user( + course_session=self.course_session, + user=self.e1, + role=CourseSessionUser.Role.EXPERT, + ) + + self.client.force_login(self.supervisor) + + def test_dashboard_contains_casework(self): + self._test_assignment_type_dashboard_details( + assignment_type=AssignmentType.CASEWORK + ) + + def test_dashboard_contains_prep_assignments(self): + self._test_assignment_type_dashboard_details( + assignment_type=AssignmentType.PREP_ASSIGNMENT + ) + + def test_dashboard_contains_edoniq_tests(self): + self._test_assignment_type_dashboard_details( + assignment_type=AssignmentType.EDONIQ_TEST + ) + + def test_dashboard_not_contains_unsupported_types(self): + """ + Since everything is mixed in the same table, we need to make sure + that the dashboard only contains the supported types does not + get confused by the unsupported ones. + """ + + irrelevant_types_for_dashboard = set(AssignmentType) - { + AssignmentType.CASEWORK, + AssignmentType.PREP_ASSIGNMENT, + AssignmentType.EDONIQ_TEST, + } + + for assignment_type in irrelevant_types_for_dashboard: + self._test_assignment_type_not_in_dashboard(assignment_type=assignment_type) + + def _test_assignment_type_dashboard_details(self, assignment_type: AssignmentType): + # GIVEN + assignment, csa = mix_assignment_cocktail( + assignment_type=assignment_type, + deadline_at=datetime(2000, 4, 1), + course_session=self.course_session, + circle=self.circle, + ) + + create_assignment_completion( + user=self.m1, + assignment=assignment, + course_session=self.course_session, + ) + + # WHEN + response = self.query( + self.GRAPHQL_QUERY, variables={"course_id": str(self.course.id)} + ) + + # THEN + self.assertResponseNoErrors(response) + dashboard = response.json()["data"]["course_statistics"] + + records = dashboard["assignments"]["records"] + self.assertEqual(len(records), 1) + + record = records[0] + + if isinstance(csa, CourseSessionAssignment): + due_date = csa.submission_deadline + else: + due_date = csa.deadline + + self.assertEqual(record["course_session_id"], str(self.course_session.id)) + self.assertEqual(record["course_session_assignment_id"], str(csa.id)) + self.assertEqual(record["generation"], str(self.course_session.generation)) + self.assertEqual(record["circle_id"], str(self.circle.id)) + self.assertEqual(record["details_url"], due_date.url_expert) + self.assertEqual(datetime.fromisoformat(record["deadline"]), due_date.start) + + self.assertEqual( + record["assignment_title"], + csa.learning_content.content_assignment.title, + ) + self.assertEqual( + record["assignment_type_translation_key"], + due_date.assignment_type_translation_key, + ) + + def _test_assignment_type_not_in_dashboard(self, assignment_type: AssignmentType): + _, csa = mix_assignment_cocktail( + assignment_type=assignment_type, + course_session=self.course_session, + circle=self.circle, + ) + + # WHEN + response = self.query( + self.GRAPHQL_QUERY, variables={"course_id": str(self.course.id)} + ) + + # THEN + self.assertResponseNoErrors(response) + dashboard = response.json()["data"]["course_statistics"] + + records = dashboard["assignments"]["records"] + self.assertEqual(len(records), 0) + + def test_metrics_summary(self): + # GIVEN + assignment_1, _ = mix_assignment_cocktail( + deadline_at=datetime(1990, 4, 1), + assignment_type=AssignmentType.CASEWORK, + course_session=self.course_session, + circle=self.circle, + ) + + assignment_2, _ = mix_assignment_cocktail( + deadline_at=datetime(2000, 4, 1), + assignment_type=AssignmentType.EDONIQ_TEST, + course_session=self.course_session, + circle=self.circle, + ) + + assignment_3, _ = mix_assignment_cocktail( + deadline_at=datetime(2010, 4, 1), + assignment_type=AssignmentType.PREP_ASSIGNMENT, + course_session=self.course_session, + circle=self.circle, + ) + + # no completions for this assignment yet + assignment_4, _ = mix_assignment_cocktail( + deadline_at=datetime(2020, 4, 1), + assignment_type=AssignmentType.EDONIQ_TEST, + course_session=self.course_session, + circle=self.circle, + ) + + # assignment 1 + assigment_1_results = [ + (self.m1, True), # passed + (self.m2, False), # failed + (self.m3, None), # unranked + ] + + for user, has_passed in assigment_1_results: + if has_passed is None: + continue + create_assignment_completion( + user=user, + assignment=assignment_1, + course_session=self.course_session, + has_passed=has_passed, + ) + + # assignment 2 + assignment_2_results = [ + (self.m1, True), # passed + (self.m2, True), # passed + (self.m3, False), # failed + ] + + for user, has_passed in assignment_2_results: + create_assignment_completion( + user=user, + assignment=assignment_2, + course_session=self.course_session, + has_passed=has_passed, + ) + + # assignment 3 + assignment_3_results = [ + (self.m1, True), # passed + (self.m2, True), # passed + (self.m3, True), # passed + ] + for user, has_passed in assignment_3_results: + create_assignment_completion( + user=user, + assignment=assignment_3, + course_session=self.course_session, + has_passed=has_passed, + ) + + # WHEN + response = self.query( + self.GRAPHQL_QUERY, variables={"course_id": str(self.course.id)} + ) + + # THEN + self.assertResponseNoErrors(response) + dashboard = response.json()["data"]["course_statistics"] + + # 1 -> incomplete (not counted for average) + # 2 -> complete 66% passed ... + # 3 -> complete 100% passed --> 83.5% + # 4 -> incomplete (not counted for average) + summary = dashboard["assignments"]["summary"] + self.assertEqual(summary["completed_count"], 2) + self.assertEqual(summary["average_passed"], 83.5) + + records = dashboard["assignments"]["records"] + self.assertEqual(len(records), 4) + + # 1 -> assigment_1_results (oldest) + assignment_1_metrics = records[0]["metrics"] + self.assertEqual(assignment_1_metrics["passed_count"], 1) + self.assertEqual(assignment_1_metrics["failed_count"], 1) + self.assertEqual(assignment_1_metrics["unranked_count"], 1) + self.assertEqual(assignment_1_metrics["ranking_completed"], False) + self.assertEqual(assignment_1_metrics["average_passed"], 34) + + # 2 -> assignment_2_results + assignment_2_metrics = records[1]["metrics"] + self.assertEqual(assignment_2_metrics["passed_count"], 2) + self.assertEqual(assignment_2_metrics["failed_count"], 1) + self.assertEqual(assignment_2_metrics["unranked_count"], 0) + self.assertEqual(assignment_2_metrics["ranking_completed"], True) + self.assertEqual(assignment_2_metrics["average_passed"], 67) + + # 3 -> assignment_3_results + assignment_3_metrics = records[2]["metrics"] + self.assertEqual(assignment_3_metrics["passed_count"], 3) + self.assertEqual(assignment_3_metrics["failed_count"], 0) + self.assertEqual(assignment_3_metrics["unranked_count"], 0) + self.assertEqual(assignment_3_metrics["ranking_completed"], True) + self.assertEqual(assignment_3_metrics["average_passed"], 100) + + # 4 -> no completions (newest) + assignment_4_metrics = records[3]["metrics"] + self.assertEqual(assignment_4_metrics["passed_count"], 0) + self.assertEqual(assignment_4_metrics["failed_count"], 0) + self.assertEqual(assignment_4_metrics["unranked_count"], 3) + self.assertEqual(assignment_4_metrics["ranking_completed"], False) + self.assertEqual(assignment_4_metrics["average_passed"], 0) + + +def mix_assignment_cocktail( + assignment_type: AssignmentType, + course_session: CourseSession, + circle: Circle, + deadline_at: datetime | None = None, +) -> Tuple[Assignment, CourseSessionAssignment | CourseSessionEdoniqTest]: + """ + Little test helper to create a course session assignment or edoniq test based + on the given assignment type. + """ + + assignment = create_assignment( + course=course_session.course, assignment_type=assignment_type + ) + + if assignment_type == AssignmentType.EDONIQ_TEST: + cset = create_course_session_edoniq_test( + deadline_at=deadline_at, + course_session=course_session, + learning_content_edoniq_test=create_assignment_learning_content( + circle=circle, + assignment=assignment, + ), + ) + return assignment, cset + else: + csa = create_course_session_assignment( + deadline_at=deadline_at, + course_session=course_session, + learning_content_assignment=create_assignment_learning_content( + circle=circle, + assignment=assignment, + ), + ) + return assignment, csa diff --git a/server/vbv_lernwelt/dashboard/tests/graphql/test_attendance.py b/server/vbv_lernwelt/dashboard/tests/graphql/test_attendance.py new file mode 100644 index 00000000..a9f4e9f8 --- /dev/null +++ b/server/vbv_lernwelt/dashboard/tests/graphql/test_attendance.py @@ -0,0 +1,123 @@ +from datetime import timedelta + +from django.utils import timezone +from graphene_django.utils import GraphQLTestCase + +from vbv_lernwelt.course.creators.test_utils import ( + add_course_session_group_supervisor, + add_course_session_user, + create_attendance_course, + create_circle, + create_course, + create_course_session, + create_course_session_group, + create_user, +) +from vbv_lernwelt.course.models import CourseSessionUser +from vbv_lernwelt.course_session.services.attendance import AttendanceUserStatus + + +class DashboardAttendanceTestCase(GraphQLTestCase): + GRAPHQL_URL = "/server/graphql/" + + def test_attendance_day_presences(self): + # GIVEN + course, course_page = create_course("Test Course") + course_session = create_course_session(course=course, title="Test Bern 2022 a") + + supervisor = create_user("supervisor") + + group = create_course_session_group(course_session=course_session) + add_course_session_group_supervisor(group=group, user=supervisor) + + circle, _ = create_circle(title="Test Circle", course_page=course_page) + + m1 = create_user("member_1") + add_course_session_user( + course_session=course_session, + user=m1, + role=CourseSessionUser.Role.MEMBER, + ) + + m2 = create_user("member_2") + add_course_session_user( + course_session=course_session, + user=m2, + role=CourseSessionUser.Role.MEMBER, + ) + + m3 = create_user("member_3") + add_course_session_user( + course_session=course_session, + user=m3, + role=CourseSessionUser.Role.MEMBER, + ) + + e1 = create_user("expert_1") + add_course_session_user( + course_session=course_session, + user=e1, + role=CourseSessionUser.Role.EXPERT, + ) + + attendance_user_list = [ + {"user_id": str(m1.id), "status": AttendanceUserStatus.PRESENT.value}, + {"user_id": str(m2.id), "status": AttendanceUserStatus.ABSENT.value}, + ] + + due_date_end = timezone.now() - timedelta(hours=2) + attendance_course = create_attendance_course( + course_session=course_session, + circle=circle, + attendance_user_list=attendance_user_list, + due_date_end=due_date_end, + ) + + self.client.force_login(supervisor) + + query = f""" + query($course_id: ID!) {{ + course_statistics(course_id: $course_id) {{ + attendance_day_presences{{ + summary{{ + days_completed + participants_present + }} + records{{ + course_session_id + generation + circle_id + due_date + participants_present + participants_total + details_url + }} + }} + }} + }} + """ + + # WHEN + response = self.query(query, variables={"course_id": str(course.id)}) + + self.assertResponseNoErrors(response) + + data = response.json()["data"] + + attendance_day_presences = data["course_statistics"]["attendance_day_presences"] + + record = attendance_day_presences["records"][0] + + self.assertEqual(record["course_session_id"], str(course_session.id)) + self.assertEqual(record["generation"], "2023") + self.assertEqual(record["participants_present"], 1) + self.assertEqual(record["participants_total"], 3) + self.assertEqual( + record["details_url"], + f"/course/test-lehrgang/cockpit/attendance?id={attendance_course.learning_content.id}" + f"&courseSessionId={course_session.id}", + ) + + summary = attendance_day_presences["summary"] + self.assertEqual(summary["days_completed"], 1) + self.assertEqual(summary["participants_present"], 34) diff --git a/server/vbv_lernwelt/dashboard/tests/graphql/test_competence.py b/server/vbv_lernwelt/dashboard/tests/graphql/test_competence.py new file mode 100644 index 00000000..9f5ae505 --- /dev/null +++ b/server/vbv_lernwelt/dashboard/tests/graphql/test_competence.py @@ -0,0 +1,109 @@ +from graphene_django.utils import GraphQLTestCase + +from vbv_lernwelt.course.creators.test_utils import ( + add_course_session_group_supervisor, + add_course_session_user, + create_circle, + create_course, + create_course_session, + create_course_session_group, + create_performance_criteria_page, + create_user, +) +from vbv_lernwelt.course.models import CourseSessionUser +from vbv_lernwelt.course.services import mark_course_completion + + +class DashboardCompetenceTestCase(GraphQLTestCase): + GRAPHQL_URL = "/server/graphql/" + + def test_competence(self): + # GIVEN + course, course_page = create_course("Test Course") + course_session = create_course_session(course=course, title="Test Bern 2022 a") + + supervisor = create_user("supervisor") + group = create_course_session_group(course_session=course_session) + add_course_session_group_supervisor(group=group, user=supervisor) + + member_one = create_user("member one") + add_course_session_user( + course_session=course_session, + user=member_one, + role=CourseSessionUser.Role.MEMBER, + ) + + member_two = create_user("member two") + add_course_session_user( + course_session=course_session, + user=member_two, + role=CourseSessionUser.Role.MEMBER, + ) + + circle, _ = create_circle(title="Test Circle", course_page=course_page) + + pc = create_performance_criteria_page( + course=course, course_page=course_page, circle=circle + ) + + mark_course_completion( + page=pc, + user=member_one, + course_session=course_session, + completion_status="SUCCESS", + ) + + mark_course_completion( + page=pc, + user=member_two, + course_session=course_session, + completion_status="FAIL", + ) + + self.client.force_login(supervisor) + + query = f"""query($course_id: ID!) {{ + course_statistics(course_id: $course_id) {{ + competences {{ + records {{ + title + course_session_id + generation + circle_id + success_count + fail_count + details_url + }} + summary {{ + success_total + fail_total + }} + }} + }} + }} + """ + variables = {"course_id": str(course.id)} + + # WHEN + response = self.query(query, variables=variables) + + # THEN + self.assertResponseNoErrors(response) + + competences = response.json()["data"]["course_statistics"]["competences"] + records = competences["records"] + + self.assertEqual(records[0]["title"], "Learning Unit") + self.assertEqual(records[0]["success_count"], 1) + self.assertEqual(records[0]["fail_count"], 1) + self.assertEqual(records[0]["circle_id"], str(circle.id)) + self.assertEqual(records[0]["course_session_id"], str(course_session.id)) + self.assertEqual(records[0]["generation"], "2023") + self.assertEqual( + records[0]["details_url"], + f"/course/{course.slug}/cockpit?courseSessionId={course_session.id}", + ) + + summary = competences["summary"] + self.assertEqual(summary["success_total"], 1) + self.assertEqual(summary["fail_total"], 1) diff --git a/server/vbv_lernwelt/dashboard/tests/graphql/test_dashboard.py b/server/vbv_lernwelt/dashboard/tests/graphql/test_dashboard.py new file mode 100644 index 00000000..5a8e0e40 --- /dev/null +++ b/server/vbv_lernwelt/dashboard/tests/graphql/test_dashboard.py @@ -0,0 +1,284 @@ +from graphene_django.utils import GraphQLTestCase + +from vbv_lernwelt.assignment.models import AssignmentType +from vbv_lernwelt.course.creators.test_utils import ( + add_course_session_group_supervisor, + add_course_session_user, + create_assignment, + create_assignment_completion, + create_circle, + create_course, + create_course_session, + create_course_session_group, + create_performance_criteria_page, + create_user, +) +from vbv_lernwelt.course.models import CourseSessionUser +from vbv_lernwelt.course.services import mark_course_completion + + +class DashboardTestCase(GraphQLTestCase): + GRAPHQL_URL = "/server/graphql/" + + def test_course_progress(self): + # GIVEN + course, course_page = create_course("Test Course") + + cs_1 = create_course_session( + course=course, title="Test Course Session 1", generation="" + ) + cs_2 = create_course_session( + course=course, title="Test Course Session 2", generation="2020" + ) + cs_3 = create_course_session( + course=course, title="Test Course Session 3", generation="1984" + ) + + member = create_user("sepp") + + add_course_session_user( + course_session=cs_1, user=member, role=CourseSessionUser.Role.MEMBER + ) + add_course_session_user( + course_session=cs_2, user=member, role=CourseSessionUser.Role.MEMBER + ) + add_course_session_user( + course_session=cs_3, user=member, role=CourseSessionUser.Role.MEMBER + ) + + # setup assignments + create_assignment_completion( + user=member, + assignment=create_assignment( + course=course, assignment_type=AssignmentType.CASEWORK + ), + course_session=cs_1, + has_passed=True, + achieved_points=10, + max_points=10, + ) + + create_assignment_completion( + user=member, + assignment=create_assignment( + course=course, assignment_type=AssignmentType.CASEWORK + ), + course_session=cs_2, + has_passed=False, + achieved_points=10, + max_points=40, + ) + + # setup competence + circle, _ = create_circle( + title="How to circle like a pro!", course_page=course_page + ) + + mark_course_completion( + page=create_performance_criteria_page( + course=course, course_page=course_page, circle=circle + ), + user=member, + course_session=cs_1, + completion_status="SUCCESS", + ) + + mark_course_completion( + page=create_performance_criteria_page( + course=course, course_page=course_page, circle=circle + ), + user=member, + course_session=cs_2, + completion_status="FAIL", + ) + + self.client.force_login(member) + + query = f"""query($course_id: ID!) {{ + course_progress(course_id: $course_id) {{ + course_id + session_to_continue_id + competence {{ + total_count + success_count + fail_count + }} + assignment {{ + total_count + points_max_count + points_achieved_count + }} + }} + }} + """ + + variables = {"course_id": str(course.id)} + + # WHEN + response = self.query(query, variables=variables) + + # THEN + self.assertResponseNoErrors(response) + + course_progress = response.json()["data"]["course_progress"] + self.assertEqual(course_progress["course_id"], str(course.id)) + self.assertEqual(course_progress["session_to_continue_id"], str(cs_2.id)) + + competence = course_progress["competence"] + self.assertEqual(competence["total_count"], 2) + self.assertEqual(competence["success_count"], 1) + self.assertEqual(competence["fail_count"], 1) + + assignment = course_progress["assignment"] + self.assertEqual(assignment["total_count"], 2) + self.assertEqual(assignment["points_max_count"], 50) + self.assertEqual(assignment["points_achieved_count"], 20) + + def test_dashboard_config(self): + # GIVEN + course_1, _ = create_course("Test Course 1") + course_2, _ = create_course("Test Course 2") + course_3, _ = create_course("Test Course 3") + + cs_1 = create_course_session(course=course_1, title="Test Course 1 Session") + cs_2 = create_course_session(course=course_2, title="Test Course 2 Session") + + cs_3_a = create_course_session(course=course_3, title="CS 3 A (as member)") + cs_3_b = create_course_session(course=course_3, title="CS 3 B (as expert)") + + supervisor = create_user("supervisor") + + # CS 1 + add_course_session_user( + course_session=cs_1, user=supervisor, role=CourseSessionUser.Role.MEMBER + ) + + # CS 2 + add_course_session_group_supervisor( + group=create_course_session_group(course_session=cs_2), user=supervisor + ) + + # CS 3 A + add_course_session_user( + course_session=cs_3_a, user=supervisor, role=CourseSessionUser.Role.MEMBER + ) + + # CS 3 B + add_course_session_user( + course_session=cs_3_b, user=supervisor, role=CourseSessionUser.Role.EXPERT + ) + + self.client.force_login(supervisor) + + # WHEN + query = """query { + dashboard_config { + id + name + slug + dashboard_type + } + } + """ + + response = self.query(query) + + # THEN + self.assertResponseNoErrors(response) + + dashboard_config = response.json()["data"]["dashboard_config"] + self.assertEqual(len(dashboard_config), 3) + + course_1_config = find_dashboard_config_by_course_id( + dashboard_config, course_1.id + ) + self.assertIsNotNone(course_1_config) + self.assertEqual(course_1_config["name"], course_1.title) + self.assertEqual(course_1_config["slug"], course_1.slug) + self.assertEqual(course_1_config["dashboard_type"], "PROGRESS_DASHBOARD") + + course_2_config = find_dashboard_config_by_course_id( + dashboard_config, course_2.id + ) + self.assertIsNotNone(course_2_config) + self.assertEqual(course_2_config["name"], course_2.title) + self.assertEqual(course_2_config["slug"], course_2.slug) + self.assertEqual(course_2_config["dashboard_type"], "STATISTICS_DASHBOARD") + + course_3_config = find_dashboard_config_by_course_id( + dashboard_config, course_3.id + ) + self.assertIsNotNone(course_3_config) + self.assertEqual(course_3_config["name"], course_3.title) + self.assertEqual(course_3_config["slug"], course_3.slug) + self.assertEqual(course_3_config["dashboard_type"], "SIMPLE_DASHBOARD") + + def test_course_statistics_deny_not_allowed_user(self): + # GIVEN + disallowed_user = create_user("1337_hacker_schorsch") + course, _ = create_course("Test Course") + create_course_session(course=course, title="Test Course Session") + + self.client.force_login(disallowed_user) + + query = f"""query($course_id: ID!) {{ + course_statistics(course_id: $course_id) {{ + course_id + }} + }} + """ + variables = {"course_id": str(course.id)} + + # WHEN + response = self.query(query, variables=variables) + + # THEN + self.assertResponseNoErrors(response) + + course_statistics = response.json()["data"]["course_statistics"] + self.assertEqual(course_statistics, None) + + def test_course_statistics_data(self): + # GIVEN + supervisor = create_user("supervisor") + course_1, _ = create_course("Test Course 1") + course_2, _ = create_course("Test Course 2") + + cs_1 = create_course_session(course=course_1, title="Test Course 1 Session") + cs_2 = create_course_session(course=course_2, title="Test Course 2 Session") + + cs_group_1 = create_course_session_group(course_session=cs_1) + add_course_session_group_supervisor(group=cs_group_1, user=supervisor) + + cs_group_2 = create_course_session_group(course_session=cs_2) + add_course_session_group_supervisor(group=cs_group_2, user=supervisor) + + self.client.force_login(supervisor) + + query = f"""query($course_id: ID!) {{ + course_statistics(course_id: $course_id) {{ + course_id + course_title + course_slug + }} + }} + """ + variables = {"course_id": str(course_2.id)} + + # WHEN + response = self.query(query, variables=variables) + + # THEN + self.assertResponseNoErrors(response) + + course_statistics = response.json()["data"]["course_statistics"] + + self.assertEqual(course_statistics["course_id"], str(course_2.id)) + self.assertEqual(course_statistics["course_title"], course_2.title) + self.assertEqual(course_statistics["course_slug"], course_2.slug) + + +def find_dashboard_config_by_course_id(dashboard_configs, course_id): + return next( + (config for config in dashboard_configs if config["id"] == str(course_id)), None + ) diff --git a/server/vbv_lernwelt/dashboard/tests/graphql/test_feedback.py b/server/vbv_lernwelt/dashboard/tests/graphql/test_feedback.py new file mode 100644 index 00000000..9c391dfd --- /dev/null +++ b/server/vbv_lernwelt/dashboard/tests/graphql/test_feedback.py @@ -0,0 +1,142 @@ +from graphene_django.utils import GraphQLTestCase + +from vbv_lernwelt.course.creators.test_utils import ( + add_course_session_group_supervisor, + add_course_session_user, + create_circle, + create_circle_expert, + create_course, + create_course_session, + create_course_session_group, + create_user, +) +from vbv_lernwelt.course.models import CourseSessionUser +from vbv_lernwelt.feedback.models import FeedbackResponse + + +class DashboardFeedbackTestCase(GraphQLTestCase): + GRAPHQL_URL = "/server/graphql/" + + def test_feedback(self): + # GIVEN + course, course_page = create_course("Test Course") + course_session = create_course_session(course=course, title="Test Bern 2022 a") + + supervisor = create_user("supervisor") + + group = create_course_session_group(course_session=course_session) + add_course_session_group_supervisor(group=group, user=supervisor) + + member = create_user("member") + add_course_session_user( + course_session=course_session, + user=member, + role=CourseSessionUser.Role.MEMBER, + ) + + circle1, _ = create_circle(title="Test Circle 1", course_page=course_page) + circle2, _ = create_circle(title="Test Circle 2", course_page=course_page) + + create_circle_expert( + course_session=course_session, circle=circle1, username="Expert 1" + ) + create_circle_expert( + course_session=course_session, circle=circle1, username="Expert 2" + ) + + FeedbackResponse.objects.create( + feedback_user=member, + data={"satisfaction": 3}, + circle=circle1, + course_session=course_session, + submitted=True, + ) + FeedbackResponse.objects.create( + feedback_user=member, + data={"satisfaction": 4}, + circle=circle1, + course_session=course_session, + submitted=True, + ) + + # Create Feedbacks for circle2 + FeedbackResponse.objects.create( + feedback_user=member, + data={"satisfaction": 1}, + circle=circle2, + course_session=course_session, + submitted=True, + ) + FeedbackResponse.objects.create( + feedback_user=member, + data={"satisfaction": 2}, + circle=circle2, + course_session=course_session, + submitted=True, + ) + + self.client.force_login(supervisor) + + query = f"""query($course_id: ID!) {{ + course_statistics(course_id: $course_id) {{ + feedback_responses {{ + records {{ + course_session_id + generation + circle_id + satisfaction_average + satisfaction_max + details_url + experts + }} + summary {{ + satisfaction_average + satisfaction_max + total_responses + }} + }} + }} + }} + """ + variables = {"course_id": str(course.id)} + + # WHEN + response = self.query(query, variables=variables) + + # THEN + self.assertResponseNoErrors(response) + + course_statistics = response.json()["data"]["course_statistics"] + feedback_responses = course_statistics["feedback_responses"] + + records = feedback_responses["records"] + self.assertEqual(len(records), 2) + + circle1_record = next( + (r for r in records if r["circle_id"] == str(circle1.id)), None + ) + self.assertEqual(circle1_record["satisfaction_average"], 3.5) + self.assertEqual(circle1_record["course_session_id"], str(course_session.id)) + self.assertEqual(circle1_record["generation"], "2023") + self.assertEqual( + circle1_record["details_url"], + f"/course/{course.slug}/cockpit/feedback/{circle1.id}?courseSessionId={course_session.id}", + ) + self.assertEqual(circle1_record["experts"], "Test Expert 1, Test Expert 2") + + circle2_record = next( + (r for r in records if r["circle_id"] == str(circle2.id)), None + ) + self.assertEqual(circle2_record["satisfaction_average"], 1.5) + self.assertEqual(circle2_record["course_session_id"], str(course_session.id)) + self.assertEqual(circle2_record["generation"], "2023") + self.assertEqual( + circle2_record["details_url"], + f"/course/{course.slug}/cockpit/feedback/{circle2.id}?courseSessionId={course_session.id}", + ) + self.assertEqual(circle2_record["experts"], "") + + summary = feedback_responses["summary"] + self.assertEqual(summary["satisfaction_average"], 2.5) + self.assertEqual(summary["satisfaction_max"], 4) + self.assertEqual(summary["total_responses"], 4) diff --git a/server/vbv_lernwelt/dashboard/tests/graphql/test_selection_metrics.py b/server/vbv_lernwelt/dashboard/tests/graphql/test_selection_metrics.py new file mode 100644 index 00000000..55213cd9 --- /dev/null +++ b/server/vbv_lernwelt/dashboard/tests/graphql/test_selection_metrics.py @@ -0,0 +1,101 @@ +from graphene_django.utils import GraphQLTestCase + +from vbv_lernwelt.course.creators.test_utils import ( + add_course_session_group_course_session, + add_course_session_group_supervisor, + add_course_session_user, + create_course, + create_course_session, + create_course_session_group, + create_user, +) +from vbv_lernwelt.course.models import CourseSessionUser + + +class DashboardTestCase(GraphQLTestCase): + GRAPHQL_URL = "/server/graphql/" + + def test_selection_metrics(self): + # GIVEN + course_1, _ = create_course("Test Course 1") + course_2, _ = create_course("Dummy Course 2") + + cs_1_a = create_course_session(course=course_1, title="Zug", generation="1984") + cs_1_b = create_course_session(course=course_1, title="Bern", generation="1984") + cs_1_c = create_course_session(course=course_1, title="Wil", generation="1984") + cs_2_a = create_course_session(course=course_2, title="Baar", generation="1984") + + member_1 = create_user("member_1") + member_2 = create_user("member_2") + member_3 = create_user("member_3") + member_4 = create_user("member_4") + + expert_1 = create_user("expert_1") + expert_2 = create_user("expert_2") + expert_3 = create_user("expert_3") + expert_4 = create_user("expert_4") + + # CS 1 A + add_course_session_user( + course_session=cs_1_a, user=member_1, role=CourseSessionUser.Role.MEMBER + ) + add_course_session_user( + course_session=cs_1_b, user=member_2, role=CourseSessionUser.Role.MEMBER + ) + + # CS 1 B + add_course_session_user( + course_session=cs_1_a, user=expert_1, role=CourseSessionUser.Role.EXPERT + ) + add_course_session_user( + course_session=cs_1_b, user=expert_2, role=CourseSessionUser.Role.EXPERT + ) + + # CS 1 C + add_course_session_user( + course_session=cs_1_c, user=member_3, role=CourseSessionUser.Role.MEMBER + ) + add_course_session_user( + course_session=cs_1_c, user=expert_3, role=CourseSessionUser.Role.EXPERT + ) + + # CS 2 A + add_course_session_user( + course_session=cs_2_a, user=member_4, role=CourseSessionUser.Role.MEMBER + ) + add_course_session_user( + course_session=cs_2_a, user=expert_4, role=CourseSessionUser.Role.EXPERT + ) + + # SUPERVISOR of course 1, session a and b BUT NOT + # of course 1, session c or course 2, session a + cs_1_ab_supervisor = create_user("supervisor") + group = create_course_session_group(course_session=cs_1_a) + add_course_session_group_course_session(course_session=cs_1_b, group=group) + add_course_session_group_supervisor(group=group, user=cs_1_ab_supervisor) + + self.client.force_login(cs_1_ab_supervisor) + + # WHEN + query = f"""query($course_id: ID!) {{ + course_statistics(course_id: $course_id) {{ + course_session_selection_metrics {{ + expert_count + participant_count + session_count + }} + }} + }}""" + + variables = {"course_id": str(course_1.id)} + response = self.query(query, variables=variables) + + # THEN + self.assertResponseNoErrors(response) + + metrics = response.json()["data"]["course_statistics"][ + "course_session_selection_metrics" + ] + self.assertEqual(metrics["expert_count"], 2) + self.assertEqual(metrics["participant_count"], 2) + self.assertEqual(metrics["session_count"], 2) diff --git a/server/vbv_lernwelt/edoniq_test/tests/test_edoniq_export.py b/server/vbv_lernwelt/edoniq_test/tests/test_edoniq_export.py index 20c6d684..e285bf2b 100644 --- a/server/vbv_lernwelt/edoniq_test/tests/test_edoniq_export.py +++ b/server/vbv_lernwelt/edoniq_test/tests/test_edoniq_export.py @@ -3,6 +3,11 @@ import csv from django.test import TestCase from vbv_lernwelt.core.admin import User +from vbv_lernwelt.core.constants import ( + TEST_STUDENT1_USER_ID, + TEST_STUDENT2_USER_ID, + TEST_TRAINER1_USER_ID, +) from vbv_lernwelt.core.create_default_users import create_default_users from vbv_lernwelt.course.consts import COURSE_TEST_ID from vbv_lernwelt.course.creators.test_course import create_test_course @@ -19,14 +24,14 @@ class EdoniqUserExportTestCase(TestCase): create_default_users() create_test_course(with_sessions=True) - user1 = User.objects.get(email="test-student1@example.com") + user1 = User.objects.get(id=TEST_STUDENT1_USER_ID) user1.additional_json_data = { "Lehrvertragsnummer": "23456", "Geburtsdatum": "01.01.1991", } user1.save() - user2 = User.objects.get(email="test-student2@example.com") + user2 = User.objects.get(id=TEST_STUDENT2_USER_ID) user2.additional_json_data = { "Firmenname": "Test AG", "Lehrvertragsnummer": "12345", @@ -45,7 +50,7 @@ class EdoniqUserExportTestCase(TestCase): self.assertEqual(len(users), 2) def test_remove_eiger_versicherungen(self): - user1 = User.objects.get(email="test-student1@example.com") + user1 = User.objects.get(id=TEST_STUDENT1_USER_ID) user1.email = "some@eiger-versicherungen.ch" user1.save() users = fetch_course_session_users( @@ -58,7 +63,7 @@ class EdoniqUserExportTestCase(TestCase): self.assertEqual(len(users), 5) def test_deduplicates_users(self): - trainer1 = User.objects.get(email="test-trainer1@example.com") + trainer1 = User.objects.get(id=TEST_TRAINER1_USER_ID) cs_zrh = CourseSession.objects.get( title="Test Zürich 2022 a", ) diff --git a/server/vbv_lernwelt/edoniq_test/views.py b/server/vbv_lernwelt/edoniq_test/views.py index f70dec12..00d8ff7b 100644 --- a/server/vbv_lernwelt/edoniq_test/views.py +++ b/server/vbv_lernwelt/edoniq_test/views.py @@ -14,8 +14,8 @@ from rest_framework.decorators import api_view from vbv_lernwelt.core.models import User from vbv_lernwelt.course.consts import COURSE_UK, COURSE_UK_FR, COURSE_UK_IT from vbv_lernwelt.course.models import CourseSessionUser -from vbv_lernwelt.course.permissions import has_course_access_by_page_request from vbv_lernwelt.edoniq_test.edoniq_sso import create_token +from vbv_lernwelt.iam.permissions import has_course_access_by_page_request from vbv_lernwelt.learnpath.models import LearningContentEdoniqTest logger = structlog.get_logger(__name__) diff --git a/server/vbv_lernwelt/feedback/graphql/mutations.py b/server/vbv_lernwelt/feedback/graphql/mutations.py index 9a569d39..e36ad651 100644 --- a/server/vbv_lernwelt/feedback/graphql/mutations.py +++ b/server/vbv_lernwelt/feedback/graphql/mutations.py @@ -4,12 +4,12 @@ from graphene.types.generic import GenericScalar from graphene_django.types import ErrorType from vbv_lernwelt.course.models import CourseSession -from vbv_lernwelt.course.permissions import has_course_session_access from vbv_lernwelt.feedback.graphql.types import ( FeedbackResponseObjectType as FeedbackResponseType, ) from vbv_lernwelt.feedback.serializers import CourseFeedbackSerializer from vbv_lernwelt.feedback.services import update_feedback_response +from vbv_lernwelt.iam.permissions import has_course_session_access from vbv_lernwelt.learnpath.models import LearningContentFeedback logger = structlog.get_logger(__name__) diff --git a/server/vbv_lernwelt/feedback/utils.py b/server/vbv_lernwelt/feedback/utils.py new file mode 100644 index 00000000..38ba733e --- /dev/null +++ b/server/vbv_lernwelt/feedback/utils.py @@ -0,0 +1,15 @@ +from django.db.models import Q + +from vbv_lernwelt.core.constants import ADMIN_USER_ID +from vbv_lernwelt.course.models import CourseSessionUser + + +def feedback_users(course_session_id): + """ + Solely accept feedback originating from members of the course session and the illustrious + administrative user, who serves as the repository for feedbacks heretofore submitted anonymously ;-) + """ + return CourseSessionUser.objects.filter( + Q(course_session_id=course_session_id, role=CourseSessionUser.Role.MEMBER) + | Q(user__id=ADMIN_USER_ID) + ).values_list("user", flat=True) diff --git a/server/vbv_lernwelt/feedback/views.py b/server/vbv_lernwelt/feedback/views.py index 95aa7dfd..7d3bb27f 100644 --- a/server/vbv_lernwelt/feedback/views.py +++ b/server/vbv_lernwelt/feedback/views.py @@ -5,8 +5,9 @@ from rest_framework.decorators import api_view from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response -from vbv_lernwelt.course.permissions import is_course_session_expert from vbv_lernwelt.feedback.models import FeedbackResponse +from vbv_lernwelt.feedback.utils import feedback_users +from vbv_lernwelt.iam.permissions import is_course_session_expert logger = structlog.get_logger(__name__) @@ -57,11 +58,7 @@ def get_feedback_for_circle(request, course_session_id, circle_id): course_session__id=course_session_id, submitted=True, circle_id=circle_id, - # filter out experts that might have submitted just for testing - # important: the commented code causes to return no feedbacks with prod data - # feedback_user__in=CourseSessionUser.objects.filter( - # course_session_id=course_session_id, role=CourseSessionUser.Role.MEMBER - # ).values_list("user", flat=True), + feedback_user__in=feedback_users(course_session_id), ).order_by("created_at") # I guess this is ok for the üK case diff --git a/server/vbv_lernwelt/iam/__init__.py b/server/vbv_lernwelt/iam/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/course/permissions.py b/server/vbv_lernwelt/iam/permissions.py similarity index 53% rename from server/vbv_lernwelt/course/permissions.py rename to server/vbv_lernwelt/iam/permissions.py index 35e79b7f..a572f21d 100644 --- a/server/vbv_lernwelt/course/permissions.py +++ b/server/vbv_lernwelt/iam/permissions.py @@ -1,4 +1,6 @@ +from vbv_lernwelt.core.models import User from vbv_lernwelt.course.models import CourseSession, CourseSessionUser +from vbv_lernwelt.course_session_group.models import CourseSessionGroup from vbv_lernwelt.learnpath.models import LearningSequence @@ -10,38 +12,40 @@ def has_course_access(user, course_id): if user.is_superuser: return True - if CourseSessionUser.objects.filter( - course_session__course_id=course_id, user=user + if CourseSessionGroup.objects.filter( + course_session__course_id=course_id, supervisor=user ).exists(): return True - return False + return CourseSessionUser.objects.filter( + course_session__course_id=course_id, user=user + ).exists() def has_course_session_access(user, course_session_id: int): if user.is_superuser: return True - if CourseSessionUser.objects.filter( + return CourseSessionUser.objects.filter( course_session_id=course_session_id, user=user - ).exists(): - return True - - return False + ).exists() def is_course_session_expert(user, course_session_id: int): if user.is_superuser: return True - if CourseSessionUser.objects.filter( + is_supervisor = CourseSessionGroup.objects.filter( + supervisor=user, course_session__id=course_session_id + ).exists() + + is_expert = CourseSessionUser.objects.filter( course_session_id=course_session_id, user=user, role=CourseSessionUser.Role.EXPERT, - ).exists(): - return True + ).exists() - return False + return is_supervisor or is_expert def course_sessions_for_user_qs(user): @@ -64,12 +68,41 @@ def is_circle_expert(user, course_session_id: int, learning_sequence_id: int) -> circle_id = learning_sequence.get_parent().circle.id - if CourseSessionUser.objects.filter( + return CourseSessionUser.objects.filter( course_session_id=course_session_id, user=user, role=CourseSessionUser.Role.EXPERT, expert__id=circle_id, + ).exists() + + +def can_view_course_session_group_statistics( + user: User, group: CourseSessionGroup +) -> bool: + if user.is_superuser: + return True + + return user in group.supervisor.all() + + +def can_view_course_session_progress(user: User, course_session: CourseSession) -> bool: + return CourseSessionUser.objects.filter( + course_session=course_session, + user=user, + role=CourseSessionUser.Role.MEMBER, + ).exists() + + +def can_view_course_session(user: User, course_session: CourseSession) -> bool: + if user.is_superuser: + return True + + if CourseSessionGroup.objects.filter( + course_session=course_session, supervisor=user ).exists(): return True - return False + return CourseSessionUser.objects.filter( + course_session=course_session, + user=user, + ).exists() diff --git a/server/vbv_lernwelt/importer/tests/test_import_course_sessions.py b/server/vbv_lernwelt/importer/tests/test_import_course_sessions.py index 9e276392..c887ea4f 100644 --- a/server/vbv_lernwelt/importer/tests/test_import_course_sessions.py +++ b/server/vbv_lernwelt/importer/tests/test_import_course_sessions.py @@ -5,6 +5,7 @@ from django.utils.timezone import make_naive from openpyxl.reader.excel import load_workbook from vbv_lernwelt.assignment.models import AssignmentType +from vbv_lernwelt.core.constants import TEST_TRAINER1_USER_ID from vbv_lernwelt.core.create_default_users import create_default_users from vbv_lernwelt.core.models import User from vbv_lernwelt.course.creators.test_course import create_test_course @@ -127,7 +128,7 @@ class CreateOrUpdateCourseSessionTestCase(TestCase): self.course, data, language="de", circle_keys=["Fahrzeug"] ) - trainer1 = User.objects.get(email="test-trainer1@example.com") + trainer1 = User.objects.get(id=TEST_TRAINER1_USER_ID) csu = CourseSessionUser.objects.create( course_session=cs, user=trainer1, diff --git a/server/vbv_lernwelt/templates/admin/index.html b/server/vbv_lernwelt/templates/admin/index.html index da2faf49..32d3e5f2 100644 --- a/server/vbv_lernwelt/templates/admin/index.html +++ b/server/vbv_lernwelt/templates/admin/index.html @@ -79,6 +79,18 @@ + + + create_course_completion_performance_criteria + + + + + + create_attendance_days + + + Testdaten zurück setzen
{{ pieData }}
{{ render() }}