Merged in feature/VBV-496-dash-regionalleiter (pull request #224)

Feature/VBV-496 dash regionalleiter

Approved-by: Daniel Egger
This commit is contained in:
Reto Aebersold 2023-11-07 12:36:54 +00:00 committed by Daniel Egger
commit 25c6021b82
92 changed files with 5046 additions and 262 deletions

View File

@ -0,0 +1,48 @@
<script setup lang="ts">
import { computed } from "vue";
import ItProgress from "@/components/ui/ItProgress.vue";
import BaseBox from "@/components/dashboard/BaseBox.vue";
const props = defineProps<{
detailsLink: string;
totalAssignments: number;
achievedPointsCount: number;
maxPointsCount: number;
}>();
const progress = computed(() => ({
SUCCESS: props.achievedPointsCount,
FAIL: 0,
UNKNOWN: props.maxPointsCount - props.achievedPointsCount,
}));
</script>
<template>
<BaseBox :details-link="detailsLink">
<template #title>{{ $t("a.Kompetenznachweis-Elemente") }}</template>
<template #content>
<div class="flex items-center">
<i18next :translation="$t('a.NUMBER Elemente abgeschlossen')">
<template #NUMBER>
<span class="mr-3 text-4xl font-bold">{{ totalAssignments }}</span>
</template>
</i18next>
</div>
<div class="flex items-center">
<i18next :translation="$t('a.xOfY Punkten erreicht')">
<template #xOfY>
<span class="mr-3 text-4xl font-bold">
{{
$t("a.VALUE von MAXIMUM", {
VALUE: props.achievedPointsCount,
MAXIMUM: props.maxPointsCount,
})
}}
</span>
</template>
</i18next>
</div>
<ItProgress :status-count="progress"></ItProgress>
</template>
</BaseBox>
</template>

View File

@ -0,0 +1,54 @@
<script setup lang="ts">
import ItProgress from "@/components/ui/ItProgress.vue";
import { computed } from "vue";
import BaseBox from "@/components/dashboard/BaseBox.vue";
const props = defineProps<{
assignmentsCompleted: number;
avgPassed: number;
}>();
const progress = computed(() => {
return {
SUCCESS: props.avgPassed,
FAIL: 100 - props.avgPassed,
UNKNOWN: 0,
};
});
</script>
<template>
<BaseBox
:details-link="'/statistic/assignment'"
data-cy="dashboard.stats.assignments"
>
<template #title>{{ $t("a.Kompetenznachweis-Elemente") }}</template>
<template #content>
<div class="flex items-center">
<i18next :translation="$t('a.NUMBER Elemente abgeschlossen')">
<template #NUMBER>
<span
class="mr-3 text-4xl font-bold"
data-cy="dashboard.stats.assignments.completed"
>
{{ assignmentsCompleted }}
</span>
</template>
</i18next>
</div>
<div class="flex items-center">
<i18next :translation="$t('a.{NUMBER} Bestanden')">
<template #NUMBER>
<span
class="mr-3 text-4xl font-bold"
data-cy="dashboard.stats.assignments.passed"
>
{{ `${Math.round(props.avgPassed)}%` }}
</span>
</template>
</i18next>
</div>
<ItProgress :status-count="progress"></ItProgress>
</template>
</BaseBox>
</template>

View File

@ -0,0 +1,51 @@
<script setup lang="ts">
import ItProgress from "@/components/ui/ItProgress.vue";
import { computed } from "vue";
import BaseBox from "@/components/dashboard/BaseBox.vue";
const props = defineProps<{
daysCompleted: number;
avgParticipantsPresent: number;
}>();
const progressRecord = computed(() => {
return {
SUCCESS: props.avgParticipantsPresent,
FAIL: 100 - props.avgParticipantsPresent,
UNKNOWN: 0,
};
});
</script>
<template>
<BaseBox :details-link="'/statistic/attendance'" data-cy="dashboard.stats.attendance">
<template #title>{{ $t("a.Anwesenheit") }}</template>
<template #content>
<div class="flex items-center">
<i18next :translation="$t('a.NUMBER Präsenztage abgeschlossen')">
<template #NUMBER>
<span
class="mr-3 text-4xl font-bold"
data-cy="dashboard.stats.attendance.dayCompleted"
>
{{ daysCompleted }}
</span>
</template>
</i18next>
</div>
<div class="flex items-center">
<i18next :translation="$t('a.NUMBER Teilnehmer anwesend')">
<template #NUMBER>
<span
class="mr-3 text-4xl font-bold"
data-cy="dashboard.stats.attendance.participantsPresent"
>
{{ `${avgParticipantsPresent}%` }}
</span>
</template>
</i18next>
</div>
<ItProgress :status-count="progressRecord"></ItProgress>
</template>
</BaseBox>
</template>

View File

@ -0,0 +1,22 @@
<script setup lang="ts">
defineProps<{
detailsLink: string;
}>();
</script>
<template>
<div class="flex flex-col space-y-4 bg-white p-6">
<h4 class="mb-1 font-bold">
<slot name="title"></slot>
</h4>
<slot name="content"></slot>
<div class="flex-grow"></div>
<div class="pt-8">
<router-link class="underline" :to="detailsLink" data-cy="basebox.detailsLink">
{{ $t("a.Details anschauen") }}
</router-link>
</div>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,43 @@
<script setup lang="ts">
import BaseBox from "@/components/dashboard/BaseBox.vue";
defineProps<{
failCount: number;
successCount: number;
detailsLink: string;
}>();
</script>
<template>
<BaseBox :details-link="detailsLink" data-cy="dashboard.stats.competence">
<template #title>{{ $t("a.Selbsteinschätzungen") }}</template>
<template #content>
<div class="flex items-center">
<it-icon-smiley-happy class="mr-4 h-12 w-12"></it-icon-smiley-happy>
<i18next :translation="$t('a.{NUMBER} Das kann ich')">
<template #NUMBER>
<span
class="mr-3 text-4xl font-bold"
data-cy="dashboard.stats.competence.success"
>
{{ successCount }}
</span>
</template>
</i18next>
</div>
<div class="flex items-center">
<it-icon-smiley-thinking class="mr-4 h-12 w-12"></it-icon-smiley-thinking>
<i18next :translation="$t('a.{NUMBER} Das will ich nochmals anschauen')">
<template #NUMBER>
<span
class="mr-3 text-4xl font-bold"
data-cy="dashboard.stats.competence.fail"
>
{{ failCount }}
</span>
</template>
</i18next>
</div>
</template>
</BaseBox>
</template>

View File

@ -0,0 +1,41 @@
<script setup lang="ts">
import DueDatesList from "@/components/dueDates/DueDatesList.vue";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useDashboardStore } from "@/stores/dashboard";
import { getCockpitUrl } from "@/utils/utils";
const dashboardStore = useDashboardStore();
const courseSessionsStore = useCourseSessionsStore();
const allDueDates = courseSessionsStore.allDueDates();
</script>
<template>
<template v-if="dashboardStore.currentDashboardConfig">
<h4 class="mb-6 text-xl font-bold">{{ $t("a.Aktueller Lehrgang") }}</h4>
<div class="mb-6 border border-gray-300 p-6">
<h3 class="mb-6">{{ dashboardStore.currentDashboardConfig.name }}</h3>
<router-link
class="btn-blue"
target="_blank"
:to="getCockpitUrl(dashboardStore.currentDashboardConfig.slug)"
>
{{ $t("a.Cockpit anschauen") }}
</router-link>
</div>
</template>
<router-link class="mb-16 block text-sm underline" to="/statistic/list">
{{ $t("a.Alle Lehrgänge anzeigen") }}
</router-link>
<h3 class="mb-6 text-xl font-bold">{{ $t("a.AlleTermine") }}</h3>
<DueDatesList
:due-dates="allDueDates"
:max-count="13"
:show-top-border="true"
:show-all-due-dates-link="true"
:show-bottom-border="true"
:show-course-session="true"
></DueDatesList>
</template>

View File

@ -0,0 +1,34 @@
<script setup lang="ts">
defineProps<{
participantCount: number;
expertCount: number;
sessionCount: number;
}>();
</script>
<template>
<div class="flex items-center justify-between bg-white p-6">
<div class="flex space-x-6">
<div class="flex items-center space-x-2">
<span class="text-4xl font-bold" data-cy="dashboard.stats.participant.count">
{{ participantCount }}
</span>
<span>{{ $t("a.Teilnehmer") }}</span>
</div>
<div class="flex items-center space-x-2">
<span class="text-4xl font-bold" data-cy="dashboard.stats.expert.count">
{{ expertCount }}
</span>
<span>{{ $t("a.Trainer") }}</span>
</div>
<div class="flex items-center space-x-2">
<span class="text-4xl font-bold" data-cy="dashboard.stats.session.count">
{{ sessionCount }}
</span>
<span>{{ $t("a.Durchführungen") }}</span>
</div>
</div>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,57 @@
<script setup lang="ts">
import BaseBox from "@/components/dashboard/BaseBox.vue";
import { getBlendedColorForRating } from "@/utils/ratingToColor";
import { computed } from "vue";
const props = defineProps<{
feedbackCount: number;
statisfactionMax: number;
statisfactionAvg: number;
}>();
const satisfactionColor = computed(() => {
return getBlendedColorForRating(props.statisfactionAvg);
});
</script>
<template>
<BaseBox :details-link="'/statistic/feedback'" data-cy="dashboard.stats.feedback">
<template #title>{{ $t("a.Feedback Teilnehmer") }}</template>
<template #content>
<div class="flex items-center">
<!-- Left Pane -->
<div
class="mr-3 flex items-center justify-center rounded p-4"
:style="{ backgroundColor: satisfactionColor }"
>
<i18next :translation="$t('a.{AVG} von {MAX}')">
<template #AVG>
<span
class="pr-2 text-4xl font-bold"
data-cy="dashboard.stats.feedback.average"
>
{{ props.statisfactionAvg.toFixed(1) }}
</span>
</template>
<template #MAX>
<span class="pl-1 text-sm">{{ props.statisfactionMax }}</span>
</template>
</i18next>
</div>
<!-- Right Pane -->
<div class="flex flex-col items-center space-y-1">
<span class="font-bold">{{ $t("a.Allgemeine Zufriedenheit") }}</span>
<div class="self-start">
<i18next :translation="$t('a.Total {NUMBER} Antworten')">
<template #NUMBER>
<span class="font-bold" data-cy="dashboard.stats.feedback.count">
{{ props.feedbackCount }}
</span>
</template>
</i18next>
</div>
</div>
</div>
</template>
</BaseBox>
</template>

View File

@ -0,0 +1,19 @@
<script setup lang="ts">
import DueDatesList from "@/components/dueDates/DueDatesList.vue";
import { useCourseSessionsStore } from "@/stores/courseSessions";
const courseSessionsStore = useCourseSessionsStore();
const allDueDates = courseSessionsStore.allDueDates();
</script>
<template>
<h4 class="mb-6 text-xl font-bold">{{ $t("a.AlleTermine") }}</h4>
<DueDatesList
:due-dates="allDueDates"
:max-count="13"
:show-top-border="true"
:show-all-due-dates-link="true"
:show-bottom-border="true"
:show-course-session="true"
></DueDatesList>
</template>

View File

@ -0,0 +1,104 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import { useTranslation } from "i18next-vue";
import type { StatisticsCourseSessionPropertiesType } from "@/gql/graphql";
const { t } = useTranslation();
interface Item {
_id: string;
course_session_id: string;
generation: string;
circle_id: string;
}
const props = defineProps<{
items: Item[];
courseSessionProperties: StatisticsCourseSessionPropertiesType;
}>();
const sessionFilter = computed(() => {
const f = props.courseSessionProperties.sessions.map((session) => ({
name: `${t("a.Durchfuehrung")}: ${session.name}`,
id: session.id,
}));
return [{ name: t("a.AlleDurchführungen"), id: "_all" }, ...f];
});
const generationFilter = computed(() => {
const f = props.courseSessionProperties.generations.map((generation) => ({
name: `${t("a.Generation")}: ${generation}`,
id: generation,
}));
return [{ name: t("a.AlleGenerationen"), id: "_all" }, ...f];
});
const circleFilter = computed(() => {
const f = props.courseSessionProperties.circles.map((circle) => ({
name: `Circle: ${circle.name}`,
id: circle.id,
}));
return [{ name: t("a.AlleCircle"), id: "_all" }, ...f];
});
const sessionFilterValue = ref(sessionFilter.value[0]);
const generationFilterValue = ref(generationFilter.value[0]);
const circleFilterValue = ref(circleFilter.value[0]);
watch(
() => props.courseSessionProperties,
() => {
sessionFilterValue.value = sessionFilter.value[0];
generationFilterValue.value = generationFilter.value[0];
circleFilterValue.value = circleFilter.value[0];
},
{ deep: true }
);
const filteredItems = computed(() => {
return props.items.filter((item) => {
const sessionMatch =
sessionFilterValue.value.id === "_all" ||
item.course_session_id === sessionFilterValue.value.id;
const generationMatch =
generationFilterValue.value.id === "_all" ||
item.generation === generationFilterValue.value.id;
const circleMatch =
circleFilterValue.value.id === "_all" ||
item.circle_id === circleFilterValue.value.id;
return sessionMatch && generationMatch && circleMatch;
});
});
</script>
<template>
<div>
<div class="flex flex-col space-x-2 lg:flex-row">
<ItDropdownSelect
v-model="sessionFilterValue"
class="min-w-[18rem]"
:items="sessionFilter"
borderless
></ItDropdownSelect>
<ItDropdownSelect
v-model="generationFilterValue"
class="min-w-[18rem]"
:items="generationFilter"
borderless
></ItDropdownSelect>
<ItDropdownSelect
v-model="circleFilterValue"
class="min-w-[18rem]"
:items="circleFilter"
borderless
></ItDropdownSelect>
</div>
<div v-for="item in filteredItems" :key="item._id" class="px-5">
<div class="border-t border-gray-500 py-4">
<slot :item="item"></slot>
</div>
</div>
</div>
</template>

View File

@ -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 "";
});
</script>
<template>
@ -60,7 +53,6 @@ const circleClasses = computed(() => {
<LearningPathCircle
v-for="circle in circles"
:key="circle.id"
:class="circleClasses"
:sectors="calculateCircleSectorData(circle)"
></LearningPathCircle>
</div>

View File

@ -0,0 +1,21 @@
<template>
<div role="status">
<svg
aria-hidden="true"
class="mr-2 h-8 w-8 animate-spin fill-blue-900 text-gray-200 dark:text-gray-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span class="sr-only">Loading...</span>
</div>
</template>

View File

@ -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,

View File

@ -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 };
}

View File

@ -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.
*/

File diff suppressed because one or more lines are too long

View File

@ -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!

View File

@ -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";

View File

@ -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,

View File

@ -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
}
}
}
}
`);

View File

@ -0,0 +1,30 @@
<script setup lang="ts">
import { getCockpitUrl } from "@/utils/utils";
import { useDashboardStore } from "@/stores/dashboard";
const dashboardStore = useDashboardStore();
</script>
<template>
<div v-if="dashboardStore.currentDashboardConfig">
<div class="mb-14 flex flex-col space-y-8">
<div
v-for="config in dashboardStore.dashboardConfigs"
:key="config.id"
class="h-full bg-white p-6"
>
<h3 class="mb-4">{{ config.name }}</h3>
<div>
<router-link
class="btn-blue"
target="_blank"
:to="getCockpitUrl(config.slug)"
:data-cy="`continue-course-${config.id}`"
>
{{ $t("a.Cockpit anschauen") }}
</router-link>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,61 @@
<script setup lang="ts">
import type { Component } from "vue";
import { onMounted } from "vue";
import StatisticPage from "@/pages/dashboard/StatisticPage.vue";
import ProgressPage from "@/pages/dashboard/ProgressPage.vue";
import SimpleDates from "@/components/dashboard/SimpleDates.vue";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import { useDashboardStore } from "@/stores/dashboard";
import type { DashboardType } from "@/gql/graphql";
import SimpleCoursePage from "@/pages/dashboard/SimpleCoursePage.vue";
import LoadingSpinner from "@/components/ui/LoadingSpinner.vue";
import CourseDetailDates from "@/components/dashboard/CourseDetailDates.vue";
const dashboardStore = useDashboardStore();
interface DashboardPage {
main: Component;
aside: Component;
}
const boards: Record<DashboardType, DashboardPage> = {
PROGRESS_DASHBOARD: { main: ProgressPage, aside: SimpleDates },
SIMPLE_DASHBOARD: { main: SimpleCoursePage, aside: SimpleDates },
STATISTICS_DASHBOARD: { main: StatisticPage, aside: CourseDetailDates },
};
onMounted(dashboardStore.loadDashboardDetails);
</script>
<template>
<div v-if="dashboardStore.loading" class="m-8 flex justify-center">
<LoadingSpinner />
</div>
<div
v-else-if="dashboardStore.currentDashboardConfig"
class="flex flex-col lg:flex-row"
>
<main class="grow bg-gray-200 lg:order-2">
<div class="m-8">
<div class="mb-10 flex items-center justify-between">
<h1 data-cy="dashboard-title">Dashboard</h1>
<ItDropdownSelect
:model-value="dashboardStore.currentDashboardConfig"
class="mt-4 w-full lg:mt-0 lg:w-96"
:items="dashboardStore.dashboardConfigs"
@update:model-value="dashboardStore.switchAndLoadDashboardConfig"
></ItDropdownSelect>
</div>
<component
:is="boards[dashboardStore.currentDashboardConfig.dashboard_type].main"
></component>
</div>
</main>
<aside class="m-8 lg:order-1 lg:w-[343px]">
<component
:is="boards[dashboardStore.currentDashboardConfig.dashboard_type].aside"
></component>
</aside>
</div>
</template>

View File

@ -0,0 +1,89 @@
<script setup lang="ts">
import { useDashboardStore } from "@/stores/dashboard";
import { computed } from "vue";
import LearningPathDiagram from "@/components/learningPath/LearningPathDiagram.vue";
import { getLearningPathUrl } from "@/utils/utils";
import type {
CourseProgressType,
ProgressDashboardAssignmentType,
ProgressDashboardCompetenceType,
} from "@/gql/graphql";
import CompetenceSummaryBox from "@/components/dashboard/CompetenceSummaryBox.vue";
import AssignmentProgressSummaryBox from "@/components/dashboard/AssignmentProgressSummaryBox.vue";
const dashboardStore = useDashboardStore();
const courseSlug = computed(() => dashboardStore.currentDashboardConfig?.slug);
const courseName = computed(() => dashboardStore.currentDashboardConfig?.name);
const courseSessionProgress = computed(() => {
return dashboardStore.currentDashBoardData as CourseProgressType;
});
const DEFAULT_ASSIGNMENT = {
points_achieved_count: 0,
points_max_count: 0,
total_count: 0,
};
const DEFAULT_COMPETENCE = { total_count: 0, success_count: 0, fail_count: 0 };
const assignment = computed<ProgressDashboardAssignmentType>(
() =>
(courseSessionProgress.value?.assignment as ProgressDashboardAssignmentType) ??
DEFAULT_ASSIGNMENT
);
const competence = computed<ProgressDashboardCompetenceType>(
() =>
(courseSessionProgress.value?.competence as ProgressDashboardCompetenceType) ??
DEFAULT_COMPETENCE
);
const competenceCertificateUrl = computed(() => {
return `/course/${courseSlug.value}/competence/certificates?courseSessionId=${courseSessionProgress.value?.session_to_continue_id}`;
});
const competenceCriteriaUrl = computed(() => {
return `/course/${courseSlug.value}/competence/criteria?courseSessionId=${courseSessionProgress.value?.session_to_continue_id}`;
});
</script>
<template>
<div
v-if="courseSessionProgress && dashboardStore.currentDashboardConfig"
class="mb-14 space-y-8"
>
<div class="flex flex-col space-y-7 bg-white p-6">
<h3>{{ courseName }}</h3>
<LearningPathDiagram
v-if="courseSessionProgress.session_to_continue_id && courseSlug"
:key="courseSlug"
:course-slug="courseSlug"
:course-session-id="courseSessionProgress.session_to_continue_id"
diagram-type="horizontal"
></LearningPathDiagram>
<div>
<router-link
class="btn-blue"
:to="getLearningPathUrl(dashboardStore.currentDashboardConfig.slug)"
:data-cy="`continue-course-${dashboardStore.currentDashboardConfig.id}`"
>
{{ $t("general.nextStep") }}
</router-link>
</div>
</div>
<div class="grid auto-rows-fr grid-cols-1 gap-8 xl:grid-cols-2">
<AssignmentProgressSummaryBox
:total-assignments="assignment.total_count"
:achieved-points-count="assignment.points_achieved_count"
:max-points-count="assignment.points_max_count"
:details-link="competenceCertificateUrl"
/>
<CompetenceSummaryBox
:fail-count="competence.fail_count"
:success-count="competence.success_count"
:details-link="competenceCriteriaUrl"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,25 @@
<script setup lang="ts">
import { getCockpitUrl } from "@/utils/utils";
import { useDashboardStore } from "@/stores/dashboard";
const dashboardStore = useDashboardStore();
</script>
<template>
<div v-if="dashboardStore.currentDashboardConfig">
<div class="mb-14">
<div class="h-full bg-white p-6">
<h3 class="mb-4">{{ dashboardStore.currentDashboardConfig.name }}</h3>
<div>
<router-link
class="btn-blue"
:to="getCockpitUrl(dashboardStore.currentDashboardConfig.slug)"
:data-cy="`continue-course-${dashboardStore.currentDashboardConfig.id}`"
>
{{ $t("a.Cockpit anschauen") }}
</router-link>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import { useDashboardStore } from "@/stores/dashboard";
import { computed } from "vue";
import CourseStatistics from "@/components/dashboard/CourseStatistics.vue";
import AttendanceSummaryBox from "@/components/dashboard/AttendanceSummaryBox.vue";
import type { CourseStatisticsType } from "@/gql/graphql";
import AssignmentSummaryBox from "@/components/dashboard/AssignmentSummaryBox.vue";
import FeedbackSummaryBox from "@/components/dashboard/FeedbackSummaryBox.vue";
import CompetenceSummaryBox from "@/components/dashboard/CompetenceSummaryBox.vue";
const dashboardStore = useDashboardStore();
const statistics = computed(() => {
return dashboardStore.currentDashBoardData as CourseStatisticsType;
});
const courseSessionSelectionMetrics = computed(() => {
return statistics.value.course_session_selection_metrics;
});
const attendanceDayPresences = computed(() => {
return statistics.value.attendance_day_presences.summary;
});
const assigmentSummary = computed(() => {
return statistics.value.assignments.summary;
});
const competenceSummary = computed(() => {
return statistics.value.competences.summary;
});
const feebackSummary = computed(() => {
return statistics.value.feedback_responses.summary;
});
</script>
<template>
<div v-if="statistics" class="mb-14 space-y-8">
<CourseStatistics
:session-count="courseSessionSelectionMetrics.session_count"
:participant-count="courseSessionSelectionMetrics.participant_count"
:expert-count="courseSessionSelectionMetrics.expert_count"
/>
<div class="grid auto-rows-fr grid-cols-1 gap-8 xl:grid-cols-2">
<AttendanceSummaryBox
:days-completed="attendanceDayPresences.days_completed"
:avg-participants-present="attendanceDayPresences.participants_present"
/>
<AssignmentSummaryBox
:assignments-completed="assigmentSummary.completed_count"
:avg-passed="assigmentSummary.average_passed"
/>
<FeedbackSummaryBox
:feedback-count="feebackSummary.total_responses"
:statisfaction-max="feebackSummary.satisfaction_max"
:statisfaction-avg="feebackSummary.satisfaction_average"
/>
<CompetenceSummaryBox
:fail-count="competenceSummary.fail_total"
:success-count="competenceSummary.success_total"
details-link="/statistic/competence"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,108 @@
<script setup lang="ts">
import { useDashboardStore } from "@/stores/dashboard";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import { computed } from "vue";
import type {
AssignmentCompletionMetricsType,
AssignmentStatisticsRecordType,
CourseStatisticsType,
} from "@/gql/graphql";
import StatisticFilterList from "@/components/dashboard/StatisticFilterList.vue";
import { useCourseStatistics } from "@/composables";
import ItProgress from "@/components/ui/ItProgress.vue";
import { getDateString } from "@/components/dueDates/dueDatesUtils";
import dayjs from "dayjs";
const dashboardStore = useDashboardStore();
const statistics = computed(() => {
return dashboardStore.currentDashBoardData as CourseStatisticsType;
});
const { courseSessionName, circleMeta } = useCourseStatistics();
const assignmentStats = (metrics: AssignmentCompletionMetricsType) => {
if (!metrics.ranking_completed) {
return {
SUCCESS: 0,
FAIL: 0,
UNKNOWN: 0,
};
}
return {
SUCCESS: metrics.passed_count,
FAIL: metrics.failed_count,
UNKNOWN: metrics.unranked_count,
};
};
const total = (metrics: AssignmentCompletionMetricsType) => {
return metrics.passed_count + metrics.failed_count;
};
</script>
<template>
<main v-if="statistics">
<div class="mb-10 flex items-center justify-between">
<h3>{{ $t("a.Arbeiten") }}</h3>
<ItDropdownSelect
:model-value="dashboardStore.currentDashboardConfig"
class="mt-4 w-full lg:mt-0 lg:w-96"
:items="dashboardStore.dashboardConfigs"
@update:model-value="dashboardStore.switchAndLoadDashboardConfig"
></ItDropdownSelect>
</div>
<div v-if="statistics.assignments.records" class="mt-8 bg-white">
<StatisticFilterList
:course-session-properties="statistics.course_session_properties"
:items="statistics.assignments.records"
>
<template #default="{ item }">
<div class="flex justify-between">
<div>
<h4 class="font-bold">
{{ (item as AssignmentStatisticsRecordType).assignment_title }}
</h4>
<div>
{{ $t("a.Durchfuehrung") }} «{{
courseSessionName(item.course_session_id)
}}» - Circle «{{ circleMeta(item.circle_id)?.name }}»
</div>
<div class="mt-4">
{{ $t("a.Abgabetermin") }}:
{{
getDateString(
dayjs((item as AssignmentStatisticsRecordType).deadline)
)
}}
</div>
</div>
<div>
<div
v-if="(item as AssignmentStatisticsRecordType).metrics.ranking_completed"
>
{{ (item as AssignmentStatisticsRecordType).metrics.passed_count }} von
{{ total((item as AssignmentStatisticsRecordType).metrics) }}
bestanden
</div>
<div v-else>Noch nicht bestätigt</div>
<ItProgress
:status-count="
assignmentStats((item as AssignmentStatisticsRecordType).metrics)
"
></ItProgress>
<router-link
class="underline"
target="_blank"
:to="(item as AssignmentStatisticsRecordType).details_url"
>
{{ $t("a.Details anschauen") }}
</router-link>
</div>
</div>
</template>
</StatisticFilterList>
</div>
</main>
</template>

View File

@ -0,0 +1,93 @@
<script setup lang="ts">
import { useDashboardStore } from "@/stores/dashboard";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import { computed } from "vue";
import type { CourseStatisticsType, PresenceRecordStatisticsType } from "@/gql/graphql";
import StatisticFilterList from "@/components/dashboard/StatisticFilterList.vue";
import ItProgress from "@/components/ui/ItProgress.vue";
import { useCourseStatistics } from "@/composables";
import { getDateString } from "@/components/dueDates/dueDatesUtils";
import dayjs from "dayjs";
const dashboardStore = useDashboardStore();
const statistics = computed(() => {
return dashboardStore.currentDashBoardData as CourseStatisticsType;
});
const { courseSessionName, circleMeta } = useCourseStatistics();
const attendanceStats = (present: number, total: number) => {
return {
SUCCESS: present,
FAIL: total - present,
UNKNOWN: 0,
};
};
</script>
<template>
<main v-if="statistics">
<div class="mb-10 flex items-center justify-between">
<h3>{{ $t("Anwesenheit") }}</h3>
<ItDropdownSelect
:model-value="dashboardStore.currentDashboardConfig"
class="mt-4 w-full lg:mt-0 lg:w-96"
:items="dashboardStore.dashboardConfigs"
@update:model-value="dashboardStore.switchAndLoadDashboardConfig"
></ItDropdownSelect>
</div>
<div v-if="statistics.attendance_day_presences.records" class="mt-8 bg-white">
<StatisticFilterList
:course-session-properties="statistics.course_session_properties"
:items="statistics.attendance_day_presences.records"
>
<template #default="{ item }">
<div class="flex justify-between">
<div>
<h4 class="font-bold">
{{ $t("a.Präsenztag") }}: Circle «{{
circleMeta(item.circle_id)?.name
}}»
</h4>
<div>
{{ $t("a.Durchfuehrung") }} «{{
courseSessionName(item.course_session_id)
}}»
</div>
<div class="mt-4">
{{ $t("a.Termin") }}:
{{
getDateString(dayjs((item as PresenceRecordStatisticsType).due_date))
}}
</div>
</div>
<div>
<div>
{{
$t("a.present von total Teilnehmenden anwesend", {
present: (item as PresenceRecordStatisticsType)
.participants_present,
total: (item as PresenceRecordStatisticsType).participants_total,
})
}}
</div>
<ItProgress
:status-count="
attendanceStats((item as PresenceRecordStatisticsType).participants_present, (item as PresenceRecordStatisticsType).participants_total)
"
></ItProgress>
<router-link
class="underline"
target="_blank"
:to="(item as PresenceRecordStatisticsType).details_url"
>
{{ $t("a.Details anschauen") }}
</router-link>
</div>
</div>
</template>
</StatisticFilterList>
</div>
</main>
</template>

View File

@ -0,0 +1,71 @@
<script setup lang="ts">
import { useDashboardStore } from "@/stores/dashboard";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import { computed } from "vue";
import type {
CompetenceRecordStatisticsType,
CourseStatisticsType,
} from "@/gql/graphql";
import StatisticFilterList from "@/components/dashboard/StatisticFilterList.vue";
import { useCourseStatistics } from "@/composables";
const dashboardStore = useDashboardStore();
const statistics = computed(() => {
return dashboardStore.currentDashBoardData as CourseStatisticsType;
});
const { courseSessionName, circleMeta } = useCourseStatistics();
</script>
<template>
<main v-if="statistics">
<div class="mb-10 flex items-center justify-between">
<h3>{{ $t("a.Selbsteinschätzung") }}</h3>
<ItDropdownSelect
:model-value="dashboardStore.currentDashboardConfig"
class="mt-4 w-full lg:mt-0 lg:w-96"
:items="dashboardStore.dashboardConfigs"
@update:model-value="dashboardStore.switchAndLoadDashboardConfig"
></ItDropdownSelect>
</div>
<div v-if="statistics.competences.records" class="mt-8 bg-white">
<StatisticFilterList
:course-session-properties="statistics.course_session_properties"
:items="statistics.competences.records"
>
<template #default="{ item }">
<div class="flex justify-between">
<div>
<h4 class="font-bold">
{{ $t("a.Selbsteinschätzung") }}:
{{ (item as CompetenceRecordStatisticsType).title }}
</h4>
<div>
Durchführung «{{ courseSessionName(item.course_session_id) }}» - Circle
«{{ circleMeta(item.circle_id)?.name }}»
</div>
</div>
<div>
<div class="mb-4 flex items-center space-x-2">
<it-icon-smiley-happy class="h-8 w-8"></it-icon-smiley-happy>
<span class="pr-3">
{{ (item as CompetenceRecordStatisticsType).success_count }}
</span>
<it-icon-smiley-thinking class="h-8 w-8"></it-icon-smiley-thinking>
<span>{{ (item as CompetenceRecordStatisticsType).fail_count }}</span>
</div>
<router-link
class="whitespace-nowrap underline"
target="_blank"
:to="(item as CompetenceRecordStatisticsType).details_url"
>
{{ $t("a.Details anschauen") }}
</router-link>
</div>
</div>
</template>
</StatisticFilterList>
</div>
</main>
</template>

View File

@ -0,0 +1,91 @@
<script setup lang="ts">
import { useDashboardStore } from "@/stores/dashboard";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import { computed } from "vue";
import type {
CourseStatisticsType,
FeedbackStatisticsRecordType,
PresenceRecordStatisticsType,
} from "@/gql/graphql";
import StatisticFilterList from "@/components/dashboard/StatisticFilterList.vue";
import { useCourseStatistics } from "@/composables";
import { getBlendedColorForRating } from "@/utils/ratingToColor";
const dashboardStore = useDashboardStore();
const statistics = computed(() => {
return dashboardStore.currentDashBoardData as CourseStatisticsType;
});
const { courseSessionName, circleMeta } = useCourseStatistics();
</script>
<template>
<main v-if="statistics">
<div class="mb-10 flex items-center justify-between">
<h3>{{ $t("a.Feedback Teilnehmer") }}</h3>
<ItDropdownSelect
:model-value="dashboardStore.currentDashboardConfig"
class="mt-4 w-full lg:mt-0 lg:w-96"
:items="dashboardStore.dashboardConfigs"
@update:model-value="dashboardStore.switchAndLoadDashboardConfig"
></ItDropdownSelect>
</div>
<div v-if="statistics.feedback_responses.records" class="mt-8 bg-white">
<StatisticFilterList
:course-session-properties="statistics.course_session_properties"
:items="statistics.feedback_responses.records"
>
<template #default="{ item }">
<div class="flex justify-between">
<div>
<h4 class="font-bold">
Feedback: Circle «{{ circleMeta(item.circle_id)?.name }}»
</h4>
<div>
{{ $t("a.Durchfuehrung") }} «{{
courseSessionName(item.course_session_id)
}}» - Trainer: {{ (item as FeedbackStatisticsRecordType).experts }}
</div>
</div>
<div>
<div class="mb-4 flex items-center space-x-2">
<div
class="rounded px-2 py-1"
:style="{ backgroundColor: getBlendedColorForRating((item as FeedbackStatisticsRecordType).satisfaction_average) }"
>
<i18next :translation="$t('a.{AVG} von {MAX}')">
<template #AVG>
<span class="font-bold">
{{
(
item as FeedbackStatisticsRecordType
).satisfaction_average.toFixed(1)
}}
</span>
</template>
<template #MAX>
<span>
{{ (item as FeedbackStatisticsRecordType).satisfaction_max }}
</span>
</template>
</i18next>
</div>
<span>
{{ $t("a.Allgemeine Zufriedenheit") }}
</span>
</div>
<router-link
class="underline"
target="_blank"
:to="(item as PresenceRecordStatisticsType).details_url"
>
{{ $t("a.Details anschauen") }}
</router-link>
</div>
</div>
</template>
</StatisticFilterList>
</div>
</main>
</template>

View File

@ -0,0 +1,23 @@
<script setup lang="ts">
import { useDashboardStore } from "@/stores/dashboard";
import { onMounted } from "vue";
import LoadingSpinner from "@/components/ui/LoadingSpinner.vue";
const dashboardStore = useDashboardStore();
onMounted(dashboardStore.loadDashboardDetails);
</script>
<template>
<div class="bg-gray-200">
<div v-if="dashboardStore.loading" class="m-8 flex justify-center">
<LoadingSpinner />
</div>
<div v-else class="container-large flex flex-col space-y-8">
<router-link class="btn-text inline-flex items-center pl-0" to="/">
<it-icon-arrow-left />
<span>{{ $t("general.back") }}</span>
</router-link>
<router-view></router-view>
</div>
</div>
</template>

View File

@ -130,7 +130,7 @@ function render() {
</script>
<template>
<div class="svg-container content-center">
<div class="aspect-square content-center">
<pre hidden>{{ pieData }}</pre>
<pre hidden>{{ render() }}</pre>
<svg :id="svgId" class="h-full min-w-[20px]">

View File

@ -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"),

View File

@ -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<CourseStatisticsType | null> => {
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<CourseProgressType | null> => {
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<DashboardConfigType[] | null> => {
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;
}
};

View File

@ -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<DashboardConfigType[]> = ref([]);
const currentDashboardConfig: Ref<DashboardConfigType | undefined> = ref();
const dashBoardDataCache: Record<
string,
CourseStatisticsType | CourseProgressType | null
> = {};
const currentDashBoardData: Ref<CourseStatisticsType | CourseProgressType | null> =
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,
};
});

View File

@ -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 {

View File

@ -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)");
});
});

View File

@ -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);
};

View File

@ -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»")
});
});
});

View File

@ -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",
);
});
});

View File

@ -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

View File

@ -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__)

View File

@ -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

View File

@ -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__)

View File

@ -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"

View File

@ -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,

View File

@ -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()

View File

@ -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

View File

@ -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)]

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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__)

View File

@ -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):

View File

@ -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,

View File

@ -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"])

View File

@ -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):
...

View File

@ -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

View File

@ -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"],
},
),
]

View File

@ -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

View File

@ -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."
)

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -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

View File

@ -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
),
)

View File

@ -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)

View File

@ -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

View File

@ -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
)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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
)

View File

@ -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)

View File

@ -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)

View File

@ -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",
)

View File

@ -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__)

View File

@ -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__)

View File

@ -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)

View File

@ -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

View File

View File

@ -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()

View File

@ -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,

View File

@ -79,6 +79,18 @@
</label>
<div style="margin-bottom: 8px; padding: 4px; border-bottom: 1px lightblue solid"></div>
<label>
<input type="checkbox" name="create_course_completion_performance_criteria" value="true">
create_course_completion_performance_criteria
</label>
<div style="margin-bottom: 8px; padding: 4px; border-bottom: 1px lightblue solid"></div>
<label>
<input type="checkbox" name="create_attendance_days" value="true">
create_attendance_days
</label>
<div style="margin-bottom: 8px; padding: 4px; border-bottom: 1px lightblue solid"></div>
<button class="btn">Testdaten zurück setzen</button>
</form>
</div>