Merged in feat/588-vv-fremdeinschatzung (pull request #277)

Feat/588 - VV Mentor Fremdeinschätzung

Approved-by: Daniel Egger
This commit is contained in:
Livio Bieri 2024-02-14 06:39:07 +00:00 committed by Christian Cueni
commit 4ab5df0753
81 changed files with 2911 additions and 199 deletions

View File

@ -1,6 +1,6 @@
2
<script setup lang="ts">
import AssignmentSubmissionProgress from "@/pages/cockpit/cockpitPage/AssignmentSubmissionProgress.vue";
import AssignmentSubmissionProgress from "@/components/cockpit/AssignmentSubmissionProgress.vue";
import type {
CourseSession,
LearningContent,
@ -10,7 +10,7 @@ import type {
import log from "loglevel";
import { computed } from "vue";
import { useTranslation } from "i18next-vue";
import FeedbackSubmissionProgress from "@/pages/cockpit/cockpitPage/FeedbackSubmissionProgress.vue";
import FeedbackSubmissionProgress from "@/components/cockpit/mentor/FeedbackSubmissionProgress.vue";
import { learningContentTypeData } from "@/utils/typeMaps";
import {
useCourseDataWithCompletion,

View File

@ -0,0 +1,23 @@
<script setup lang="ts">
import AssignmentItem from "@/components/cockpit/mentor/AssignmentItem.vue";
import type { RouteLocationRaw } from "vue-router";
defineProps<{
taskTitle: string;
circleTitle: string;
pendingTasks: number;
taskLink: RouteLocationRaw;
}>();
</script>
<template>
<AssignmentItem
:task-title="`${$t('a.Selbsteinschätzung')}: ${taskTitle}`"
:circle-title="circleTitle"
:pending-tasks="pendingTasks"
:task-link="taskLink"
:pending-tasks-label="$t('a.Selbsteinschätzungen geteilt')"
:task-link-pending-label="$t('a.Fremdeinschätzung vornehmen')"
:task-link-label="$t('a.Selbsteinschätzung anzeigen')"
/>
</template>

View File

@ -3,7 +3,7 @@ import { useCircleStore } from "@/stores/circle";
import type { CircleType, LearningUnit } from "@/types";
import * as log from "loglevel";
import { useCurrentCourseSession, useCourseDataWithCompletion } from "@/composables";
import { useCourseDataWithCompletion, useCurrentCourseSession } from "@/composables";
import LearningContentContainer from "@/pages/learningPath/learningContentPage/LearningContentContainer.vue";
import LearningContentMultiLayout from "@/pages/learningPath/learningContentPage/layouts/LearningContentMultiLayout.vue";
import eventBus from "@/utils/eventBus";
@ -11,35 +11,56 @@ import { useRouteQuery } from "@vueuse/router";
import { computed, onUnmounted } from "vue";
import { getPreviousRoute } from "@/router/history";
import { getCompetenceNaviUrl } from "@/utils/utils";
import SelfEvaluationRequestFeedbackPage from "@/pages/learningPath/selfEvaluationPage/SelfEvaluationRequestFeedbackPage.vue";
log.debug("LearningContent.vue setup");
const circleStore = useCircleStore();
const courseSession = useCurrentCourseSession();
const courseCompletionData = useCourseDataWithCompletion();
const questionIndex = useRouteQuery("step", "0", { transform: Number, mode: "push" });
const previousRoute = getPreviousRoute();
const props = defineProps<{
learningUnit: LearningUnit;
circle: CircleType;
}>();
const circleStore = useCircleStore();
const courseSession = useCurrentCourseSession();
const courseCompletionData = useCourseDataWithCompletion();
const questions = computed(() => props.learningUnit?.performance_criteria ?? []);
const numPages = computed(() => {
if (learningUnitHasFeedbackPage.value) {
return questions.value.length + 1;
} else {
return questions.value.length;
}
});
const questionIndex = useRouteQuery("step", "0", { transform: Number, mode: "push" });
const previousRoute = getPreviousRoute();
const learningUnitHasFeedbackPage = computed(
() => props.learningUnit?.feedback_user !== "NO_FEEDBACK"
);
const currentQuestion = computed(() => questions.value[questionIndex.value]);
const showPreviousButton = computed(() => questionIndex.value != 0);
const showNextButton = computed(
() => questionIndex.value + 1 < questions.value?.length && questions.value?.length > 1
() => questionIndex.value + 1 < numPages.value && numPages.value > 1
);
const showExitButton = computed(
() =>
questions.value?.length === 1 || questions.value?.length === questionIndex.value + 1
const isLastStep = computed(
() => questions.value?.length === 1 || numPages.value == questionIndex.value + 1
);
function handleContinue() {
log.debug("handleContinue");
if (questionIndex.value + 1 < questions.value.length) {
// not answering a question is allowed especially,
// nonetheless we want to still know this state in the backend!
if (currentQuestion.value && currentQuestion.value.completion_status === "UNKNOWN") {
courseCompletionData.markCompletion(currentQuestion.value, "UNKNOWN");
}
if (questionIndex.value + 1 < numPages.value) {
log.debug("increment questionIndex", questionIndex.value);
questionIndex.value += 1;
} else {
@ -50,7 +71,7 @@ function handleContinue() {
function handleBack() {
log.debug("handleBack");
if (questionIndex.value > 0 && questionIndex.value < questions.value.length) {
if (questionIndex.value > 0 && questionIndex.value < numPages.value) {
questionIndex.value -= 1;
}
}
@ -78,16 +99,20 @@ onUnmounted(() => {
:sub-title="$t('a.Selbsteinschätzung')"
:title="`${learningUnit.title}`"
icon="it-icon-lc-learning-module"
:steps-count="questions.length"
:steps-count="numPages"
:show-next-button="showNextButton"
:show-exit-button="showExitButton"
:show-exit-button="isLastStep"
:show-start-button="false"
:show-previous-button="showPreviousButton"
:base-url="props.learningUnit.evaluate_url"
:close-button-variant="learningUnitHasFeedbackPage ? 'close' : 'mark_as_done'"
:end-badge-text="
learningUnitHasFeedbackPage ? $t('general.submission') : undefined
"
@previous="handleBack()"
@next="handleContinue()"
>
<div class="h-full">
<div v-if="currentQuestion" class="h-full">
<div class="mt-8">
<h3 class="heading-3">
{{ currentQuestion.title }}
@ -137,6 +162,11 @@ onUnmounted(() => {
</div>
</div>
</div>
<SelfEvaluationRequestFeedbackPage
v-else-if="isLastStep && learningUnit.feedback_user == 'MENTOR_FEEDBACK'"
:learning-unit="props.learningUnit"
:criteria="questions"
/>
</LearningContentMultiLayout>
</LearningContentContainer>
</div>

View File

@ -2,7 +2,7 @@
import ItButton from "@/components/ui/ItButton.vue";
import ItCheckbox from "@/components/ui/ItCheckbox.vue";
import { ref } from "vue";
import { bustItGetCache, useCSRFFetch } from "@/fetchHelpers";
import { bustItGetCache } from "@/fetchHelpers";
import { useUserStore } from "@/stores/user";
import eventBus from "@/utils/eventBus";
import log from "loglevel";
@ -12,9 +12,8 @@ import { useMutation } from "@urql/vue";
import { UPSERT_ASSIGNMENT_COMPLETION_MUTATION } from "@/graphql/mutations";
import type { Assignment } from "@/types";
import DateEmbedding from "@/components/dueDates/DateEmbedding.vue";
import { useCurrentCourseSession } from "@/composables";
const currentCourseSession = useCurrentCourseSession();
import { useLearningMentors } from "@/composables";
import NoMentorInformationPanel from "@/components/mentor/NoMentorInformationPanel.vue";
const props = defineProps<{
submissionDeadlineStart?: string | null;
@ -29,10 +28,7 @@ const upsertAssignmentCompletionMutation = useMutation(
UPSERT_ASSIGNMENT_COMPLETION_MUTATION
);
const { data: learningMentors } = useCSRFFetch(
`/api/mentor/${props.courseSessionId}/mentors`
).json();
const learningMentors = useLearningMentors().learningMentors;
const selectedLearningMentor = ref();
const onSubmit = async () => {
@ -85,27 +81,7 @@ const onSubmit = async () => {
</div>
</div>
<div v-else class="my-6">
<div class="flex space-x-2 bg-sky-200 p-4">
<it-icon-info class="it-icon h-6 w-6 text-sky-700" />
<div>
<div class="mb-4">
{{
$t(
"a.Aktuell hast du noch keine Person als Lernbegleitung eingeladen. Lade jetzt jemanden ein."
)
}}
</div>
<router-link
:to="{
name: 'learningMentorManagement',
params: { courseSlug: currentCourseSession.course.slug },
}"
class="btn-blue px-4 py-2 font-bold"
>
{{ $t("a.Lernbegleitung einladen") }}
</router-link>
</div>
</div>
<NoMentorInformationPanel />
</div>
</div>
<p v-if="props.submissionDeadlineStart" class="pt-6">

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
import { useCurrentCourseSession } from "@/composables";
const currentCourseSession = useCurrentCourseSession();
</script>
<template>
<div class="flex space-x-2 bg-sky-200 p-4">
<it-icon-info class="it-icon h-6 w-6 text-sky-700" />
<div>
<div class="mb-4">
{{
$t(
"a.Aktuell hast du noch keine Person als Lernbegleitung eingeladen. Lade jetzt jemanden ein."
)
}}
</div>
<router-link
:to="{
name: 'learningMentorManagement',
params: { courseSlug: currentCourseSession.course.slug },
}"
class="btn-blue px-4 py-2 font-bold"
>
{{ $t("a.Lernbegleitung einladen") }}
</router-link>
</div>
</div>
</template>

View File

@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed } from "vue";
import {
type FeedbackRequest,
getFeedbackEvaluationCaption,
getSelfEvaluationCaption,
getSmiley,
} from "@/services/selfEvaluationFeedback";
const props = defineProps<{
feedback: FeedbackRequest;
}>();
const feedbackRequesterAvatar = computed(() => {
return props.feedback.feedback_requester_user.avatar_url;
});
const feedbackRequesterName = computed(() => {
return `${props.feedback.feedback_requester_user.first_name} ${props.feedback.feedback_requester_user.last_name}`;
});
</script>
<template>
<div
v-for="(criteria, index) in props.feedback.criteria"
:key="criteria.course_completion_id"
class="pb-10"
>
<span class="text-gray-900">{{ criteria.title }}</span>
<div class="mt-3 grid grid-cols-2 border-2 border-gray-200">
<!-- Feedback requester assessment -->
<div class="flex h-12 items-center pl-4">
<b>
{{
$t("a.Selbsteinschätzung von FEEDBACK_REQUESTER_NAME", {
FEEDBACK_REQUESTER_NAME: feedbackRequesterName,
})
}}
</b>
<img class="ml-2 h-7 w-7 rounded-full" :src="feedbackRequesterAvatar" />
</div>
<div class="flex items-center justify-start space-x-2 bg-white">
<component :is="getSmiley(criteria.self_assessment)" class="h-6 w-6" />
<span>{{ getSelfEvaluationCaption(criteria.self_assessment) }}</span>
</div>
<!-- Feedback provider assessment -->
<div class="flex h-12 items-center bg-gray-200 pl-4">
<b>{{ $t("a.Deine Fremdeinschätzung") }}</b>
</div>
<div class="flex items-center justify-between bg-gray-200">
<div class="flex justify-start space-x-2">
<component :is="getSmiley(criteria.feedback_assessment)" class="h-6 w-6" />
<span>
{{
getFeedbackEvaluationCaption(
criteria.feedback_assessment,
feedback.feedback_requester_user
)
}}
</span>
</div>
<router-link
v-if="!feedback.feedback_submitted"
:to="{
name: 'mentorSelfEvaluationFeedback',
params: { learningUnitId: feedback.learning_unit_id },
query: { step: index },
}"
class="mr-4 underline"
>
{{ $t("a.Bearbeiten") }}
</router-link>
</div>
</div>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,67 @@
<script setup lang="ts">
import {
type Criterion,
getFeedbackEvaluationCaption,
} from "@/services/selfEvaluationFeedback";
import type { User } from "@/types";
import { computed } from "vue";
const props = defineProps<{
criteria: Criterion;
requester: User;
}>();
const emit = defineEmits(["evaluation"]);
const fullname = computed(
() => `${props.requester.first_name} ${props.requester.last_name}`
);
const description = computed(() => `«${props.criteria.title}»`);
const currentEvaluation = computed(() => props.criteria.feedback_assessment);
const onPassed = () => {
emit("evaluation", props.criteria, "SUCCESS");
};
const onFailed = () => {
emit("evaluation", props.criteria, "FAIL");
};
</script>
<template>
<div class="mt-16 space-y-4 bg-gray-200 p-7">
<span>{{ $t("a.Leistungsziel") }}:</span>
<div class="text-bold text-xl">{{ description }}</div>
</div>
<div class="mt-16 flex flex-row items-center pb-4">
<span class="text-2xl font-bold">
{{ $t("a.Kann FULLNAME das?", { FULLNAME: fullname }) }}
</span>
<img class="ml-4 h-12 w-12 rounded" :src="requester.avatar_url" :alt="fullname" />
</div>
<div class="flex space-x-10">
<button
class="inline-flex flex-1 items-center border-2 p-4 text-left"
:class="currentEvaluation === 'SUCCESS' ? 'border-green-500' : 'border-gray-300'"
@click="onPassed"
>
<it-icon-smiley-happy class="mr-4 h-16 w-16"></it-icon-smiley-happy>
<span class="text-lg font-bold">
{{ getFeedbackEvaluationCaption("SUCCESS", requester) }}
</span>
</button>
<button
class="inline-flex flex-1 items-center border-2 p-4 text-left"
:class="currentEvaluation === 'FAIL' ? 'border-orange-500' : 'border-gray-300'"
@click="onFailed"
>
<it-icon-smiley-thinking class="mr-4 h-16 w-16"></it-icon-smiley-thinking>
<span class="text-lg font-bold">
{{ getFeedbackEvaluationCaption("FAIL", requester) }}
</span>
</button>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,41 @@
<script setup lang="ts">
import type { FeedbackRequest } from "@/services/selfEvaluationFeedback";
import FeedbackProvided from "@/components/selfEvaluationFeedback/FeedbackProvided.vue";
import ItButton from "@/components/ui/ItButton.vue";
defineProps<{
feedback: FeedbackRequest;
}>();
const emit = defineEmits(["release"]);
</script>
<template>
<div class="mb-12 mt-12 space-y-5 border-2 border-gray-200 p-7">
<div class="text text-bold text-xl">{{ $t("a.Fremdeinschätzung freigeben") }}</div>
<template v-if="!feedback.feedback_submitted">
<div>
{{
$t(
"a.Überprüfe deine Eingaben unten und gib anschliessend deine Fremdeinschätzung für FEEDBACK_REQUESTER frei",
{
FEEDBACK_REQUESTER: feedback.feedback_requester_user.first_name,
}
)
}}
</div>
<ItButton variant="primary" size="large" @click="emit('release')">
{{ $t("a.Fremdeinschätzung freigeben") }}
</ItButton>
</template>
<div v-else class="flex space-x-2 bg-green-200 p-4">
<it-icon-check class="it-icon h-6 w-6 text-green-700" />
<div>
{{ $t("a.Du hast deine Fremdeinschätzung freigegeben") }}
</div>
</div>
</div>
<FeedbackProvided :feedback="feedback" />
</template>
<style scoped></style>

View File

@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed } from "vue";
import {
type FeedbackRequest,
getFeedbackReceivedCaption,
getSelfEvaluationCaption,
getSmiley,
} from "@/services/selfEvaluationFeedback";
const props = defineProps<{
feedback: FeedbackRequest;
}>();
const feedbackProviderAvatar = computed(() => {
return props.feedback.feedback_provider_user.avatar_url;
});
const feedbackProviderName = computed(() => {
return `${props.feedback.feedback_provider_user.first_name} ${props.feedback.feedback_provider_user.last_name}`;
});
</script>
<template>
<div
v-for="criteria in props.feedback.criteria"
:key="criteria.course_completion_id"
class="mb-10"
>
<span class="text-gray-900">{{ criteria.title }}</span>
<div class="mt-3 grid grid-cols-2 border-2 border-gray-200">
<!-- Feedback requester assessment -->
<div class="flex h-12 items-center pl-4">
<b>{{ $t("a.Deine Selbsteinschätzung") }}</b>
</div>
<div class="flex items-center justify-start space-x-2 bg-white">
<component :is="getSmiley(criteria.self_assessment)" class="h-6 w-6" />
<span>{{ getSelfEvaluationCaption(criteria.self_assessment) }}</span>
</div>
<!-- Feedback provider assessment -->
<div class="flex h-12 items-center bg-gray-200 pl-4">
<b>
{{
$t("a.Fremdeinschätzung von FEEDBACK_PROVIDER_NAME", {
FEEDBACK_PROVIDER_NAME: feedbackProviderName,
})
}}
</b>
<img class="ml-2 h-7 w-7 rounded-full" :src="feedbackProviderAvatar" />
</div>
<div class="flex items-center justify-start space-x-2 bg-gray-200">
<component :is="getSmiley(criteria.feedback_assessment)" class="h-6 w-6" />
<span>{{ getFeedbackReceivedCaption(criteria.feedback_assessment) }}</span>
</div>
</div>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,51 @@
<script setup lang="ts">
import type { LearningUnit, LearningUnitPerformanceCriteria } from "@/types";
defineProps<{
learningUnit: LearningUnit;
criteria: LearningUnitPerformanceCriteria[];
showEditLink: boolean;
}>();
</script>
<template>
<div
v-for="(completion, index) in criteria"
:key="completion.id"
class="flex flex-col space-y-4 border-t border-gray-400 py-8"
>
<div class="flex justify-between">
<b>{{ completion.title }}</b>
<router-link
v-if="showEditLink"
:to="`${learningUnit.evaluate_url}?step=${index}`"
class="underline"
>
{{ $t("a.Bearbeiten") }}
</router-link>
</div>
<div
v-if="completion.completion_status == 'SUCCESS'"
class="flex flex-row items-center space-x-2"
>
<it-icon-smiley-happy class="h-6 w-6" />
<span>{{ $t("selfEvaluation.yes") }}</span>
</div>
<div
v-else-if="completion.completion_status == 'FAIL'"
class="flex flex-row items-center space-x-2"
>
<it-icon-smiley-thinking class="h-6 w-6" />
<span>{{ $t("selfEvaluation.no") }}</span>
</div>
<div
v-else-if="completion.completion_status == 'UNKNOWN'"
class="flex flex-row items-center space-x-2"
>
<it-icon-smiley-neutral class="h-6 w-6" />
<span>{{ $t("a.Nicht bewertet") }}</span>
</div>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,26 @@
<script setup lang="ts">
defineProps<{
feedbackMentorName: string;
}>();
</script>
<template>
<div class="flex space-x-2 bg-green-200 p-4">
<it-icon-check class="it-icon h-6 w-6 text-green-700" />
<div>
{{
$t("a.Du hast deine Selbsteinschätzung erfolgreich mit FULL_NAME geteilt.", {
FULL_NAME: feedbackMentorName,
})
}}
</div>
</div>
<div class="pt-6">
{{
$t(
"a.FULL_NAME wird eine Fremdeinschätzung für dich vornehmen. Du wirst per Benachrichtigung informiert, sobald die Fremdeinschätzung für dich freigegeben wurde.",
{ FULL_NAME: feedbackMentorName }
)
}}
</div>
</template>

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import type { DropdownSelectable } from "@/types";
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from "@headlessui/vue";
import { computed } from "vue";
import { computed } from "vue"; // https://stackoverflow.com/questions/64775876/vue-3-pass-reactive-object-to-component-with-two-way-binding
// https://stackoverflow.com/questions/64775876/vue-3-pass-reactive-object-to-component-with-two-way-binding
interface Props {
@ -11,6 +11,7 @@ interface Props {
};
items?: DropdownSelectable[];
borderless?: boolean;
placeholderText?: string | null;
}
const emit = defineEmits<{
@ -25,6 +26,7 @@ const props = withDefaults(defineProps<Props>(), {
};
},
items: () => [],
placeholderText: null,
});
const dropdownSelected = computed<DropdownSelectable>({
@ -47,7 +49,12 @@ const dropdownSelected = computed<DropdownSelectable>({
<span v-if="dropdownSelected.iconName" class="mr-4">
<component :is="dropdownSelected.iconName"></component>
</span>
<span class="block truncate">{{ dropdownSelected.name }}</span>
<span class="block truncate">
{{ dropdownSelected.name }}
<span v-if="placeholderText && !dropdownSelected.name" class="text-gray-900">
{{ placeholderText }}
</span>
</span>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>

View File

@ -1,3 +1,4 @@
import { useCSRFFetch } from "@/fetchHelpers";
import type { CourseStatisticsType } from "@/gql/graphql";
import { graphqlClient } from "@/graphql/client";
import { COURSE_QUERY, COURSE_SESSION_DETAIL_QUERY } from "@/graphql/queries";
@ -19,6 +20,7 @@ import type {
CourseSession,
CourseSessionDetail,
LearningContentWithCompletion,
LearningMentor,
LearningPathType,
LearningUnitPerformanceCriteria,
PerformanceCriteria,
@ -27,7 +29,7 @@ import { useQuery } from "@urql/vue";
import orderBy from "lodash/orderBy";
import log from "loglevel";
import type { ComputedRef } from "vue";
import { computed, ref, watchEffect } from "vue";
import { computed, onMounted, ref, watchEffect } from "vue";
export function useCurrentCourseSession() {
/**
@ -463,3 +465,25 @@ export function useFileUpload() {
return { upload, error, loading, fileInfo };
}
export function useLearningMentors() {
const learningMentors = ref<LearningMentor[]>([]);
const currentCourseSessionId = useCurrentCourseSession().value.id;
const loading = ref(false);
const fetchMentors = async () => {
loading.value = true;
const { data } = await useCSRFFetch(
`/api/mentor/${currentCourseSessionId}/mentors`
).json();
learningMentors.value = data.value;
loading.value = false;
};
onMounted(fetchMentors);
return {
learningMentors,
loading,
};
}

View File

@ -20,7 +20,7 @@ const documents = {
"\n query assignmentCompletionQuery(\n $assignmentId: ID!\n $courseSessionId: ID!\n $learningContentId: ID\n $assignmentUserId: UUID\n ) {\n assignment(id: $assignmentId) {\n assignment_type\n needs_expert_evaluation\n max_points\n content_type\n effort_required\n evaluation_description\n evaluation_document_url\n evaluation_tasks\n id\n intro_text\n performance_objectives\n slug\n tasks\n title\n translation_key\n solution_sample {\n id\n url\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n assignment_user_id: $assignmentUserId\n learning_content_page_id: $learningContentId\n ) {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_user {\n id\n first_name\n last_name\n }\n assignment_user {\n avatar_url\n first_name\n last_name\n id\n }\n evaluation_points\n evaluation_max_points\n evaluation_passed\n edoniq_extended_time_flag\n completion_data\n task_completion_data\n }\n }\n": types.AssignmentCompletionQueryDocument,
"\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 enable_circle_documents\n circle_contact_type\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 enable_circle_documents\n circle_contact_type\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 assignment_type\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 assignment_type\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 courseQuery($slug: String!) {\n course(slug: $slug) {\n id\n title\n slug\n category_name\n enable_circle_documents\n circle_contact_type\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 feedback_user\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 assignment_type\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 assignment_type\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,
@ -72,7 +72,7 @@ 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 enable_circle_documents\n circle_contact_type\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 assignment_type\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 assignment_type\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 enable_circle_documents\n circle_contact_type\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 assignment_type\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 assignment_type\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentRichTextObjectType {\n text\n }\n }\n }\n }\n }\n }\n }\n }\n }\n"];
export function graphql(source: "\n query courseQuery($slug: String!) {\n course(slug: $slug) {\n id\n title\n slug\n category_name\n enable_circle_documents\n circle_contact_type\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 feedback_user\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 assignment_type\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 assignment_type\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 enable_circle_documents\n circle_contact_type\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 feedback_user\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 assignment_type\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 assignment_type\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.
*/

File diff suppressed because one or more lines are too long

View File

@ -279,6 +279,7 @@ type PerformanceCriteriaObjectType implements CoursePageInterface {
type LearningUnitObjectType implements CoursePageInterface {
title_hidden: Boolean!
feedback_user: LearnpathLearningUnitFeedbackUserChoices!
id: ID!
title: String!
slug: String!
@ -292,6 +293,15 @@ type LearningUnitObjectType implements CoursePageInterface {
evaluate_url: String!
}
"""An enumeration."""
enum LearnpathLearningUnitFeedbackUserChoices {
"""NO_FEEDBACK"""
NO_FEEDBACK
"""MENTOR_FEEDBACK"""
MENTOR_FEEDBACK
}
interface LearningContentInterface {
id: ID!
title: String!

View File

@ -69,6 +69,7 @@ export const LearningPathObjectType = "LearningPathObjectType";
export const LearningSequenceObjectType = "LearningSequenceObjectType";
export const LearningUnitObjectType = "LearningUnitObjectType";
export const LearnpathLearningContentAssignmentAssignmentTypeChoices = "LearnpathLearningContentAssignmentAssignmentTypeChoices";
export const LearnpathLearningUnitFeedbackUserChoices = "LearnpathLearningUnitFeedbackUserChoices";
export const Mutation = "Mutation";
export const PerformanceCriteriaObjectType = "PerformanceCriteriaObjectType";
export const PresenceRecordStatisticsType = "PresenceRecordStatisticsType";

View File

@ -239,6 +239,7 @@ export const COURSE_QUERY = graphql(`
icon
...CoursePageFields
learning_units {
feedback_user
evaluate_url
...CoursePageFields
performance_criteria {

View File

@ -11,7 +11,7 @@ import type {
} from "@/types";
import log from "loglevel";
import { computed, onMounted, reactive } from "vue";
import AssignmentSubmissionProgress from "@/pages/cockpit/cockpitPage/AssignmentSubmissionProgress.vue";
import AssignmentSubmissionProgress from "@/components/cockpit/AssignmentSubmissionProgress.vue";
import { useCourseSessionDetailQuery } from "@/composables";
import { formatDueDate } from "../../../components/dueDates/dueDatesUtils";
import { stringifyParse } from "@/utils/utils";

View File

@ -3,12 +3,12 @@ import LearningPathDiagram from "@/components/learningPath/LearningPathDiagram.v
import ItPersonRow from "@/components/ui/ItPersonRow.vue";
import { useCourseSessionDetailQuery, useCurrentCourseSession } from "@/composables";
import SubmissionsOverview from "@/pages/cockpit/cockpitPage/SubmissionsOverview.vue";
import SubmissionsOverview from "@/components/cockpit/SubmissionsOverview.vue";
import { useExpertCockpitStore } from "@/stores/expertCockpit";
import log from "loglevel";
import CockpitDates from "@/pages/cockpit/cockpitPage/CockpitDates.vue";
import CockpitDates from "@/components/cockpit/CockpitDates.vue";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import UserStatusCount from "@/pages/cockpit/cockpitPage/UserStatusCount.vue";
import UserStatusCount from "@/components/cockpit/UserStatusCount.vue";
import { useExpertCockpitPageData } from "@/pages/cockpit/cockpitPage/composables";
const props = defineProps<{

View File

@ -1,11 +1,12 @@
<script setup lang="ts">
import type { PraxisAssignment } from "@/services/mentorCockpit";
import type { Assignment } from "@/services/mentorCockpit";
import { useMentorCockpit } from "@/services/mentorCockpit";
import { useCurrentCourseSession } from "@/composables";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import { computed, type Ref, ref } from "vue";
import PraxisAssignmentItem from "@/components/cockpit/mentor/PraxisAssignmentItem.vue";
import { useTranslation } from "i18next-vue";
import SelfAssignmentFeedbackAssignmentItem from "@/components/cockpit/mentor/SelfAssignmentFeedbackAssignmentItem.vue";
const { t } = useTranslation();
const courseSession = useCurrentCourseSession();
@ -34,7 +35,7 @@ const circleFilter = computed(() => {
];
});
const filteredAssignments: Ref<PraxisAssignment[]> = computed(() => {
const filteredAssignments: Ref<Assignment[]> = computed(() => {
if (!summary.value) return [];
let filtered = summary.value.assignments;
@ -80,6 +81,16 @@ const filteredAssignments: Ref<PraxisAssignment[]> = computed(() => {
}"
:task-title="item.title"
/>
<SelfAssignmentFeedbackAssignmentItem
v-else-if="item.type === 'self_evaluation_feedback'"
:circle-title="mentorCockpitStore.getCircleTitleById(item.circle_id)"
:pending-tasks="item.pending_evaluations"
:task-link="{
name: 'mentorCockpitSelfEvaluationFeedbackAssignments',
params: { learningUnitId: item.id },
}"
:task-title="item.title"
/>
</template>
</div>
</template>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Participant, PraxisAssignment } from "@/services/mentorCockpit";
import type { Assignment, Participant } from "@/services/mentorCockpit";
import { useMentorCockpit } from "@/services/mentorCockpit";
import { computed, onMounted, type Ref } from "vue";
import { useCurrentCourseSession } from "@/composables";
@ -11,10 +11,9 @@ const props = defineProps<{
const courseSession = useCurrentCourseSession();
const mentorCockpitStore = useMentorCockpit(courseSession.value.id);
const participants = computed(() => mentorCockpitStore.summary.value?.participants);
const praxisAssignment: Ref<PraxisAssignment | null> = computed(() =>
mentorCockpitStore.getPraxisAssignmentById(props.praxisAssignmentId)
const praxisAssignment: Ref<Assignment | null> = computed(() =>
mentorCockpitStore.getAssignmentById(props.praxisAssignmentId)
);
const getParticipantById = (id: string): Participant | null => {

View File

@ -0,0 +1,133 @@
<script setup lang="ts">
import type { Assignment, Participant } from "@/services/mentorCockpit";
import { useMentorCockpit } from "@/services/mentorCockpit";
import { computed, type Ref } from "vue";
import { useCurrentCourseSession } from "@/composables";
const props = defineProps<{
learningUnitId: string;
}>();
const courseSession = useCurrentCourseSession();
const mentorCockpitStore = useMentorCockpit(courseSession.value.id);
const selfEvaluationFeedback: Ref<Assignment | null> = computed(() =>
mentorCockpitStore.getAssignmentById(props.learningUnitId)
);
const getParticipantById = (id: string): Participant | null => {
if (mentorCockpitStore.summary.value?.participants) {
const found = mentorCockpitStore.summary.value.participants.find(
(item) => item.id === id
);
return found || null;
}
return null;
};
</script>
<template>
<div v-if="selfEvaluationFeedback">
<div class="p-6">
<h2 class="mb-2">
{{ $t("a.Selbsteinschätzung") }}: {{ selfEvaluationFeedback.title }}
</h2>
<span class="text-gray-800">
Circle «{{
mentorCockpitStore.getCircleTitleById(selfEvaluationFeedback.circle_id)
}}»
</span>
<template v-if="selfEvaluationFeedback.pending_evaluations > 0">
<div class="flex flex-row items-center space-x-2 pt-4">
<div
class="flex h-7 w-7 items-center justify-center rounded-full border-2 border-green-500 px-3 text-sm font-bold"
>
<span>{{ selfEvaluationFeedback.pending_evaluations }}</span>
</div>
<span>{{ $t("a.Selbsteinschätzungen geteilt") }}</span>
</div>
</template>
</div>
<div class="border-t">
<div
v-for="item in selfEvaluationFeedback.completions"
:key="item.user_id"
class="flex flex-col items-start justify-between gap-4 border-b py-2 pl-5 pr-5 last:border-b-0 md:flex-row md:items-center md:justify-between md:gap-16"
>
<!-- Left -->
<div class="flex flex-grow flex-row items-center justify-start">
<div class="w-80">
<div class="flex items-center space-x-2">
<img
:alt="item.last_name"
class="h-11 w-11 rounded-full"
:src="
getParticipantById(item.user_id)?.avatar_url ||
'/static/avatars/myvbv-default-avatar.png'
"
/>
<div>
<div class="text-bold">
{{ getParticipantById(item.user_id)?.first_name }}
{{ getParticipantById(item.user_id)?.last_name }}
</div>
</div>
</div>
</div>
<!-- Center -->
<div
class="flex flex-grow flex-row items-center justify-start space-x-2 pl-20"
>
<template v-if="item.status == 'SUBMITTED'">
<div
class="flex h-7 w-7 items-center justify-center rounded-full border-2 border-green-500 px-3 py-1 text-sm font-bold"
>
<span class="flex items-center">
<it-icon-check class="h-5 w-5"></it-icon-check>
</span>
</div>
<span>{{ $t("a.Selbsteinschätzung geteilt") }}</span>
</template>
<template v-if="item.status == 'EVALUATED'">
<div
class="flex h-7 w-7 items-center justify-center rounded-full border-2 border-green-500 bg-green-500 px-3 py-1 text-sm font-bold"
>
<span class="flex items-center">
<it-icon-check class="h-5 w-5"></it-icon-check>
</span>
</div>
<span>{{ $t("a.Fremdeinschätzung freigeben") }}</span>
</template>
</div>
<!-- Right -->
<div>
<router-link
v-if="item.status == 'SUBMITTED'"
class="btn-primary"
:to="{
name: 'mentorSelfEvaluationFeedback',
params: {
learningUnitId: learningUnitId,
},
}"
>
{{ $t("a.Fremdeinschätzung vornehmen") }}
</router-link>
<router-link
v-if="item.status == 'EVALUATED'"
class="underline"
:to="{
name: 'mentorSelfEvaluationFeedback',
params: {
learningUnitId: learningUnitId,
},
}"
>
{{ $t("a.Selbsteinschätzung anzeigen") }}
</router-link>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,161 @@
<script setup lang="ts">
import LearningContentMultiLayout from "@/pages/learningPath/learningContentPage/layouts/LearningContentMultiLayout.vue";
import LearningContentContainer from "@/pages/learningPath/learningContentPage/LearningContentContainer.vue";
import { useRouter } from "vue-router";
import { computed, ref, watch } from "vue";
import {
type Criterion,
useSelfEvaluationFeedback,
} from "@/services/selfEvaluationFeedback";
import { useRouteQuery } from "@vueuse/router";
import FeedbackProviderRankCriteria from "@/components/selfEvaluationFeedback/FeedbackProviderRankCriteria.vue";
import FeedbackProviderReleaseOverview from "@/components/selfEvaluationFeedback/FeedbackProviderReleaseOverview.vue";
import log from "loglevel";
const router = useRouter();
const props = defineProps<{
learningUnitId: string;
courseSlug: string;
}>();
const currentStepRouteParam = useRouteQuery("step", "0", {
transform: Number,
mode: "push",
});
const selfEvaluationFeedback = useSelfEvaluationFeedback(
props.learningUnitId,
"provider"
);
const feedback = computed(() => selfEvaluationFeedback?.feedback.value);
watch(
() => feedback.value,
() => {
if (feedback.value && feedback.value.feedback_submitted) {
log.info("Feedback submitted, redirecting to overview page!");
router.push({
name: "mentorSelfEvaluationFeedback",
params: {
learningUnitId: props.learningUnitId,
},
query: {
step: feedback.value.criteria.length,
},
});
}
}
);
const title = computed(() => {
if (feedback.value) {
return feedback.value.title;
}
return "";
});
const currentStep = ref(currentStepRouteParam);
const stepsCount = computed(() => {
if (feedback.value) {
return feedback.value.criteria.length + 1;
}
return 0;
});
const currentCriteria = computed(() => {
if (feedback.value && currentStep.value < stepsCount.value - 1) {
return feedback.value.criteria[currentStep.value];
}
return null;
});
const showNextButton = computed(() => {
if (feedback.value) {
return currentStep.value < stepsCount.value - 1;
}
return false;
});
const handleBack = () => {
if (currentStep.value > 0) {
currentStep.value--;
}
};
const handleContinue = () => {
if (currentStep.value < stepsCount.value) {
currentStep.value++;
}
};
const clickExit = () => {
console.log("clickExit");
router.push({
name: "mentorCockpitSelfEvaluationFeedbackAssignments",
params: {
learningUnitId: props.learningUnitId,
},
});
};
const handleFeedbackEvaluation = async (
criteria: Criterion,
evaluation: "SUCCESS" | "FAIL"
) => {
if (!feedback.value) {
return;
}
await selfEvaluationFeedback.addFeedbackAssessment(
criteria.course_completion_id,
evaluation
);
};
const handleFeedbackRelease = async () => {
if (!feedback.value) {
return;
}
await selfEvaluationFeedback.releaseFeedback();
};
</script>
<template>
<LearningContentContainer v-if="feedback" @exit="clickExit">
<LearningContentMultiLayout
icon="it-icon-lc-learning-module"
close-button-variant="close"
:current-step="currentStep"
:sub-title="$t('a.Selbsteinschätzung')"
:title="title"
:steps-count="stepsCount"
:show-next-button="showNextButton"
:show-exit-button="!showNextButton"
:show-start-button="false"
:show-previous-button="currentStep > 0 && !feedback.feedback_submitted"
:end-badge-text="$t('general.submission')"
@exit="clickExit()"
@previous="handleBack()"
@next="handleContinue()"
>
<div v-if="feedback" class="h-full">
<!-- Performance Criteria Evaluation -->
<FeedbackProviderRankCriteria
v-if="currentCriteria"
:requester="feedback.feedback_requester_user"
:criteria="currentCriteria"
@evaluation="handleFeedbackEvaluation"
/>
<!-- Submission -->
<FeedbackProviderReleaseOverview
v-else
:feedback="feedback"
@release="handleFeedbackRelease"
/>
</div>
</LearningContentMultiLayout>
</LearningContentContainer>
</template>
<style scoped></style>

View File

@ -12,7 +12,7 @@ defineEmits(["exit"]);
<template>
<div>
<div class="absolute bottom-0 top-0 w-full bg-white">
<div class="absolute bottom-0 left-0 top-0 w-full bg-white">
<CoursePreviewBar v-if="courseSessionsStore.hasCourseSessionPreview" />
<div
:class="{

View File

@ -65,6 +65,7 @@ const icon = computed(() => {
const onExit = async () => {
await props.beforeExitCallback();
eventBus.emit("finishedLearningContent", true);
emit("exit");
};
const emit = defineEmits(["previous", "next", "exit"]);

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import * as log from "loglevel";
import SelfEvaluation from "@/pages/learningPath/selfEvaluationPage/SelfEvaluation.vue";
import SelfEvaluation from "@/components/learningPath/SelfEvaluation.vue";
import { computed } from "vue";
import { useCourseDataWithCompletion } from "@/composables";
@ -14,9 +14,9 @@ const props = defineProps<{
}>();
const courseData = useCourseDataWithCompletion(props.courseSlug);
const learningUnit = computed(() =>
courseData.findLearningUnit(props.learningUnitSlug, props.circleSlug)
);
const learningUnit = computed(() => {
return courseData.findLearningUnit(props.learningUnitSlug, props.circleSlug);
});
const circle = computed(() => {
return courseData.findCircle(props.circleSlug);
});

View File

@ -0,0 +1,133 @@
<script setup lang="ts">
import type { LearningUnit, LearningUnitPerformanceCriteria } from "@/types";
import { useLearningMentors } from "@/composables";
import { computed, ref } from "vue";
import ItButton from "@/components/ui/ItButton.vue";
import NoMentorInformationPanel from "@/components/mentor/NoMentorInformationPanel.vue";
import { useSelfEvaluationFeedback } from "@/services/selfEvaluationFeedback";
import FeedbackRequestedInformationPanel from "@/components/selfEvaluationFeedback/FeedbackRequestedInformationPanel.vue";
import FeedbackReceived from "@/components/selfEvaluationFeedback/FeedbackReceived.vue";
import FeedbackRequested from "@/components/selfEvaluationFeedback/FeedbackRequested.vue";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
const props = defineProps<{
learningUnit: LearningUnit;
criteria: LearningUnitPerformanceCriteria[];
}>();
const selfEvaluationFeedback = useSelfEvaluationFeedback(
props.learningUnit.id,
"requester"
);
const storedFeedback = computed(() => selfEvaluationFeedback.feedback.value);
const isStoredFeedbackLoading = computed(() => selfEvaluationFeedback.loading.value);
const feedbackProvider = computed(() => storedFeedback.value?.feedback_provider_user);
// if no feedback is stored "current session" state management (mentor selection etc.)
const learningMentors = useLearningMentors();
const isMentorsLoading = computed(() => learningMentors.loading.value);
const mentors = computed(() => {
return learningMentors.learningMentors.value.map((mentor) => ({
id: mentor.mentor.id,
name: `${mentor.mentor.first_name} ${mentor.mentor.last_name}`,
}));
});
const currentSessionRequestedMentor = ref();
const VisualState = {
LOADING: "LOADING",
NO_MENTOR: "NO_MENTOR",
HAS_REQUESTED_FEEDBACK: "HAS_REQUESTED_FEEDBACK",
HAS_RECEIVED_FEEDBACK: "HAS_RECEIVED_FEEDBACK",
HAS_NOT_REQUESTED_FEEDBACK: "HAS_NOT_REQUESTED_FEEDBACK",
};
const currentVisualState = computed(() => {
if (isMentorsLoading.value || isStoredFeedbackLoading.value) {
return VisualState.LOADING;
} else if (mentors.value.length == 0) {
return VisualState.NO_MENTOR;
} else if (storedFeedback.value && !storedFeedback.value.feedback_submitted) {
return VisualState.HAS_REQUESTED_FEEDBACK;
} else if (storedFeedback.value && storedFeedback.value.feedback_submitted) {
return VisualState.HAS_RECEIVED_FEEDBACK;
} else {
return VisualState.HAS_NOT_REQUESTED_FEEDBACK;
}
});
const onRequestFeedback = async () => {
await selfEvaluationFeedback.requestFeedback(currentSessionRequestedMentor.value.id);
};
</script>
<template>
<div v-if="currentVisualState != VisualState.LOADING">
<div class="mb-10 w-full pt-8">
<div
v-if="currentVisualState != VisualState.HAS_RECEIVED_FEEDBACK"
class="w-full border border-gray-400"
>
<div class="m-6 space-y-6">
<h3 class="heading-3">
{{ $t("a.Selbsteinschätzung teilen") }}
</h3>
<NoMentorInformationPanel
v-if="currentVisualState == VisualState.NO_MENTOR"
/>
<FeedbackRequestedInformationPanel
v-if="currentVisualState == VisualState.HAS_REQUESTED_FEEDBACK"
:feedback-mentor-name="`${feedbackProvider?.first_name} ${feedbackProvider?.last_name}`"
/>
<div v-else-if="currentVisualState == VisualState.HAS_NOT_REQUESTED_FEEDBACK">
<p>
{{
$t(
"a.Du kannst deine Selbsteinschätzung mit deiner Lernbegleitung teilen, damit sie eine Fremdeinschätzung vornimmt."
)
}}
</p>
<ItDropdownSelect
v-model="currentSessionRequestedMentor"
:placeholder-text="`${$t('a.Lernbegleitung auswählen')}…`"
class="mt-6 w-80"
:items="mentors"
></ItDropdownSelect>
<ItButton
class="mt-6"
variant="primary"
size="large"
:disabled="!currentSessionRequestedMentor"
@click="onRequestFeedback"
>
<p v-if="!currentSessionRequestedMentor">
{{ $t("a.Selbsteinschätzung teilen") }}
</p>
<p v-else>
{{
$t("a.Selbsteinschätzung mit MENTOR_NAME teilen", {
MENTOR_NAME: currentSessionRequestedMentor?.name,
})
}}
</p>
</ItButton>
</div>
</div>
</div>
</div>
<FeedbackReceived
v-if="currentVisualState == VisualState.HAS_RECEIVED_FEEDBACK && storedFeedback"
:feedback="storedFeedback"
/>
<FeedbackRequested
v-else
:criteria="props.criteria"
:learning-unit="props.learningUnit"
:show-edit-link="currentVisualState != VisualState.HAS_REQUESTED_FEEDBACK"
/>
</div>
</template>
<style scoped></style>

View File

@ -180,7 +180,7 @@ const router = createRouter({
{
path: "",
component: () =>
import("@/pages/cockpit/cockpitPage/mentor/MentorOverview.vue"),
import("@/pages/cockpit/cockpitPage/mentor/MentorOverviewPage.vue"),
name: "mentorCockpitOverview",
meta: {
cockpitType: "mentor",
@ -189,12 +189,24 @@ const router = createRouter({
{
path: "participants",
component: () =>
import("@/pages/cockpit/cockpitPage/mentor/MentorParticipants.vue"),
import("@/pages/cockpit/cockpitPage/mentor/MentorParticipantsPage.vue"),
name: "mentorCockpitParticipants",
meta: {
cockpitType: "mentor",
},
},
{
path: "self-evaluation-feedback/:learningUnitId",
component: () =>
import(
"@/pages/cockpit/cockpitPage/mentor/SelfEvaluationFeedbackPage.vue"
),
name: "mentorSelfEvaluationFeedback",
meta: {
cockpitType: "mentor",
},
props: true,
},
{
path: "details",
component: () =>
@ -207,7 +219,7 @@ const router = createRouter({
path: "praxis-assignments/:praxisAssignmentId",
component: () =>
import(
"@/pages/cockpit/cockpitPage/mentor/MentorPraxisAssignment.vue"
"@/pages/cockpit/cockpitPage/mentor/MentorPraxisAssignmentPage.vue"
),
name: "mentorCockpitPraxisAssignments",
meta: {
@ -215,6 +227,18 @@ const router = createRouter({
},
props: true,
},
{
path: "self-evaluation-feedback-assignments/:learningUnitId",
component: () =>
import(
"@/pages/cockpit/cockpitPage/mentor/MentorSelfEvaluationFeedbackAssignmentPage.vue"
),
name: "mentorCockpitSelfEvaluationFeedbackAssignments",
meta: {
cockpitType: "mentor",
},
props: true,
},
],
},
],

View File

@ -30,7 +30,7 @@ interface Completion {
url: string;
}
export interface PraxisAssignment {
export interface Assignment {
id: string;
title: string;
circle_id: string;
@ -42,7 +42,7 @@ export interface PraxisAssignment {
interface Summary {
participants: Participant[];
circles: Circle[];
assignments: PraxisAssignment[];
assignments: Assignment[];
}
export const useMentorCockpit = (
@ -60,7 +60,7 @@ export const useMentorCockpit = (
return "";
};
const getPraxisAssignmentById = (id: string): PraxisAssignment | null => {
const getAssignmentById = (id: string): Assignment | null => {
if (summary.value?.assignments) {
const found = summary.value.assignments.find(
(assignment) => assignment.id === id
@ -93,7 +93,7 @@ export const useMentorCockpit = (
summary,
error,
getCircleTitleById,
getPraxisAssignmentById,
fetchData,
getAssignmentById,
};
};

View File

@ -0,0 +1,182 @@
import { useCSRFFetch } from "@/fetchHelpers";
import type { User } from "@/types";
import { toValue } from "@vueuse/core";
import { t } from "i18next";
import type { Ref } from "vue";
import { computed, onMounted, ref } from "vue";
export interface FeedbackRequest {
feedback_id: string;
learning_unit_id: number;
circle_name: string;
title: string;
// submitted => provider submitted (released) his/her feedback
feedback_submitted: boolean;
feedback_requester_user: User;
feedback_provider_user: User;
criteria: Criterion[];
}
export interface Criterion {
course_completion_id: string;
title: string;
self_assessment: "FAIL" | "SUCCESS" | "UNKNOWN";
feedback_assessment: "FAIL" | "SUCCESS" | "UNKNOWN";
}
/** To keep the backend permissions model simple, we have two endpoints:
* 1. /requester/: for the user who requested the feedback
* 2. /provider/: for the user who provides the feedback
*
* Design decision: We generally just re-fetch the whole feedback from the backend
* after each action (e.g. request, release, add-assessment) to keep the frontend simple.
*/
export function useSelfEvaluationFeedback(
learningUnitId: Ref<string> | string,
feedbackRole: "requester" | "provider"
) {
const feedback = ref<FeedbackRequest>();
const loading = ref(false);
const error = ref();
const url = computed(
() => `/api/self-evaluation-feedback/${feedbackRole}/${learningUnitId}/feedback`
);
const fetchFeedback = async () => {
error.value = undefined;
loading.value = true;
console.log("Fetching feedback for learning unit", learningUnitId);
const { data, statusCode, error: _error } = await useCSRFFetch(url.value).json();
loading.value = false;
if (_error.value) {
error.value = _error;
feedback.value = undefined;
return;
}
if (statusCode.value === 404) {
feedback.value = undefined;
} else {
feedback.value = data.value;
}
};
const requestFeedback = async (fromProviderUserId: string) => {
if (feedbackRole !== "requester") {
console.warn("Cannot request feedback");
return;
}
const url = `/api/self-evaluation-feedback/requester/${toValue(
learningUnitId
)}/feedback/start`;
await useCSRFFetch(url).post({
feedback_provider_user_id: fromProviderUserId,
});
await fetchFeedback();
};
const addFeedbackAssessment = async (
courseCompletionId: string,
assessment: "FAIL" | "SUCCESS"
) => {
if (feedbackRole !== "provider" || !feedback.value) {
console.warn("Cannot add feedback assessment");
return;
}
await useCSRFFetch(
`/api/self-evaluation-feedback/provider/feedback/${feedback.value.feedback_id}/add-assessment`
).put({
course_completion_id: courseCompletionId,
feedback_assessment: assessment,
});
await fetchFeedback();
};
const releaseFeedback = async () => {
if (feedbackRole !== "provider" || !feedback.value) {
console.warn("Cannot release feedback");
return;
}
await useCSRFFetch(
`/api/self-evaluation-feedback/provider/feedback/${feedback.value.feedback_id}/release`
).put({});
await fetchFeedback();
};
onMounted(fetchFeedback);
return {
feedback,
error,
loading,
// feedback requester actions
requestFeedback,
// feedback provider actions
addFeedbackAssessment,
releaseFeedback,
};
}
export const getSmiley = (assessment: "FAIL" | "SUCCESS" | "UNKNOWN") => {
switch (assessment) {
case "SUCCESS":
return "it-icon-smiley-happy";
case "FAIL":
return "it-icon-smiley-thinking";
default:
return "it-icon-smiley-neutral";
}
};
export const getSelfEvaluationCaption = (
assessment: "FAIL" | "SUCCESS" | "UNKNOWN"
) => {
switch (assessment) {
case "SUCCESS":
return t("selfEvaluation.yes");
case "FAIL":
return t("selfEvaluation.no");
case "UNKNOWN":
return t("a.Nicht bewertet");
}
};
export const getFeedbackReceivedCaption = (
assessment: "FAIL" | "SUCCESS" | "UNKNOWN"
) => {
switch (assessment) {
case "SUCCESS":
return t("receivedEvaluation.yes");
case "FAIL":
return t("receivedEvaluation.no");
case "UNKNOWN":
return t("a.Nicht bewertet");
}
};
export const getFeedbackEvaluationCaption = (
assessment: "FAIL" | "SUCCESS" | "UNKNOWN",
requester: User
) => {
switch (assessment) {
case "SUCCESS":
return t("a.Ja, NAME kann das.", {
NAME: requester.first_name,
});
case "FAIL":
return t("a.Nein, NAME muss das nochmals anschauen.", {
NAME: requester.first_name,
});
case "UNKNOWN":
return t("a.Nicht bewertet");
}
};

View File

@ -457,6 +457,17 @@ export interface ExpertSessionUser extends CourseSessionUser {
role: "EXPERT";
}
export interface Mentor {
id: number;
first_name: string;
last_name: string;
}
export interface LearningMentor {
id: number;
mentor: Mentor;
}
export type CourseSessionDetail = CourseSessionObjectType;
// document upload
@ -579,3 +590,16 @@ export interface FeedbackData {
};
feedbackType: FeedbackType;
}
export type User = {
id: string;
first_name: string;
last_name: string;
email: string;
username: string;
avatar_url: string;
organisation: string | null;
is_superuser: boolean;
course_session_experts: any[];
language: string;
};

View File

@ -9,71 +9,71 @@ describe("circle.cy.js", () => {
});
it("can open circle page", () => {
cy.get("[data-cy=\"circle-title\"]").should("contain", "Fahrzeug");
cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug");
});
it("can toggle learning content", () => {
cy.get("[data-cy=\"circle-title\"]").should("contain", "Fahrzeug");
cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug");
cy.get(
"[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox\"]"
'[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox"]'
).should("have.class", "cy-unchecked");
cy.get(
"[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox\"]"
'[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox"]'
).click();
cy.get(
"[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox\"]"
'[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox"]'
).should("have.class", "cy-checked");
// completion data should still be there after reload
cy.reload();
cy.get(
"[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox\"]"
'[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox"]'
).should("have.class", "cy-checked");
});
it("can open learning contents and complete them by continuing", () => {
cy.get(
"[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-verschaffe-dir-einen-überblick\"]"
'[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-verschaffe-dir-einen-überblick"]'
).click();
cy.get("[data-cy=\"lc-title\"]").should(
cy.get('[data-cy="lc-title"]').should(
"contain",
"Verschaffe dir einen Überblick"
);
cy.get("[data-cy=\"complete-and-continue\"]").click({ force: true });
cy.get("[data-cy=\"circle-title\"]").should("contain", "Fahrzeug");
cy.get('[data-cy="complete-and-continue"]').click({ force: true });
cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug");
cy.get("[data-cy=\"ls-continue-button\"]").click({ force: true });
cy.get("[data-cy=\"lc-title\"]").should(
cy.get('[data-cy="ls-continue-button"]').click();
cy.get('[data-cy="lc-title"]').should(
"contain",
"Handlungsfeld «Fahrzeug»"
);
cy.get("[data-cy=\"complete-and-continue\"]").click({ force: true });
cy.get("[data-cy=\"circle-title\"]").should("contain", "Fahrzeug");
cy.get('[data-cy="complete-and-continue"]').click({ force: true });
cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug");
cy.get(
"[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-verschaffe-dir-einen-überblick-checkbox\"]"
'[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-verschaffe-dir-einen-überblick-checkbox"]'
).should("have.class", "cy-checked");
cy.get(
"[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox\"]"
'[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox"]'
).should("have.class", "cy-checked");
});
it("continue button works", () => {
cy.get("[data-cy=\"ls-continue-button\"]").should("contain", "Los geht's");
cy.get("[data-cy=\"ls-continue-button\"]").click();
cy.get('[data-cy="ls-continue-button"]').should("contain", "Los geht's");
cy.get('[data-cy="ls-continue-button"]').click();
cy.get("[data-cy=\"lc-title\"]").should(
cy.get('[data-cy="lc-title"]').should(
"contain",
"Verschaffe dir einen Überblick"
);
cy.get("[data-cy=\"complete-and-continue\"]").click({ force: true });
cy.get('[data-cy="complete-and-continue"]').click({ force: true });
cy.get("[data-cy=\"ls-continue-button\"]").should("contain", "Weiter geht's");
cy.get("[data-cy=\"ls-continue-button\"]").click();
cy.get("[data-cy=\"lc-title\"]").should(
cy.get('[data-cy="ls-continue-button"]').should("contain", "Weiter geht's");
cy.get('[data-cy="ls-continue-button"]').click();
cy.get('[data-cy="lc-title"]').should(
"contain",
"Handlungsfeld «Fahrzeug»"
);
@ -81,43 +81,43 @@ describe("circle.cy.js", () => {
it("can open learning content by url", () => {
cy.visit("/course/test-lehrgang/learn/fahrzeug/handlungsfeld-fahrzeug");
cy.get("[data-cy=\"lc-title\"]").should(
cy.get('[data-cy="lc-title"]').should(
"contain",
"Handlungsfeld «Fahrzeug»"
);
cy.get("[data-cy=\"close-learning-content\"]").click();
cy.get("[data-cy=\"circle-title\"]").should("contain", "Fahrzeug");
cy.get('[data-cy="close-learning-content"]').click();
cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug");
});
it("checks number of sequences and contents", () => {
cy.get("[data-cy=\"lp-learning-sequence\"]").should("have.length", 3);
cy.get("[data-cy=\"lp-learning-sequence\"]")
cy.get('[data-cy="lp-learning-sequence"]').should("have.length", 3);
cy.get('[data-cy="lp-learning-sequence"]')
.first()
.should("contain", "Vorbereitung");
cy.get("[data-cy=\"lp-learning-sequence\"]")
cy.get('[data-cy="lp-learning-sequence"]')
.eq(1)
.should("contain", "Training");
cy.get("[data-cy=\"lp-learning-sequence\"]")
cy.get('[data-cy="lp-learning-sequence"]')
.last()
.should("contain", "Transfer");
cy.get("[data-cy=\"lp-learning-content\"]").should("have.length", 10);
cy.get("[data-cy=\"lp-learning-content\"]")
cy.get('[data-cy="lp-learning-content"]').should("have.length", 10);
cy.get('[data-cy="lp-learning-content"]')
.first()
.should("contain", "Verschaffe dir einen Überblick");
cy.get("[data-cy=\"lp-learning-content\"]")
cy.get('[data-cy="lp-learning-content"]')
.eq(4)
.should("contain", "Präsenzkurs Fahrzeug");
cy.get("[data-cy=\"lp-learning-content\"]")
cy.get('[data-cy="lp-learning-content"]')
.eq(7)
.should("contain", "Reflexion");
cy.get("[data-cy=\"lp-learning-content\"]")
cy.get('[data-cy="lp-learning-content"]')
.last()
.should("contain", "Feedback");
cy.visit("/course/test-lehrgang/learn/reisen");
cy.get("[data-cy=\"lp-learning-sequence\"]").should("have.length", 3);
cy.get("[data-cy=\"lp-learning-content\"]").should("have.length", 9);
cy.get('[data-cy="lp-learning-sequence"]').should("have.length", 3);
cy.get('[data-cy="lp-learning-content"]').should("have.length", 9);
});
});

View File

@ -133,6 +133,7 @@ LOCAL_APPS = [
"vbv_lernwelt.course_session_group",
"vbv_lernwelt.shop",
"vbv_lernwelt.learning_mentor",
"vbv_lernwelt.self_evaluation_feedback",
]
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS

View File

@ -141,6 +141,9 @@ urlpatterns = [
path("api/mentor/<signed_int:course_session_id>/", include("vbv_lernwelt.learning_mentor.urls")),
# self evaluation feedback
path("api/self-evaluation-feedback/", include("vbv_lernwelt.self_evaluation_feedback.urls")),
# assignment
path(
r"api/assignment/<signed_int:assignment_id>/<signed_int:course_session_id>/status/",

View File

@ -0,0 +1,36 @@
# Generated by Django 3.2.20 on 2024-01-24 09:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("assignment", "0011_assignment_solution_sample"),
]
operations = [
migrations.AlterField(
model_name="assignment",
name="assignment_type",
field=models.CharField(
choices=[
("PRAXIS_ASSIGNMENT", "PRAXIS_ASSIGNMENT"),
("CASEWORK", "CASEWORK"),
("PREP_ASSIGNMENT", "PREP_ASSIGNMENT"),
("REFLECTION", "REFLECTION"),
("CONDITION_ACCEPTANCE", "CONDITION_ACCEPTANCE"),
("EDONIQ_TEST", "EDONIQ_TEST"),
],
default="CASEWORK",
max_length=50,
),
),
migrations.AlterField(
model_name="assignment",
name="needs_expert_evaluation",
field=models.BooleanField(
default=False,
help_text="Muss der Auftrag durch eine/n Experten/in oder eine Lernbegleitung beurteilt werden?",
),
),
]

View File

@ -25,6 +25,7 @@ TEST_STUDENT1_USER_ID = "65c73ad0-6d53-43a9-a4a4-64143f27b03a"
TEST_STUDENT2_USER_ID = "19c40d94-15cc-4198-aaad-ef707c4b0900"
TEST_STUDENT3_USER_ID = "bcf94dba-53bc-474b-a22d-e4af39aa042b"
TEST_MENTOR1_USER_ID = "d1f5f5a9-5b0a-4e1a-9e1a-9e9b5b5e1b1b"
TEST_STUDENT1_VV_USER_ID = "5ff59857-8de5-415e-a387-4449f9a0337a"
TEST_COURSE_SESSION_BERN_ID = -1
TEST_COURSE_SESSION_ZURICH_ID = -2

View File

@ -13,6 +13,7 @@ from vbv_lernwelt.core.constants import (
ADMIN_USER_ID,
TEST_MENTOR1_USER_ID,
TEST_STUDENT1_USER_ID,
TEST_STUDENT1_VV_USER_ID,
TEST_STUDENT2_USER_ID,
TEST_STUDENT3_USER_ID,
TEST_SUPERVISOR1_USER_ID,
@ -210,9 +211,10 @@ def create_default_users(default_password="test", set_avatar=False):
last_name="Expert3",
)
_create_student_user(
id=TEST_STUDENT1_VV_USER_ID,
email="student-vv@eiger-versicherungen.ch",
first_name="Student",
last_name="VV",
first_name="Viktor",
last_name="Vollgas",
)
_create_student_user(
email="patrizia.huggel@eiger-versicherungen.ch",
@ -364,10 +366,11 @@ def create_default_users(default_password="test", set_avatar=False):
_create_user(
_id=TEST_MENTOR1_USER_ID,
email="test-mentor1@example.com",
first_name="[Mentor]",
last_name="Mentor",
first_name="Micheala",
last_name="Weber-Mentor",
password=default_password,
language="de",
avatar_image="uk1.patrizia.huggel.jpg",
)

View File

@ -13,7 +13,7 @@ from vbv_lernwelt.core.constants import (
TEST_STUDENT3_USER_ID,
TEST_TRAINER1_USER_ID,
)
from vbv_lernwelt.core.models import User
from vbv_lernwelt.core.models import Organisation, User
from vbv_lernwelt.course.consts import COURSE_TEST_ID
from vbv_lernwelt.course.creators.test_course import (
create_edoniq_test_result_data,
@ -108,6 +108,7 @@ def command(
CourseSessionAttendanceCourse.objects.all().update(attendance_user_list=[])
LearningMentor.objects.all().delete()
User.objects.all().update(organisation=Organisation.objects.first())
User.objects.all().update(language="de")
User.objects.all().update(additional_json_data={})
@ -331,16 +332,32 @@ def command(
attendance_course.save()
if create_learning_mentor:
print("Create learning mentor")
mentor = LearningMentor.objects.create(
uk_mentor = LearningMentor.objects.create(
course=Course.objects.get(id=COURSE_TEST_ID),
mentor=User.objects.get(id=TEST_MENTOR1_USER_ID),
)
course_session = CourseSession.objects.get(id=TEST_COURSE_SESSION_BERN_ID)
csu = CourseSessionUser.objects.get(
user__id=TEST_STUDENT1_USER_ID, course_session=course_session
uk_mentor.participants.add(
CourseSessionUser.objects.get(
user__id=TEST_STUDENT1_USER_ID,
course_session=CourseSession.objects.get(
id=TEST_COURSE_SESSION_BERN_ID
),
)
)
mentor.participants.add(csu)
# FIXME: Add mentor to VV course as well, once we can:
# -> https://bitbucket.org/iterativ/vbv_lernwelt/pull-requests/287
# vv_course = Course.objects.get(id=COURSE_VERSICHERUNGSVERMITTLERIN_ID)
# vv_course_session = CourseSession.objects.get(course=vv_course)
# vv_mentor = LearningMentor.objects.create(
# course=vv_course,
# mentor=User.objects.get(id=TEST_MENTOR1_USER_ID),
# )
# vv_mentor.participants.add(
# CourseSessionUser.objects.get(
# user__id=TEST_STUDENT1_VV_USER_ID, course_session=vv_course_session
# )
# )
course = Course.objects.get(id=COURSE_TEST_ID)
course.enable_circle_documents = enable_circle_documents

View File

@ -167,6 +167,10 @@ def cypress_reset_view(request):
request.data.get("create_attendance_days") == "true"
)
options["create_learning_mentor"] = (
request.data.get("create_learning_mentor") == "true"
)
call_command(
"cypress_reset",
**options,

View File

@ -46,6 +46,7 @@ from vbv_lernwelt.learnpath.models import (
LearningContentAssignment,
LearningContentEdoniqTest,
LearningPath,
LearningUnit,
)
from vbv_lernwelt.learnpath.tests.learning_path_factories import (
CircleFactory,
@ -58,11 +59,13 @@ from vbv_lernwelt.learnpath.tests.learning_path_factories import (
)
def create_course(title: str, _id=None) -> Tuple[Course, CoursePage]:
def create_course(
title: str = "Course Title", _id=None, course_page_title: str = "Test Lehrgang"
) -> Tuple[Course, CoursePage]:
course = Course.objects.create(id=_id, title=title, category_name="Handlungsfeld")
course_page = CoursePageFactory(
title="Test Lehrgang",
title=course_page_title,
parent=get_wagtail_default_site().root_page,
course=course,
)
@ -268,10 +271,28 @@ def create_course_session_edoniq_test(
return cset
def create_learning_unit(
circle: Circle,
course: Course,
course_category_title: str = "Course Category",
) -> LearningUnit:
cat, _ = CourseCategory.objects.get_or_create(
course=course,
title=course_category_title,
)
return LearningUnitFactory(
title="Learning Unit",
parent=circle,
course_category=cat,
)
def create_performance_criteria_page(
course: Course,
course_page: CoursePage,
circle: Circle,
learning_unit: LearningUnitFactory | None = None,
) -> PerformanceCriteria:
competence_navi_page = CompetenceNaviPageFactory(
title="Competence Navi",
@ -290,17 +311,14 @@ def create_performance_criteria_page(
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)
if not learning_unit:
learning_unit = create_learning_unit(circle=circle, course=course)
return PerformanceCriteriaFactory(
parent=action_competence,
competence_id="X1.1",
title="Performance Criteria",
learning_unit=lu,
learning_unit=learning_unit,
)

View File

@ -0,0 +1,36 @@
# Generated by Django 3.2.20 on 2024-01-24 09:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("duedate", "0008_auto_20231108_0747"),
("course_session", "0005_auto_20230825_1723"),
]
operations = [
migrations.AlterField(
model_name="coursesessionassignment",
name="evaluation_deadline",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="assignment_evaluation_deadline",
to="duedate.duedate",
),
),
migrations.AlterField(
model_name="coursesessionassignment",
name="submission_deadline",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="assignment_submission_deadline",
to="duedate.duedate",
),
),
]

View File

@ -45,6 +45,7 @@ class Migration(migrations.Migration):
dependencies = [
("duedate", "0004_alter_duedate_start"),
("learnpath", "0008_add_edoniq_sequence_id"),
("course_session", "0005_auto_20230825_1723"),
]
operations = [

View File

@ -10,9 +10,10 @@ from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.course_session.models import CourseSessionAssignment
from vbv_lernwelt.learning_mentor.entities import (
CompletionStatus,
PraxisAssignmentCompletion,
PraxisAssignmentStatus,
MentorAssignmentCompletion,
MentorAssignmentStatus,
MentorAssignmentStatusType,
MentorCompletionStatus,
)
@ -21,7 +22,7 @@ def get_assignment_completions(
assignment: Assignment,
participants: List[User],
evaluation_user: User,
) -> List[PraxisAssignmentCompletion]:
) -> List[MentorAssignmentCompletion]:
evaluation_results = AssignmentCompletion.objects.filter(
assignment_user__in=participants,
course_session=course_session,
@ -34,14 +35,14 @@ def get_assignment_completions(
completion_status = result["completion_status"]
if completion_status == AssignmentCompletionStatus.EVALUATION_SUBMITTED.value:
status = CompletionStatus.EVALUATED
status = MentorCompletionStatus.EVALUATED
elif completion_status in [
AssignmentCompletionStatus.SUBMITTED.value,
AssignmentCompletionStatus.EVALUATION_IN_PROGRESS.value,
]:
status = CompletionStatus.SUBMITTED
status = MentorCompletionStatus.SUBMITTED
else:
status = CompletionStatus.UNKNOWN
status = MentorCompletionStatus.UNKNOWN
user_status_map[result["assignment_user"]] = (
status,
@ -49,25 +50,25 @@ def get_assignment_completions(
)
status_priority = {
CompletionStatus.SUBMITTED: 1,
CompletionStatus.EVALUATED: 2,
CompletionStatus.UNKNOWN: 3,
MentorCompletionStatus.SUBMITTED: 1,
MentorCompletionStatus.EVALUATED: 2,
MentorCompletionStatus.UNKNOWN: 3,
}
sorted_participants = sorted(
participants,
key=lambda u: (
status_priority.get(
user_status_map.get(u.id, (CompletionStatus.UNKNOWN, ""))[0]
user_status_map.get(u.id, (MentorCompletionStatus.UNKNOWN, ""))[0]
),
user_status_map.get(u.id, ("", u.last_name))[1],
),
)
return [
PraxisAssignmentCompletion(
MentorAssignmentCompletion(
status=user_status_map.get(
user.id, (CompletionStatus.UNKNOWN, user.last_name)
user.id, (MentorCompletionStatus.UNKNOWN, user.last_name)
)[0],
user_id=user.id,
last_name=user.last_name,
@ -79,7 +80,7 @@ def get_assignment_completions(
def get_praxis_assignments(
course_session: CourseSession, participants: List[User], evaluation_user: User
) -> Tuple[List[PraxisAssignmentStatus], Set[int]]:
) -> Tuple[List[MentorAssignmentStatus], Set[int]]:
records = []
circle_ids = set()
@ -105,19 +106,20 @@ def get_praxis_assignments(
[
completion
for completion in completions
if completion.status == CompletionStatus.SUBMITTED
if completion.status == MentorCompletionStatus.SUBMITTED
]
)
circle_id = learning_content.get_circle().id
records.append(
PraxisAssignmentStatus(
MentorAssignmentStatus(
id=course_session_assignment.id,
title=learning_content.content_assignment.title,
circle_id=circle_id,
pending_evaluations=submitted_count,
completions=completions,
type=MentorAssignmentStatusType.PRAXIS_ASSIGNMENT,
)
)

View File

@ -0,0 +1,108 @@
from typing import List, Set, Tuple
import structlog
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import Course
from vbv_lernwelt.learning_mentor.entities import (
MentorAssignmentCompletion,
MentorAssignmentStatus,
MentorAssignmentStatusType,
MentorCompletionStatus,
)
from vbv_lernwelt.learnpath.models import (
LearningUnit,
LearningUnitPerformanceFeedbackType,
)
from vbv_lernwelt.self_evaluation_feedback.models import SelfEvaluationFeedback
logger = structlog.get_logger(__name__)
def create_blank_completions_non_requesters(
completions: List[MentorAssignmentCompletion],
participants: List[User],
) -> List[MentorAssignmentCompletion]:
non_requester_completions = []
participants_user_ids = set([str(p.id) for p in participants])
completion_seen_user_ids = set([str(c.user_id) for c in completions])
user_by_id = {str(p.id): p for p in participants}
for non_requester_user_id in participants_user_ids - completion_seen_user_ids:
non_requester_user = user_by_id[non_requester_user_id]
non_requester_completions.append(
MentorAssignmentCompletion(
status=MentorCompletionStatus.UNKNOWN,
user_id=non_requester_user.id,
last_name=non_requester_user.last_name,
url="",
)
)
return non_requester_completions
def get_self_feedback_evaluation(
participants: List[User],
evaluation_user: User,
course: Course,
) -> Tuple[List[MentorAssignmentStatus], Set[int]]:
records: List[MentorAssignmentStatus] = []
circle_ids: Set[int] = set()
if not participants:
return records, circle_ids
# very unfortunate: we can't simply get all SelfEvaluationFeedback objects since then
# we would miss the one where no feedback was requested -> so we get all learning units
# and check if we have to take them into account (course, feedback type, etc.)
for learning_unit in LearningUnit.objects.filter(
feedback_user=LearningUnitPerformanceFeedbackType.MENTOR_FEEDBACK.value,
course_category__course_id=course.id,
):
feedbacks = SelfEvaluationFeedback.objects.filter(
learning_unit=learning_unit,
feedback_requester_user__in=participants,
feedback_provider_user=evaluation_user,
)
circle_id = learning_unit.get_circle().id
circle_ids.add(circle_id)
pending_evaluations = len([f for f in feedbacks if not f.feedback_submitted])
completions = [
MentorAssignmentCompletion(
# feedback_submitted as seen from the perspective of the evaluation user (feedback provider)
# means that the feedback has been evaluated by the feedback provider, hence the status is EVALUATED
status=MentorCompletionStatus.EVALUATED
if f.feedback_submitted
else MentorCompletionStatus.SUBMITTED,
user_id=f.feedback_requester_user.id,
last_name=f.feedback_requester_user.last_name,
url=f"/course/{course.slug}/cockpit/mentor/self-evaluation-feedback/{f.learning_unit.id}",
)
for f in feedbacks
]
# requesting feedback is optional, so we need to add blank completions
# for those mentees who did not request a feedback
completions += create_blank_completions_non_requesters(
completions=completions,
participants=participants,
)
records.append(
MentorAssignmentStatus(
id=learning_unit.id,
title=learning_unit.title,
circle_id=circle_id,
pending_evaluations=pending_evaluations,
completions=completions,
type=MentorAssignmentStatusType.SELF_EVALUATION_FEEDBACK,
)
)
return records, circle_ids

View File

@ -3,24 +3,30 @@ from enum import Enum
from typing import List
class CompletionStatus(str, Enum):
class MentorCompletionStatus(str, Enum):
UNKNOWN = "UNKNOWN"
SUBMITTED = "SUBMITTED"
EVALUATED = "EVALUATED"
class MentorAssignmentStatusType(str, Enum):
PRAXIS_ASSIGNMENT = "praxis_assignment"
SELF_EVALUATION_FEEDBACK = "self_evaluation_feedback"
@dataclass
class PraxisAssignmentCompletion:
status: CompletionStatus
class MentorAssignmentCompletion:
status: MentorCompletionStatus
user_id: str
last_name: str
url: str
@dataclass
class PraxisAssignmentStatus:
class MentorAssignmentStatus:
id: str
title: str
circle_id: str
pending_evaluations: int
completions: List[PraxisAssignmentCompletion]
completions: List[MentorAssignmentCompletion]
type: MentorAssignmentStatusType

View File

@ -4,7 +4,7 @@ from vbv_lernwelt.core.serializers import UserSerializer
from vbv_lernwelt.learning_mentor.models import LearningMentor, MentorInvitation
class PraxisAssignmentCompletionSerializer(serializers.Serializer):
class MentorAssignmentCompletionSerializer(serializers.Serializer):
status = serializers.SerializerMethodField()
user_id = serializers.CharField()
last_name = serializers.CharField()
@ -15,13 +15,13 @@ class PraxisAssignmentCompletionSerializer(serializers.Serializer):
return obj.status.value
class PraxisAssignmentStatusSerializer(serializers.Serializer):
class MentorAssignmentStatusSerializer(serializers.Serializer):
id = serializers.CharField()
title = serializers.CharField()
circle_id = serializers.CharField()
pending_evaluations = serializers.IntegerField()
completions = PraxisAssignmentCompletionSerializer(many=True)
type = serializers.ReadOnlyField(default="praxis_assignment")
completions = MentorAssignmentCompletionSerializer(many=True)
type = serializers.ReadOnlyField()
class InvitationSerializer(serializers.ModelSerializer):

View File

@ -18,7 +18,7 @@ from vbv_lernwelt.learning_mentor.content.praxis_assignment import (
get_assignment_completions,
get_praxis_assignments,
)
from vbv_lernwelt.learning_mentor.entities import CompletionStatus
from vbv_lernwelt.learning_mentor.entities import MentorCompletionStatus
class AttendanceServicesTestCase(TestCase):
@ -74,10 +74,10 @@ class AttendanceServicesTestCase(TestCase):
# THEN
expected_order = ["Beta", "Alpha", "Gamma", "Kappa"]
expected_statuses = {
"Alpha": CompletionStatus.EVALUATED, # user1
"Beta": CompletionStatus.SUBMITTED, # user2
"Gamma": CompletionStatus.UNKNOWN, # user4 (no AssignmentCompletion)
"Kappa": CompletionStatus.UNKNOWN, # user3 (IN_PROGRESS should be PENDING)
"Alpha": MentorCompletionStatus.EVALUATED, # user1
"Beta": MentorCompletionStatus.SUBMITTED, # user2
"Gamma": MentorCompletionStatus.UNKNOWN, # user4 (no AssignmentCompletion)
"Kappa": MentorCompletionStatus.UNKNOWN, # user3 (IN_PROGRESS should be PENDING)
}
self.assertEqual(len(results), len(participants))

View File

@ -1,3 +1,5 @@
from typing import Dict, List, Optional
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
@ -7,6 +9,7 @@ from vbv_lernwelt.assignment.models import (
AssignmentCompletionStatus,
AssignmentType,
)
from vbv_lernwelt.core.admin import User
from vbv_lernwelt.course.creators.test_utils import (
add_course_session_user,
create_assignment,
@ -15,10 +18,22 @@ from vbv_lernwelt.course.creators.test_utils import (
create_course,
create_course_session,
create_course_session_assignment,
create_learning_unit,
create_user,
)
from vbv_lernwelt.course.models import CourseSessionUser
from vbv_lernwelt.learning_mentor.models import LearningMentor
from vbv_lernwelt.learnpath.models import LearningUnitPerformanceFeedbackType
from vbv_lernwelt.self_evaluation_feedback.models import SelfEvaluationFeedback
def get_completion_for_user(
completions: List[Dict[str, str]], user: User
) -> Optional[Dict[str, str]]:
for completion in completions:
if completion["user_id"] == str(user.id):
return completion
return None
class LearningMentorAPITest(APITestCase):
@ -28,15 +43,6 @@ class LearningMentorAPITest(APITestCase):
self.circle, _ = create_circle(title="Circle", course_page=self.course_page)
self.assignment = create_assignment(
course=self.course, assignment_type=AssignmentType.PRAXIS_ASSIGNMENT
)
lca = create_assignment_learning_content(self.circle, self.assignment)
create_course_session_assignment(
course_session=self.course_session, learning_content_assignment=lca
)
self.mentor = create_user("mentor")
self.participant_1 = add_course_session_user(
self.course_session,
@ -109,7 +115,7 @@ class LearningMentorAPITest(APITestCase):
self.assertEqual(participant_1["first_name"], "Test")
self.assertEqual(participant_1["last_name"], "Participant_1")
def test_api_praxis_assignments(self) -> None:
def test_api_self_evaluation_feedback(self) -> None:
# GIVEN
participants = [self.participant_1, self.participant_2, self.participant_3]
self.client.force_login(self.mentor)
@ -118,12 +124,104 @@ class LearningMentorAPITest(APITestCase):
mentor=self.mentor,
course=self.course_session.course,
)
mentor.participants.set(participants)
learning_unit = create_learning_unit(
circle=self.circle,
course=self.course,
)
# performance criteria under this learning unit shall be evaluated by the mentor
learning_unit.feedback_user = (
LearningUnitPerformanceFeedbackType.MENTOR_FEEDBACK.name
)
learning_unit.save()
# 1: we already evaluated
SelfEvaluationFeedback.objects.create(
feedback_requester_user=self.participant_1.user,
feedback_provider_user=self.mentor,
learning_unit=learning_unit,
feedback_submitted=True,
)
# 2: we have not evaluated yet
SelfEvaluationFeedback.objects.create(
feedback_requester_user=self.participant_2.user,
feedback_provider_user=self.mentor,
learning_unit=learning_unit,
feedback_submitted=False,
)
# 3: did not request feedback
# ...
# WHEN
response = self.client.get(self.url)
# THEN
assignments = response.data["assignments"]
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
response.data["circles"],
[{"id": self.circle.id, "title": self.circle.title}],
)
self.assertEqual(len(assignments), 1)
assignment = assignments[0]
self.assertEqual(assignment["type"], "self_evaluation_feedback")
self.assertEqual(assignment["pending_evaluations"], 1)
completions = assignment["completions"]
self.assertEqual(
len(completions),
3,
)
completion_1 = get_completion_for_user(completions, self.participant_1.user)
self.assertEqual(completion_1["status"], "EVALUATED")
self.assertEqual(completion_1["last_name"], "Participant_1")
self.assertEqual(completion_1["user_id"], str(self.participant_1.user.id))
completion_2 = get_completion_for_user(completions, self.participant_2.user)
self.assertEqual(completion_2["status"], "SUBMITTED")
self.assertEqual(completion_2["last_name"], "Participant_2")
self.assertEqual(completion_2["user_id"], str(self.participant_2.user.id))
completion_3 = get_completion_for_user(completions, self.participant_3.user)
self.assertEqual(completion_3["status"], "UNKNOWN")
self.assertEqual(completion_3["last_name"], "Participant_3")
self.assertEqual(completion_3["user_id"], str(self.participant_3.user.id))
def test_api_praxis_assignments(self) -> None:
# GIVEN
self.client.force_login(self.mentor)
assignment = create_assignment(
course=self.course, assignment_type=AssignmentType.PRAXIS_ASSIGNMENT
)
lca = create_assignment_learning_content(self.circle, assignment)
create_course_session_assignment(
course_session=self.course_session, learning_content_assignment=lca
)
mentor = LearningMentor.objects.create(
mentor=self.mentor,
course=self.course_session.course,
)
participants = [self.participant_1, self.participant_2, self.participant_3]
mentor.participants.set(participants)
AssignmentCompletion.objects.create(
assignment_user=self.participant_1.user,
course_session=self.course_session,
assignment=self.assignment,
assignment=assignment,
completion_status=AssignmentCompletionStatus.EVALUATION_SUBMITTED.value,
evaluation_user=self.mentor,
)
@ -131,7 +229,7 @@ class LearningMentorAPITest(APITestCase):
AssignmentCompletion.objects.create(
assignment_user=self.participant_3.user,
course_session=self.course_session,
assignment=self.assignment,
assignment=assignment,
completion_status=AssignmentCompletionStatus.SUBMITTED.value,
evaluation_user=self.mentor,
)

View File

@ -12,11 +12,14 @@ from vbv_lernwelt.iam.permissions import has_role_in_course, is_course_session_m
from vbv_lernwelt.learning_mentor.content.praxis_assignment import (
get_praxis_assignments,
)
from vbv_lernwelt.learning_mentor.content.self_evaluation_feedback import (
get_self_feedback_evaluation,
)
from vbv_lernwelt.learning_mentor.models import LearningMentor, MentorInvitation
from vbv_lernwelt.learning_mentor.serializers import (
InvitationSerializer,
MentorAssignmentStatusSerializer,
MentorSerializer,
PraxisAssignmentStatusSerializer,
)
from vbv_lernwelt.learnpath.models import Circle
from vbv_lernwelt.notify.email.email_services import EmailTemplate, send_email
@ -37,24 +40,40 @@ def mentor_summary(request, course_session_id: int):
assignments = []
circle_ids = set()
praxis_assignments, _circle_ids = get_praxis_assignments(
course_session=course_session, participants=users, evaluation_user=request.user
praxis_assignments, praxis_assignments_circle_ids = get_praxis_assignments(
course_session=course_session,
participants=users,
evaluation_user=request.user, # noqa
)
(
self_evaluation_feedbacks,
self_evaluation_feedback_circle_ids,
) = get_self_feedback_evaluation(
participants=users,
evaluation_user=request.user, # noqa
course=course_session.course,
)
circle_ids.update(praxis_assignments_circle_ids)
circle_ids.update(self_evaluation_feedback_circle_ids)
assignments.extend(
MentorAssignmentStatusSerializer(praxis_assignments, many=True).data
)
assignments.extend(
PraxisAssignmentStatusSerializer(praxis_assignments, many=True).data
MentorAssignmentStatusSerializer(self_evaluation_feedbacks, many=True).data
)
circle_ids.update(_circle_ids)
circles = Circle.objects.filter(id__in=circle_ids).values("id", "title")
assignments.sort(
key=lambda x: (-x.get("pending_evaluations", 0), x.get("title", "").lower())
)
return Response(
{
"participants": [UserSerializer(user).data for user in users],
"circles": list(circles),
"circles": list(
Circle.objects.filter(id__in=circle_ids).values("id", "title")
),
"assignments": assignments,
}
)

View File

@ -5,11 +5,16 @@ from wagtail.rich_text import RichText
from wagtail_localize.models import LocaleSynchronization
from vbv_lernwelt.assignment.models import Assignment
from vbv_lernwelt.competence.factories import PerformanceCriteriaFactory
from vbv_lernwelt.competence.factories import (
ActionCompetenceFactory,
ActionCompetenceListPageFactory,
PerformanceCriteriaFactory,
)
from vbv_lernwelt.competence.models import ActionCompetence
from vbv_lernwelt.core.admin import User
from vbv_lernwelt.course.consts import COURSE_VERSICHERUNGSVERMITTLERIN_ID
from vbv_lernwelt.course.models import CourseCategory, CoursePage
from vbv_lernwelt.learnpath.models import LearningUnitPerformanceFeedbackType
from vbv_lernwelt.learnpath.tests.learning_path_factories import (
CircleFactory,
LearningContentAssignmentFactory,
@ -43,7 +48,7 @@ def create_vv_new_learning_path(
)
TopicFactory(title="Basis", is_visible=False, parent=lp)
create_circle_basis(lp)
create_circle_basis(lp, course_page=course_page)
TopicFactory(title="Gewinnen von Kunden", parent=lp)
create_circle_gewinnen(lp)
@ -103,7 +108,7 @@ def create_vv_pruefung_learning_path(
Page.objects.update(owner=user)
def create_circle_basis(lp, title="Basis"):
def create_circle_basis(lp, title="Basis", course_page=None):
circle = CircleFactory(
title=title,
parent=lp,
@ -145,10 +150,42 @@ def create_circle_basis(lp, title="Basis"):
)
LearningSequenceFactory(title="Arbeitsalltag", parent=circle)
LearningUnitFactory(
lu = LearningUnitFactory(
title="Mein neuer Job, Arbeitstechnik, Soziale Medien, Datenschutz und Beratungspflichten",
feedback_user=LearningUnitPerformanceFeedbackType.MENTOR_FEEDBACK.name,
parent=circle,
)
competence_profile_page = ActionCompetenceListPageFactory(
title="KompetenzNavi",
parent=course_page,
)
ace = ActionCompetenceFactory(
parent=competence_profile_page,
)
PerformanceCriteriaFactory(
parent=ace,
competence_id="VV-Arbeitsalltag-A",
title="Ich kenne die wichtigsten Aspekte des Arbeitsalltags als Versicherungsvermittler/-in.",
learning_unit=lu,
)
PerformanceCriteriaFactory(
parent=ace,
competence_id="VV-Arbeitsalltag-B",
title="Ich identifiziere und analysiere neue Markttrends im Versicherungssektor.",
learning_unit=lu,
)
PerformanceCriteriaFactory(
parent=ace,
competence_id="VV-Arbeitsalltag-C",
title="Ich nutze digitale Tools zur Optimierung der Kundenbetreuung und -beratung im Versicherungswesen.",
learning_unit=lu,
)
LearningContentPlaceholderFactory(
title="Mediathek",
parent=circle,

View File

@ -234,7 +234,7 @@ class LearningUnitObjectType(DjangoObjectType):
class Meta:
model = LearningUnit
interfaces = (CoursePageInterface,)
fields = ["evaluate_url", "title_hidden"]
fields = ["evaluate_url", "title_hidden", "feedback_user"]
def resolve_evaluate_url(self: LearningUnit, info, **kwargs):
return self.get_evaluate_url()

View File

@ -0,0 +1,41 @@
# Generated by Django 3.2.20 on 2024-01-17 13:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("learnpath", "0012_auto_20231129_0827"),
]
operations = [
migrations.AddField(
model_name="learningunit",
name="feedback_user",
field=models.CharField(
blank=True,
choices=[
("NO_FEEDBACK", "NO_FEEDBACK"),
("MENTOR_FEEDBACK", "MENTOR_FEEDBACK"),
],
default="NO_FEEDBACK",
max_length=255,
),
),
migrations.AlterField(
model_name="learningcontentassignment",
name="assignment_type",
field=models.CharField(
choices=[
("PRAXIS_ASSIGNMENT", "PRAXIS_ASSIGNMENT"),
("CASEWORK", "CASEWORK"),
("PREP_ASSIGNMENT", "PREP_ASSIGNMENT"),
("REFLECTION", "REFLECTION"),
("CONDITION_ACCEPTANCE", "CONDITION_ACCEPTANCE"),
("EDONIQ_TEST", "EDONIQ_TEST"),
],
default="CASEWORK",
max_length=50,
),
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 3.2.20 on 2024-01-17 14:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("learnpath", "0013_auto_20240117_1400"),
]
operations = [
migrations.AlterField(
model_name="learningunit",
name="feedback_user",
field=models.CharField(
choices=[
("NO_FEEDBACK", "NO_FEEDBACK"),
("MENTOR_FEEDBACK", "MENTOR_FEEDBACK"),
],
default="NO_FEEDBACK",
max_length=255,
),
),
]

View File

@ -0,0 +1,50 @@
# Generated by Django 3.2.20 on 2024-02-12 15:19
from django.db import migrations
VV_COURSE_IDS_WITH_MENTOR_FEEDBACK = [
-4, # vv-de
-10, # vv-fr
-11, # vv-it
]
def is_learning_unit_in_vv_course(learning_unit):
return learning_unit.course_category.course_id in VV_COURSE_IDS_WITH_MENTOR_FEEDBACK
def mutate_data(apps, schema_editor):
"""
Enable feedback for learning units in VV courses, this means that on the self-assessment page
of the learning unit, the user can request feedback from the mentor.
"""
LearningUnit = apps.get_model("learnpath", "LearningUnit") # noqa
for learning_unit in LearningUnit.objects.all():
if is_learning_unit_in_vv_course(learning_unit):
learning_unit.feedback_user = "MENTOR_FEEDBACK"
learning_unit.save()
def rollback_data(apps, schema_editor):
"""
Disable feedback for learning units in VV courses, this means that on the self-assessment page
of the learning unit, the user can not request feedback from the mentor. -> Default behaviour.
"""
LearningUnit = apps.get_model("learnpath", "LearningUnit") # noqa
for learning_unit in LearningUnit.objects.all():
if is_learning_unit_in_vv_course(learning_unit):
learning_unit.feedback_user = "NO_FEEDBACK"
learning_unit.save()
class Migration(migrations.Migration):
dependencies = [
("learnpath", "0014_alter_learningunit_feedback_user"),
]
operations = [
migrations.RunPython(mutate_data, rollback_data),
]

View File

@ -1,8 +1,10 @@
import re
from enum import Enum
from typing import Tuple
from django.db import models
from django.utils.text import slugify
from wagtail.admin.panels import FieldPanel, PageChooserPanel
from wagtail.admin.panels import FieldPanel, HelpPanel, PageChooserPanel
from wagtail.fields import RichTextField, StreamField
from wagtail.models import Page
@ -117,6 +119,13 @@ class Circle(CourseBasePage):
return f"{self.title}"
class LearningUnitPerformanceFeedbackType(Enum):
"""Defines how feedback on the performance criteria (n) of a learning unit are given."""
NO_FEEDBACK = "NO_FEEDBACK"
MENTOR_FEEDBACK = "MENTOR_FEEDBACK"
class LearningSequence(CourseBasePage):
serialize_field_names = ["icon"]
@ -169,10 +178,21 @@ class LearningUnit(CourseBasePage):
"course.CourseCategory", on_delete=models.SET_NULL, null=True, blank=True
)
title_hidden = models.BooleanField(default=False)
feedback_user = models.CharField(
max_length=255,
choices=[(tag.name, tag.name) for tag in LearningUnitPerformanceFeedbackType],
default=LearningUnitPerformanceFeedbackType.NO_FEEDBACK.name,
)
content_panels = Page.content_panels + [
FieldPanel("course_category"),
FieldPanel("title_hidden"),
FieldPanel("feedback_user"),
HelpPanel(
content="👆 Feedback zur Selbsteinschätzung: Normalerweise <code>NO_FEEDBACK</code>, "
"ausser bei den Lerninhalten Selbsteinschätzungen, die eine Bewertung haben von einer "
"Lernbegleitung haben sollen (z.B. VV)."
),
]
class Meta:
@ -200,21 +220,29 @@ class LearningUnit(CourseBasePage):
)
super(LearningUnit, self).save(clean, user, log_action, **kwargs)
def get_frontend_url(self):
def get_frontend_url_parts(self) -> Tuple[str, str, str]:
"""
Extracts the course, circle and learning unit part from the slug.
:return: Tuple of course, circle and learning unit part
"""
r = re.compile(
r"^(?P<coursePart>.+?)-lp-circle-(?P<circlePart>.+?)-lu-(?P<luPart>.+?)$"
)
m = r.match(self.slug)
if m is None:
return "ERROR: could not parse slug"
return f"/course/{m.group('coursePart')}/learn/{m.group('circlePart')}#lu-{m.group('luPart')}"
ValueError(f"Could not parse slug: {self.slug}")
return m.group("coursePart"), m.group("circlePart"), m.group("luPart")
def get_frontend_url(self):
course, circle, learning_unit = self.get_frontend_url_parts()
return f"/course/{course}/learn/{circle}#lu-{learning_unit}"
def get_evaluate_url(self):
r = re.compile(
r"^(?P<coursePart>.+?)-lp-circle-(?P<circlePart>.+?)-lu-(?P<luPart>.+?)$"
)
m = r.match(self.slug)
return f"/course/{m.group('coursePart')}/learn/{m.group('circlePart')}/evaluate/{m.group('luPart')}"
course, circle, learning_unit = self.get_frontend_url_parts()
return f"/course/{course}/learn/{circle}/evaluate/{learning_unit}"
def get_admin_display_title(self):
return f"LE: {self.draft_title}"

View File

@ -20,6 +20,7 @@ class LearningUnitSerializer(
"course_category",
"children",
"title_hidden",
"feedback_user",
],
)
):

View File

@ -2,6 +2,12 @@ from django.test import TestCase
from vbv_lernwelt.core.create_default_users import create_default_users
from vbv_lernwelt.course.creators.test_course import create_test_course
from vbv_lernwelt.course.creators.test_utils import (
create_circle,
create_course,
create_course_session,
create_learning_unit,
)
from vbv_lernwelt.learnpath.models import LearningContentPlaceholder
@ -31,3 +37,22 @@ class SaveSlugTestCase(TestCase):
self.assertEqual(
lc_fachcheck.slug, "test-lehrgang-lp-circle-reisen-lc-fachcheck-foobar"
)
def test_learning_unit_frontend_url_parts(self):
# GIVEN
course, course_page = create_course(course_page_title="What Ever Course Page")
course_session = create_course_session(course=course, title=":)")
circle, _ = create_circle(title="A-nice Circle", course_page=course_page)
# WHEN
cut = create_learning_unit(
course_category_title="course category title",
circle=circle,
course=course,
)
course_part, circle_part, learning_unit_part = cut.get_frontend_url_parts()
# THEN
self.assertEqual(course_part, "what-ever-course-page")
self.assertEqual(circle_part, "a-nice-circle")
self.assertEqual(learning_unit_part, "course-category-title")

View File

@ -34,6 +34,8 @@ def user_image(request, image_id):
rendition.file.open("rb")
image_format = imghdr.what(rendition.file)
return StreamingHttpResponse(
FileWrapper(rendition.file), content_type="image/" + image_format
FileWrapper(rendition.file),
content_type=f"image/{image_format}" if image_format else "binary/octet-stream",
)

View File

@ -76,6 +76,20 @@ class EmailTemplate(Enum):
"it": "d-30c6aa9accda4973a940dd25703cb4a9",
}
# Fremdeinschätzung (Requester → Provider)
SELF_EVALUATION_FEEDBACK_REQUESTED = {
"de": "d-cf9c6681991d4293a7baccaa5b043c5c",
"fr": "d-6b103876807a4a0db6a0c31651c1e8ba",
"it": "d-403b2f9d09bb41dc9dc85eed6c35c942",
}
# Fremdeinschätzung (Provider → Requester)
SELF_EVALUATION_FEEDBACK_PROVIDED = {
"de": "d-e547bff40252458fa802759f2c502e3a",
"fr": "d-62aa7ce8639c49319f92edb858bbb1cd",
"it": "d-de2b5dfaf5d2470dbeea5d3ea2a6c442",
}
def send_email(
recipient_email: str,

View File

@ -24,6 +24,13 @@ class NotificationTrigger(models.TextChoices):
CASEWORK_EVALUATED = "CASEWORK_EVALUATED", _("Casework Evaluated")
NEW_FEEDBACK = "NEW_FEEDBACK", _("New Feedback")
SELF_EVALUATION_FEEDBACK_REQUESTED = "SELF_EVALUATION_FEEDBACK_REQUESTED", _(
"Self Evaluation Feedback Requested"
)
SELF_EVALUATION_FEEDBACK_PROVIDED = "SELF_EVALUATION_FEEDBACK_PROVIDED", _(
"Self Evaluation Feedback Provided"
)
class Notification(AbstractNotification):
# UUIDs are not supported by the notifications app...

View File

@ -23,6 +23,7 @@ from vbv_lernwelt.notify.models import (
NotificationCategory,
NotificationTrigger,
)
from vbv_lernwelt.self_evaluation_feedback.models import SelfEvaluationFeedback
if TYPE_CHECKING:
from vbv_lernwelt.assignment.models import AssignmentCompletion
@ -73,6 +74,74 @@ class NotificationService:
email_template=EmailTemplate.CASEWORK_SUBMITTED,
)
@classmethod
def send_self_evaluation_feedback_request_feedback_notification(
cls,
self_evaluation_feedback: SelfEvaluationFeedback,
):
"""Requester -> Provider"""
requester_user = self_evaluation_feedback.feedback_requester_user
provider_user = self_evaluation_feedback.feedback_provider_user
texts = {
"de": "%(requester)s hat eine Selbsteinschätzung mit dir geteilt",
"fr": "%(requester)s a partagé une auto-évaluation avec vous",
"it": "%(requester)s ha condiviso una valutazione personale con te",
}
verb = texts.get(provider_user.language, "de") % {
"requester": requester_user.get_full_name(),
}
return cls._send_notification(
recipient=provider_user,
verb=verb,
notification_category=NotificationCategory.USER_INTERACTION,
notification_trigger=NotificationTrigger.SELF_EVALUATION_FEEDBACK_REQUESTED,
sender=requester_user,
target_url=self_evaluation_feedback.feedback_provider_evaluation_url,
action_object=self_evaluation_feedback,
email_template=EmailTemplate.SELF_EVALUATION_FEEDBACK_REQUESTED,
template_data={
"mentee_name": requester_user.get_full_name(),
"mentee_email": requester_user.email,
},
)
@classmethod
def send_self_evaluation_feedback_received_notification(
cls,
self_evaluation_feedback: SelfEvaluationFeedback,
):
"""Provider -> Requester"""
requester_user = self_evaluation_feedback.feedback_requester_user
provider_user = self_evaluation_feedback.feedback_provider_user
texts = {
"de": "%(provider)s hat dir eine Fremdeinschätzung gegeben",
"fr": "%(provider)s vous a donné une évaluation externe",
"it": "%(provider)s ti ha dato una valutazione esterna",
}
verb = texts.get(requester_user.language, "de") % {
"provider": provider_user.get_full_name(),
}
return cls._send_notification(
recipient=requester_user,
verb=verb,
notification_category=NotificationCategory.USER_INTERACTION,
notification_trigger=NotificationTrigger.SELF_EVALUATION_FEEDBACK_PROVIDED,
sender=provider_user,
target_url=self_evaluation_feedback.feedback_requester_results_url,
action_object=self_evaluation_feedback,
email_template=EmailTemplate.SELF_EVALUATION_FEEDBACK_PROVIDED,
template_data={
"mentor_name": provider_user.get_full_name(),
"mentor_email": provider_user.email,
},
)
@classmethod
def send_assignment_evaluated_notification(
cls,

View File

@ -0,0 +1,49 @@
from django.contrib import admin
from vbv_lernwelt.self_evaluation_feedback.models import (
CourseCompletionFeedback,
SelfEvaluationFeedback,
)
@admin.register(SelfEvaluationFeedback)
class CourseSessionAdmin(admin.ModelAdmin):
list_display = (
"id",
"feedback_submitted",
"feedback_requester_user",
"feedback_provider_user",
"learning_unit",
)
list_filter = (
"feedback_submitted",
"feedback_requester_user",
"feedback_provider_user",
"learning_unit",
)
search_fields = (
"feedback_submitted",
"feedback_requester_user",
"feedback_provider_user",
"learning_unit",
)
@admin.register(CourseCompletionFeedback)
class CourseSessionAdmin(admin.ModelAdmin):
list_display = (
"id",
"feedback",
"course_completion",
"feedback_assessment",
)
list_filter = (
"feedback",
"course_completion",
"feedback_assessment",
)
search_fields = (
"feedback",
"course_completion",
"feedback_assessment",
)

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class SelfEvaluationFeedbackConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "vbv_lernwelt.self_evaluation_feedback"

View File

@ -0,0 +1,113 @@
# Generated by Django 3.2.20 on 2024-01-21 18:42
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import vbv_lernwelt.course.models
class Migration(migrations.Migration):
initial = True
dependencies = [
("course", "0006_auto_20231221_1411"),
("learnpath", "0014_alter_learningunit_feedback_user"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="SelfEvaluationFeedback",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("feedback_submitted", models.BooleanField(default=False)),
(
"feedback_provider_user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="feedback_provider_user",
to=settings.AUTH_USER_MODEL,
),
),
(
"feedback_requester_user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="feedback_requester_user",
to=settings.AUTH_USER_MODEL,
),
),
(
"learning_unit",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="learnpath.learningunit",
),
),
],
),
migrations.CreateModel(
name="CourseCompletionFeedback",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"provider_evaluation_feedback",
models.CharField(
choices=[
(
vbv_lernwelt.course.models.CourseCompletionStatus[
"SUCCESS"
],
"SUCCESS",
),
(
vbv_lernwelt.course.models.CourseCompletionStatus[
"FAIL"
],
"FAIL",
),
(
vbv_lernwelt.course.models.CourseCompletionStatus[
"UNKNOWN"
],
"UNKNOWN",
),
],
default="UNKNOWN",
max_length=255,
),
),
(
"feedback",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="self_evaluation_feedback.selfevaluationfeedback",
),
),
(
"requester_evaluation",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="course.coursecompletion",
),
),
],
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.20 on 2024-01-22 13:20
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("self_evaluation_feedback", "0001_initial"),
]
operations = [
migrations.RenameField(
model_name="coursecompletionfeedback",
old_name="requester_evaluation",
new_name="course_completion",
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.2.20 on 2024-01-23 15:46
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
(
"self_evaluation_feedback",
"0002_rename_requester_evaluation_coursecompletionfeedback_course_completion",
),
]
operations = [
migrations.RenameField(
model_name="coursecompletionfeedback",
old_name="provider_evaluation_feedback",
new_name="feedback_assessment",
),
]

View File

@ -0,0 +1,44 @@
from django.db import models
from vbv_lernwelt.core.admin import User
from vbv_lernwelt.course.models import CourseCompletion, CourseCompletionStatus
class SelfEvaluationFeedback(models.Model):
feedback_submitted = models.BooleanField(default=False)
feedback_requester_user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="feedback_requester_user"
)
feedback_provider_user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="feedback_provider_user"
)
learning_unit = models.ForeignKey(
"learnpath.LearningUnit", on_delete=models.CASCADE
)
@property
def feedback_requester_results_url(self) -> str:
url = self.learning_unit.get_evaluate_url()
received_evaluation_step = len(self.learning_unit.performancecriteria_set.all())
return f"{url}?step={received_evaluation_step}"
@property
def feedback_provider_evaluation_url(self) -> str:
course, _, __ = self.learning_unit.get_frontend_url_parts()
return f"/course/{course}/cockpit/mentor/self-evaluation-feedback/{self.learning_unit.id}"
class CourseCompletionFeedback(models.Model):
feedback = models.ForeignKey(SelfEvaluationFeedback, on_delete=models.CASCADE)
# the course completion has to be evaluated by the feedback provider
course_completion = models.ForeignKey(CourseCompletion, on_delete=models.CASCADE)
feedback_assessment = models.CharField(
max_length=255,
choices=[(status, status.value) for status in CourseCompletionStatus],
default=CourseCompletionStatus.UNKNOWN.value,
)

View File

@ -0,0 +1,81 @@
from typing import List
from rest_framework import serializers
from vbv_lernwelt.competence.models import PerformanceCriteria
from vbv_lernwelt.core.serializers import UserSerializer
from vbv_lernwelt.course.models import CourseCompletion, CourseCompletionStatus
from vbv_lernwelt.self_evaluation_feedback.models import (
CourseCompletionFeedback,
SelfEvaluationFeedback,
)
class SelfEvaluationFeedbackSerializer(serializers.ModelSerializer):
criteria = serializers.SerializerMethodField()
feedback_requester_user = UserSerializer(read_only=True)
feedback_provider_user = UserSerializer(read_only=True)
learning_unit_id = serializers.PrimaryKeyRelatedField(
read_only=True, source="learning_unit"
)
feedback_id = serializers.PrimaryKeyRelatedField(read_only=True, source="id")
circle_name = serializers.SerializerMethodField()
title = serializers.CharField(source="learning_unit.title")
class Meta:
model = SelfEvaluationFeedback
fields = [
"feedback_id",
"title",
"circle_name",
"learning_unit_id",
"feedback_submitted",
"feedback_requester_user",
"feedback_provider_user",
"criteria",
]
def get_circle_name(self, obj):
return obj.learning_unit.get_circle().title
def get_criteria(self, obj):
performance_criteria: List[
PerformanceCriteria
] = obj.learning_unit.performancecriteria_set.all()
criteria = []
for pc in performance_criteria:
# requester self assessment
completion = CourseCompletion.objects.filter(
page_id=pc.id,
user=obj.feedback_requester_user,
).first()
self_assessment = (
completion.completion_status
if completion
else CourseCompletionStatus.UNKNOWN.value
)
# provider feedback assessment
feedback = CourseCompletionFeedback.objects.filter(
course_completion=completion
).first()
feedback_assessment = (
feedback.feedback_assessment
if feedback
else CourseCompletionStatus.UNKNOWN.value
)
criteria.append(
{
"course_completion_id": completion.id if completion else None,
"title": pc.title,
"self_assessment": self_assessment,
"feedback_assessment": feedback_assessment,
}
)
return criteria

View File

@ -0,0 +1,463 @@
from unittest.mock import patch
from django.urls import reverse
from rest_framework.test import APITestCase
from vbv_lernwelt.course.creators.test_utils import (
add_course_session_user,
create_circle,
create_course,
create_course_session,
create_learning_unit,
create_performance_criteria_page,
create_user,
)
from vbv_lernwelt.course.models import CourseCompletionStatus, CourseSessionUser
from vbv_lernwelt.course.services import mark_course_completion
from vbv_lernwelt.learning_mentor.models import LearningMentor
from vbv_lernwelt.self_evaluation_feedback.models import (
CourseCompletionFeedback,
SelfEvaluationFeedback,
)
def create_self_evaluation_feedback(
learning_unit, feedback_requester_user, feedback_provider_user
):
return SelfEvaluationFeedback.objects.create(
learning_unit=learning_unit,
feedback_requester_user=feedback_requester_user,
feedback_provider_user=feedback_provider_user,
)
class SelfEvaluationFeedbackAPI(APITestCase):
def setUp(self) -> None:
self.member = create_user("member")
self.mentor = create_user("mentor")
self.course, self.course_page = create_course("Test Course")
self.course_session = create_course_session(
course=self.course, title="Test Bern 2022 a"
)
member_csu = add_course_session_user(
course_session=self.course_session,
user=self.member,
role=CourseSessionUser.Role.MEMBER,
)
self.circle, _ = create_circle(
title="Test Circle", course_page=self.course_page
)
learning_mentor = LearningMentor.objects.create(
mentor=self.mentor,
course=self.course_session.course,
)
learning_mentor.participants.add(member_csu)
@patch(
"vbv_lernwelt.notify.services.NotificationService.send_self_evaluation_feedback_request_feedback_notification"
)
def test_start_self_evaluation_feedback(self, mock_notification_service_send):
# GIVEN
learning_unit = create_learning_unit(course=self.course, circle=self.circle)
pc = create_performance_criteria_page(
course=self.course,
course_page=self.course_page,
circle=self.circle,
learning_unit=learning_unit,
)
mark_course_completion(
page=pc,
user=self.member,
course_session=self.course_session,
completion_status="SUCCESS",
)
self.client.force_login(self.member)
# WHEN
response = self.client.post(
reverse(
"start_self_evaluation_feedback",
args=[learning_unit.id],
),
{
"feedback_provider_user_id": self.mentor.id,
},
)
# make sure re-starting is a no-op
self.client.post(
reverse(
"start_self_evaluation_feedback",
args=[learning_unit.id],
),
{
"feedback_provider_user_id": self.mentor.id,
},
)
# shall be idempotent
self.client.post(
reverse(
"start_self_evaluation_feedback",
args=[learning_unit.id],
),
{
"feedback_provider_user_id": self.mentor.id,
},
)
# THEN
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["success"], True)
self.assertEqual(
SelfEvaluationFeedback.objects.count(),
1,
)
self_evaluation_feedback = SelfEvaluationFeedback.objects.first()
self.assertEqual(self_evaluation_feedback.feedback_requester_user, self.member)
self.assertEqual(self_evaluation_feedback.feedback_provider_user, self.mentor)
self.assertEqual(self_evaluation_feedback.learning_unit, learning_unit)
mock_notification_service_send.assert_called_once_with(
self_evaluation_feedback=self_evaluation_feedback
)
def test_start_self_evaluation_feedback_not_allowed_user(self):
# GIVEN
learning_unit = create_learning_unit(course=self.course, circle=self.circle)
not_a_mentor = create_user("not_a_mentor")
self.client.force_login(self.member)
# WHEN
response = self.client.post(
reverse("start_self_evaluation_feedback", args=[learning_unit.id]),
{
"feedback_provider_user_id": not_a_mentor.id,
},
)
# THEN
self.assertEqual(response.status_code, 403)
def test_get_self_evaluation_feedback_as_requester(self):
"""Tests endpoint of feedback REQUESTER"""
# GIVEN
learning_unit = create_learning_unit(course=self.course, circle=self.circle)
performance_criteria_1 = create_performance_criteria_page(
course=self.course,
course_page=self.course_page,
circle=self.circle,
learning_unit=learning_unit,
)
create_performance_criteria_page(
course=self.course,
course_page=self.course_page,
circle=self.circle,
learning_unit=learning_unit,
)
completion = mark_course_completion(
page=performance_criteria_1,
user=self.member,
course_session=self.course_session,
completion_status=CourseCompletionStatus.SUCCESS.value,
)
self_evaluation_feedback = create_self_evaluation_feedback(
learning_unit=learning_unit,
feedback_requester_user=self.member,
feedback_provider_user=self.mentor,
)
CourseCompletionFeedback.objects.create(
feedback=self_evaluation_feedback,
course_completion=completion,
feedback_assessment=CourseCompletionStatus.FAIL.value,
)
self.client.force_login(self.member)
# WHEN
response = self.client.get(
reverse(
"get_self_evaluation_feedback_as_requester",
args=[learning_unit.id],
)
)
# THEN
self.assertEqual(response.status_code, 200)
feedback = response.data
self.assertEqual(feedback["learning_unit_id"], learning_unit.id)
self.assertEqual(feedback["feedback_submitted"], False)
self.assertEqual(feedback["circle_name"], self.circle.title)
provider_user = feedback["feedback_provider_user"]
self.assertEqual(provider_user["id"], str(self.mentor.id)) # noqa
self.assertEqual(provider_user["first_name"], self.mentor.first_name)
self.assertEqual(provider_user["last_name"], self.mentor.last_name)
self.assertEqual(provider_user["avatar_url"], self.mentor.avatar_url)
requester_user = feedback["feedback_requester_user"]
self.assertEqual(requester_user["id"], str(self.member.id)) # noqa
self.assertEqual(requester_user["first_name"], self.member.first_name)
self.assertEqual(requester_user["last_name"], self.member.last_name)
self.assertEqual(requester_user["avatar_url"], self.member.avatar_url)
self.assertEqual(len(feedback["criteria"]), 2)
first_criteria = feedback["criteria"][0]
self.assertEqual(first_criteria["course_completion_id"], completion.id)
self.assertEqual(first_criteria["title"], performance_criteria_1.title)
self.assertEqual(
first_criteria["self_assessment"],
CourseCompletionStatus.SUCCESS.value,
)
self.assertEqual(
first_criteria["feedback_assessment"], CourseCompletionStatus.FAIL.value
)
second_criteria = feedback["criteria"][1]
self.assertEqual(second_criteria["course_completion_id"], None)
self.assertEqual(second_criteria["title"], performance_criteria_1.title)
self.assertEqual(
second_criteria["self_assessment"], CourseCompletionStatus.UNKNOWN.value
)
self.assertEqual(
second_criteria["feedback_assessment"],
CourseCompletionStatus.UNKNOWN.value,
)
def test_get_self_evaluation_feedback_as_provider(self):
"""Tests endpoint of feedback PROVIDER"""
# GIVEN
learning_unit = create_learning_unit(course=self.course, circle=self.circle)
performance_criteria_1 = create_performance_criteria_page(
course=self.course,
course_page=self.course_page,
circle=self.circle,
learning_unit=learning_unit,
)
create_performance_criteria_page(
course=self.course,
course_page=self.course_page,
circle=self.circle,
learning_unit=learning_unit,
)
completion = mark_course_completion(
page=performance_criteria_1,
user=self.member,
course_session=self.course_session,
completion_status=CourseCompletionStatus.SUCCESS.value,
)
self_evaluation_feedback = create_self_evaluation_feedback(
learning_unit=learning_unit,
feedback_requester_user=self.member,
feedback_provider_user=self.mentor,
)
CourseCompletionFeedback.objects.create(
feedback=self_evaluation_feedback,
course_completion=completion,
feedback_assessment=CourseCompletionStatus.FAIL.value,
)
self.client.force_login(self.mentor)
# WHEN
response = self.client.get(
reverse(
"get_self_evaluation_feedback_as_provider",
args=[self_evaluation_feedback.learning_unit.id],
)
)
# THEN
self.assertEqual(response.status_code, 200)
feedback = response.data
self.assertEqual(feedback["learning_unit_id"], learning_unit.id)
self.assertEqual(feedback["title"], learning_unit.title)
self.assertEqual(feedback["feedback_submitted"], False)
self.assertEqual(feedback["circle_name"], self.circle.title)
provider_user = feedback["feedback_provider_user"]
self.assertEqual(provider_user["id"], str(self.mentor.id)) # noqa
self.assertEqual(provider_user["first_name"], self.mentor.first_name)
self.assertEqual(provider_user["last_name"], self.mentor.last_name)
self.assertEqual(provider_user["avatar_url"], self.mentor.avatar_url)
requester_user = feedback["feedback_requester_user"]
self.assertEqual(requester_user["id"], str(self.member.id)) # noqa
self.assertEqual(requester_user["first_name"], self.member.first_name)
self.assertEqual(requester_user["last_name"], self.member.last_name)
self.assertEqual(requester_user["avatar_url"], self.member.avatar_url)
self.assertEqual(len(feedback["criteria"]), 2)
first_criteria = feedback["criteria"][0]
self.assertEqual(first_criteria["course_completion_id"], completion.id)
self.assertEqual(first_criteria["title"], performance_criteria_1.title)
self.assertEqual(
first_criteria["self_assessment"],
CourseCompletionStatus.SUCCESS.value,
)
self.assertEqual(
first_criteria["feedback_assessment"], CourseCompletionStatus.FAIL.value
)
second_criteria = feedback["criteria"][1]
self.assertEqual(second_criteria["course_completion_id"], None)
self.assertEqual(second_criteria["title"], performance_criteria_1.title)
self.assertEqual(
second_criteria["self_assessment"], CourseCompletionStatus.UNKNOWN.value
)
self.assertEqual(
second_criteria["feedback_assessment"],
CourseCompletionStatus.UNKNOWN.value,
)
def test_self_evaluation_feedback_assessment(self):
# GIVEN
learning_unit = create_learning_unit(course=self.course, circle=self.circle)
performance_criteria_1 = create_performance_criteria_page(
course=self.course,
course_page=self.course_page,
circle=self.circle,
learning_unit=learning_unit,
)
completion = mark_course_completion(
page=performance_criteria_1,
user=self.member,
course_session=self.course_session,
completion_status=CourseCompletionStatus.SUCCESS.value,
)
self_evaluation_feedback = create_self_evaluation_feedback(
learning_unit=learning_unit,
feedback_requester_user=self.member,
feedback_provider_user=self.mentor,
)
self.client.force_login(self.mentor)
# WHEN
response = self.client.put(
reverse(
"add_self_evaluation_feedback_assessment",
args=[self_evaluation_feedback.id],
),
{
"course_completion_id": completion.id,
"feedback_assessment": CourseCompletionStatus.FAIL.value,
},
)
# THEN
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["success"], True)
feedback = CourseCompletionFeedback.objects.get(
feedback=self_evaluation_feedback,
course_completion=completion,
)
self.assertEqual(
feedback.feedback_assessment, CourseCompletionStatus.FAIL.value
)
@patch(
"vbv_lernwelt.notify.services.NotificationService.send_self_evaluation_feedback_received_notification"
)
def test_release_self_evaluation_feedback(self, mock_notification_service_send):
# GIVEN
learning_unit = create_learning_unit(course=self.course, circle=self.circle)
self_evaluation_feedback = create_self_evaluation_feedback(
learning_unit=learning_unit,
feedback_requester_user=self.member,
feedback_provider_user=self.mentor,
)
self.assertEqual(self_evaluation_feedback.feedback_submitted, False)
self.client.force_login(self.mentor)
# WHEN
response = self.client.put(
reverse(
"release_self_evaluation_feedback", args=[self_evaluation_feedback.id]
),
)
# shall be idempotent
response = self.client.put(
reverse(
"release_self_evaluation_feedback", args=[self_evaluation_feedback.id]
),
)
# THEN
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["success"], True)
self.assertEqual(
SelfEvaluationFeedback.objects.get(
id=self_evaluation_feedback.id
).feedback_submitted,
True,
)
mock_notification_service_send.assert_called_once_with(
self_evaluation_feedback=self_evaluation_feedback
)
def test_get_self_evaluation_feedback_frontend_urls(self):
"""Makes sure that the frontend urls are correct (used in notifications)"""
# GIVEN
learning_unit = create_learning_unit(course=self.course, circle=self.circle)
cut = create_self_evaluation_feedback(
learning_unit=learning_unit,
feedback_requester_user=self.member,
feedback_provider_user=self.mentor,
)
# WHEN
requester_url = cut.feedback_requester_results_url
provider_url = cut.feedback_provider_evaluation_url
# THEN
_course, _circle, _learning_unit = learning_unit.get_frontend_url_parts()
# 0 -> no completions so step=0 is correct
_step = len(learning_unit.performancecriteria_set.all())
self.assertEqual(
requester_url,
f"/course/{_course}/learn/{_circle}/evaluate/{_learning_unit}?step={_step}",
)
self.assertEqual(
provider_url,
f"/course/{_course}/cockpit/mentor/self-evaluation-feedback/{learning_unit.id}",
)

View File

@ -0,0 +1,37 @@
from django.urls import path
from vbv_lernwelt.self_evaluation_feedback.views import (
add_provider_self_evaluation_feedback,
get_self_evaluation_feedback_as_provider,
get_self_evaluation_feedback_as_requester,
release_provider_self_evaluation_feedback,
start_self_evaluation_feedback,
)
urlpatterns = [
path(
"requester/<int:learning_unit_id>/feedback/start",
start_self_evaluation_feedback,
name="start_self_evaluation_feedback",
),
path(
"requester/<int:learning_unit_id>/feedback",
get_self_evaluation_feedback_as_requester,
name="get_self_evaluation_feedback_as_requester",
),
path(
"provider/<int:learning_unit_id>/feedback",
get_self_evaluation_feedback_as_provider,
name="get_self_evaluation_feedback_as_provider",
),
path(
"provider/feedback/<int:feedback_id>/release",
release_provider_self_evaluation_feedback,
name="release_self_evaluation_feedback",
),
path(
"provider/feedback/<int:feedback_id>/add-assessment",
add_provider_self_evaluation_feedback,
name="add_self_evaluation_feedback_assessment",
),
]

View File

@ -0,0 +1,125 @@
from django.shortcuts import get_object_or_404
from rest_framework.decorators import api_view, permission_classes
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseCompletion
from vbv_lernwelt.learning_mentor.models import LearningMentor
from vbv_lernwelt.learnpath.models import LearningUnit
from vbv_lernwelt.notify.services import NotificationService
from vbv_lernwelt.self_evaluation_feedback.models import (
CourseCompletionFeedback,
SelfEvaluationFeedback,
)
from vbv_lernwelt.self_evaluation_feedback.serializers import (
SelfEvaluationFeedbackSerializer,
)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def start_self_evaluation_feedback(request, learning_unit_id):
feedback_provider_user_id = request.data.get("feedback_provider_user_id")
learning_unit = get_object_or_404(LearningUnit, id=learning_unit_id)
feedback_provider_user = get_object_or_404(User, id=feedback_provider_user_id)
if not LearningMentor.objects.filter(
course=learning_unit.get_course(),
mentor=feedback_provider_user,
participants__user=request.user,
).exists():
raise PermissionDenied()
# calling start multiple times shall be a no-op
feedback, created = SelfEvaluationFeedback.objects.get_or_create(
feedback_requester_user=request.user,
feedback_provider_user=feedback_provider_user,
learning_unit=learning_unit,
)
if created:
NotificationService.send_self_evaluation_feedback_request_feedback_notification(
self_evaluation_feedback=feedback
)
return Response({"success": True})
@api_view(["PUT"])
@permission_classes([IsAuthenticated])
def release_provider_self_evaluation_feedback(request, feedback_id):
feedback = get_object_or_404(
SelfEvaluationFeedback, id=feedback_id, feedback_provider_user=request.user
)
if feedback.feedback_submitted:
return Response({"success": True})
feedback.feedback_submitted = True
feedback.save()
NotificationService.send_self_evaluation_feedback_received_notification(
self_evaluation_feedback=feedback
)
return Response({"success": True})
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def get_self_evaluation_feedback_as_provider(request, learning_unit_id):
feedback = get_object_or_404(
SelfEvaluationFeedback,
learning_unit_id=learning_unit_id,
feedback_provider_user=request.user,
)
return Response(SelfEvaluationFeedbackSerializer(feedback).data)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def get_self_evaluation_feedback_as_requester(request, learning_unit_id):
learning_unit = get_object_or_404(LearningUnit, id=learning_unit_id)
feedback = get_object_or_404(
SelfEvaluationFeedback,
learning_unit=learning_unit,
feedback_requester_user=request.user,
)
return Response(SelfEvaluationFeedbackSerializer(feedback).data)
@api_view(["PUT"])
@permission_classes([IsAuthenticated])
def add_provider_self_evaluation_feedback(request, feedback_id):
feedback_assessment = request.data.get("feedback_assessment")
feedback = get_object_or_404(
SelfEvaluationFeedback, id=feedback_id, feedback_provider_user=request.user
)
course_completion = get_object_or_404(
CourseCompletion,
id=request.data.get("course_completion_id"),
user=feedback.feedback_requester_user,
)
(
course_completion_feedback,
created,
) = CourseCompletionFeedback.objects.get_or_create(
feedback=feedback,
course_completion=course_completion,
defaults={"feedback_assessment": feedback_assessment},
)
if not created:
course_completion_feedback.feedback_assessment = feedback_assessment
course_completion_feedback.save()
return Response({"success": True})

View File

@ -47,6 +47,12 @@
<form action="/api/core/cypressreset/" method="post">
{% csrf_token %}
<label>
<input type="checkbox" name="create_learning_mentor" value="true">
create_learning_mentor
</label>
<div style="margin-bottom: 8px; padding: 4px; border-bottom: 1px lightblue solid"></div>
<label>
<input type="checkbox" name="create_assignment_completion" value="true">
create_assignment_completion