Merged develop into feature/tracking-code

This commit is contained in:
Christian Cueni 2024-02-28 11:41:07 +00:00
commit e659b4b410
100 changed files with 4401 additions and 473 deletions

View File

@ -97,12 +97,12 @@ js-linting: &js-linting
default-steps: &default-steps
- parallel:
- step: *e2e
- step: *e2e
- step: *python-tests
- step: *python-linting
- step: *js-tests
- step: *js-linting
- step: *e2e
- step: *e2e
- step: *python-tests
- step: *python-linting
- step: *js-tests
- step: *js-linting
# main pipelines definitions
pipelines:
@ -132,16 +132,16 @@ pipelines:
script:
- echo "Release ready!"
- parallel:
- step:
<<: *deploy
name: deploy prod
deployment: prod
trigger: manual
- step:
<<: *deploy
name: deploy prod-azure
deployment: prod-azure
trigger: manual
- step:
<<: *deploy
name: deploy prod
deployment: prod
trigger: manual
- step:
<<: *deploy
name: deploy prod-azure
deployment: prod-azure
trigger: manual
custom:
deploy-feature-branch:
- step:

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,113 @@
<script setup lang="ts">
import { computed } from "vue";
import type { LearningUnitSummary } from "@/services/selfEvaluationFeedback";
import SmileyCell from "@/components/selfEvaluationFeedback/SmileyCell.vue";
const props = defineProps<{
summary: LearningUnitSummary;
}>();
const hasFeedbackReceived = computed(() => {
return props.summary.feedback_assessment?.submitted_by_provider ?? false;
});
const feedbackProviderAvatar = computed(() => {
return props.summary.feedback_assessment?.provider_user.avatar_url ?? "";
});
const feedbackProviderName = computed(() => {
if (!props.summary.feedback_assessment?.provider_user) {
return "";
} else {
return `${props.summary.feedback_assessment.provider_user.first_name} ${props.summary.feedback_assessment.provider_user.last_name}`;
}
});
</script>
<template>
<div class="bg-white" data-cy>
<!-- Top Row -->
<div class="flex items-center justify-between border-b-2 border-gray-200 p-4">
<div class="flex flex-col">
<b>{{ props.summary.title }}</b>
<span>Circle «{{ props.summary.circle_title }}»</span>
</div>
<span class="underline">
<router-link
:to="props.summary.detail_url"
:data-cy="`self-eval-${summary.id}-detail-url`"
>
{{ $t("a.Selbsteinschätzung anschauen") }}
</router-link>
</span>
</div>
<div class="ml-4 mr-4">
<!-- Self Assessment Row-->
<div class="flex pb-2 pt-2">
<div class="w-1/2">
{{ $t("a.Deine Selbsteinschätzung") }}
</div>
<div class="cell">
<SmileyCell
:count="props.summary.self_assessment.counts.pass"
:cypress-identifier="`self-eval-${props.summary.id}-pass`"
smiley="it-icon-smiley-happy"
/>
</div>
<div class="cell">
<SmileyCell
:count="props.summary.self_assessment.counts.fail"
:cypress-identifier="`self-eval-${props.summary.id}-fail`"
smiley="it-icon-smiley-thinking"
/>
</div>
<div class="cell">
<SmileyCell
:count="props.summary.self_assessment.counts.unknown"
:cypress-identifier="`self-eval-${props.summary.id}-unknown`"
smiley="it-icon-smiley-neutral"
/>
</div>
</div>
<!-- Feedback Assessment Row -->
<div v-if="hasFeedbackReceived" class="border-t-2 border-gray-200">
<div class="flex pb-2 pt-2">
<div class="flex w-1/2 items-center">
<span>
{{
$t("a.Fremdeinschätzung von FEEDBACK_PROVIDER_NAME", {
FEEDBACK_PROVIDER_NAME: feedbackProviderName,
})
}}
</span>
<img class="ml-2 h-7 w-7 rounded-full" :src="feedbackProviderAvatar" />
</div>
<div class="cell">
<SmileyCell
:count="props.summary.feedback_assessment?.counts.pass ?? 0"
smiley="it-icon-smiley-happy"
/>
</div>
<div class="cell">
<SmileyCell
:count="props.summary.feedback_assessment?.counts.fail ?? 0"
smiley="it-icon-smiley-thinking"
/>
</div>
<div class="cell">
<SmileyCell
:count="props.summary.feedback_assessment?.counts.unknown ?? 0"
smiley="it-icon-smiley-neutral"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="postcss" scoped>
.cell {
@apply w-12;
}
</style>

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

@ -0,0 +1,24 @@
<script setup lang="ts">
import log from "loglevel";
const props = defineProps<{
count: number;
smiley: string;
cypressIdentifier?: string;
}>();
log.info("Rendering SmileyCell:", props.cypressIdentifier);
</script>
<template>
<template v-if="count > 0">
<div class="flex items-center justify-center">
<component :is="smiley" class="mr-1 inline-block h-6 w-6"></component>
<p class="inline-block w-6" :data-cy="cypressIdentifier">
{{ count }}
</p>
</div>
</template>
</template>
<style scoped></style>

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>({
@ -35,7 +37,7 @@ const dropdownSelected = computed<DropdownSelectable>({
<template>
<Listbox v-model="dropdownSelected" as="div">
<div class="relative mt-1 w-full">
<div class="relative w-full">
<ListboxButton
class="relative flex w-full cursor-default flex-row items-center bg-white py-3 pl-5 pr-10 text-left"
:class="{
@ -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

@ -4,14 +4,15 @@ import { COMPETENCE_NAVI_CERTIFICATE_QUERY } from "@/graphql/queries";
import { useQuery } from "@urql/vue";
import { computed } from "vue";
import type { CompetenceCertificate } from "@/types";
import { useCurrentCourseSession, useCourseDataWithCompletion } from "@/composables";
import { useCurrentCourseSession } from "@/composables";
import {
assignmentsMaxEvaluationPoints,
assignmentsUserPoints,
competenceCertificateProgressStatusCount,
} from "@/pages/competence/utils";
import { useSelfEvaluationFeedbackSummaries } from "@/services/selfEvaluationFeedback";
import ItProgress from "@/components/ui/ItProgress.vue";
import { calcPerformanceCriteriaStatusCount } from "@/services/competence";
import { VV_COURSE_IDS } from "@/constants";
const props = defineProps<{
courseSlug: string;
@ -20,7 +21,6 @@ const props = defineProps<{
log.debug("CompetenceIndexPage setup", props);
const courseSession = useCurrentCourseSession();
const courseData = useCourseDataWithCompletion(props.courseSlug);
const certificatesQuery = useQuery({
query: COMPETENCE_NAVI_CERTIFICATE_QUERY,
@ -49,16 +49,41 @@ const userPointsEvaluatedAssignments = computed(() => {
return assignmentsUserPoints(allAssignments.value);
});
const performanceCriteriaStatusCount = computed(() => {
return calcPerformanceCriteriaStatusCount(courseData.flatPerformanceCriteria.value);
const selfEvaluationFeedbackSummaries = useSelfEvaluationFeedbackSummaries(
useCurrentCourseSession().value.id
);
const selfAssessmentCounts = computed(
() => selfEvaluationFeedbackSummaries.aggregates.value?.self_assessment
);
const feedbackEvaluationCounts = computed(
() => selfEvaluationFeedbackSummaries.aggregates.value?.feedback_assessment
);
const isFeedbackEvaluationVisible = computed(
() =>
selfEvaluationFeedbackSummaries.aggregates.value?.feedback_assessment_visible ??
false
);
// FIXME 22.02.24: To-be-tackled NEXT in a separate PR (shippable member comp.navi)
// -> Do not use the VV_COURSE_ID anymore (discuss with @chrigu) -> We do this next.
const currentCourseSession = useCurrentCourseSession();
const hasCompetenceCertificates = computed(() => {
return !VV_COURSE_IDS.includes(currentCourseSession.value.course.id);
});
const isLoaded = computed(
() =>
!selfEvaluationFeedbackSummaries.loading.value && !certificatesQuery.fetching.value
);
</script>
<template>
<div class="container-large lg:mt-4">
<h1 class="mb-8">{{ $t("a.KompetenzNavi") }}</h1>
<section class="mb-4 bg-white p-8">
<div v-if="isLoaded" class="container-large lg:mt-4">
<!-- Competence certificates -->
<section v-if="hasCompetenceCertificates" class="mb-4 bg-white p-8">
<div class="flex items-center">
<h3>{{ $t("a.Kompetenznachweise") }}</h3>
</div>
@ -80,7 +105,7 @@ const performanceCriteriaStatusCount = computed(() => {
<div
v-for="certificate in competenceCertificates"
:key="certificate.id"
class="flex flex-col justify-between border-b py-4 first:border-t lg:flex-row lg:items-center"
class="flex flex-col justify-between py-4 lg:flex-row lg:items-center"
:data-cy="`certificate-${certificate.slug}`"
>
<div class="text-bold text-xl">
@ -130,59 +155,96 @@ const performanceCriteriaStatusCount = computed(() => {
</div>
</section>
<!-- Self-evaluation -->
<section class="mb-4 bg-white px-8 py-4 lg:mb-8 lg:py-8">
<h3 class="mb-4 border-b pb-4 lg:border-0 lg:pb-0">
{{ $t("a.Selbsteinschätzungen") }}
</h3>
<ul
class="mb-6 flex flex-col lg:flex-row lg:items-center lg:justify-between lg:gap-8"
>
<li
class="mb-4 inline-block flex-1 border-b pb-4 lg:mb-0 lg:w-1/3 lg:border-b-0 lg:border-r lg:pb-0"
<div class="mb-8">
<h3 class="mb-4 pb-4 lg:pb-0">
{{ $t("a.Selbsteinschätzungen") }}
</h3>
<ul
class="mb-6 flex flex-col lg:flex-row lg:items-center lg:justify-between lg:gap-8"
>
<h5 class="mb-4 text-gray-700">«{{ $t("selfEvaluation.no") }}»</h5>
<div class="flex flex-row items-center">
<it-icon-smiley-thinking class="h-16 w-16"></it-icon-smiley-thinking>
<p
class="ml-4 inline-block text-7xl font-bold"
data-cy="self-evaluation-fail"
>
{{ performanceCriteriaStatusCount.FAIL }}
</p>
</div>
</li>
<li
class="mb-4 inline-block flex-1 border-b pb-4 lg:mb-0 lg:w-1/3 lg:border-b-0 lg:border-r lg:pb-0"
<li class="mb-4 inline-block flex-1 pb-4 lg:mb-0 lg:w-1/3 lg:pb-0">
<h5 class="mb-4 text-gray-700">«{{ $t("selfEvaluation.no") }}»</h5>
<div class="flex flex-row items-center">
<it-icon-smiley-thinking class="h-16 w-16"></it-icon-smiley-thinking>
<p
class="ml-4 inline-block text-7xl font-bold"
data-cy="self-evaluation-fail"
>
{{ selfAssessmentCounts?.fail }}
</p>
</div>
</li>
<li class="mb-4 inline-block flex-1 pb-4 lg:mb-0 lg:w-1/3 lg:pb-0">
<h5 class="mb-4 text-gray-700">«{{ $t("selfEvaluation.yes") }}»</h5>
<div class="flex flex-row items-center">
<it-icon-smiley-happy class="h-16 w-16"></it-icon-smiley-happy>
<p
class="ml-4 inline-block text-7xl font-bold"
data-cy="self-evaluation-success"
>
{{ selfAssessmentCounts?.pass }}
</p>
</div>
</li>
<li class="flex-1 pb-4 lg:mb-0 lg:w-1/3 lg:pb-0">
<h5 class="mb-4 text-gray-700">{{ $t("competences.notAssessed") }}</h5>
<div class="flex flex-row items-center">
<it-icon-smiley-neutral class="h-16 w-16"></it-icon-smiley-neutral>
<p
class="ml-4 inline-block text-7xl font-bold"
data-cy="self-evaluation-unknown"
>
{{ selfAssessmentCounts?.unknown }}
</p>
</div>
</li>
</ul>
</div>
<!-- Feedback evaluation -->
<div v-if="isFeedbackEvaluationVisible" class="mb-8 border-t pt-8">
<h3 class="mb-4 pb-4 lg:pb-0">
{{ $t("a.Fremdeinschätzungen") }}
</h3>
<ul
class="mb-6 flex flex-col lg:flex-row lg:items-center lg:justify-between lg:gap-8"
>
<h5 class="mb-4 text-gray-700">«{{ $t("selfEvaluation.yes") }}»</h5>
<div class="flex flex-row items-center">
<it-icon-smiley-happy class="h-16 w-16"></it-icon-smiley-happy>
<p
class="ml-4 inline-block text-7xl font-bold"
data-cy="self-evaluation-success"
>
{{ performanceCriteriaStatusCount.SUCCESS }}
</p>
</div>
</li>
<li class="flex-1 border-b pb-4 lg:mb-0 lg:w-1/3 lg:border-b-0 lg:pb-0">
<h5 class="mb-4 text-gray-700">{{ $t("competences.notAssessed") }}</h5>
<div class="flex flex-row items-center">
<it-icon-smiley-neutral class="h-16 w-16"></it-icon-smiley-neutral>
<p
class="ml-4 inline-block text-7xl font-bold"
data-cy="self-evaluation-unknown"
>
{{ performanceCriteriaStatusCount.UNKNOWN }}
</p>
</div>
</li>
</ul>
<li class="mb-4 inline-block flex-1 pb-4 lg:mb-0 lg:w-1/3 lg:pb-0">
<h5 class="mb-4 text-gray-700">«{{ $t("receivedEvaluation.no") }}»</h5>
<div class="flex flex-row items-center">
<it-icon-smiley-thinking class="h-16 w-16"></it-icon-smiley-thinking>
<p class="ml-4 inline-block text-7xl font-bold">
{{ feedbackEvaluationCounts?.fail }}
</p>
</div>
</li>
<li class="mb-4 inline-block flex-1 pb-4 lg:mb-0 lg:w-1/3 lg:pb-0">
<h5 class="mb-4 text-gray-700">«{{ $t("receivedEvaluation.yes") }}»</h5>
<div class="flex flex-row items-center">
<it-icon-smiley-happy class="h-16 w-16"></it-icon-smiley-happy>
<p class="ml-4 inline-block text-7xl font-bold">
{{ feedbackEvaluationCounts?.pass }}
</p>
</div>
</li>
<li class="flex-1 pb-4 lg:mb-0 lg:w-1/3 lg:pb-0">
<h5 class="mb-4 text-gray-700">{{ $t("competences.notAssessed") }}</h5>
<div class="flex flex-row items-center">
<it-icon-smiley-neutral class="h-16 w-16"></it-icon-smiley-neutral>
<p class="ml-4 inline-block text-7xl font-bold">
{{ feedbackEvaluationCounts?.unknown }}
</p>
</div>
</li>
</ul>
</div>
<div>
<router-link
:to="`/course/${props.courseSlug}/competence/criteria`"
class="btn-text mt-4 inline-flex items-center py-2 pl-0"
:to="`/course/${props.courseSlug}/competence/self-evaluation-and-feedback`"
class="btn-text inline-flex items-center py-2 pl-0"
>
<span>{{ $t("general.showAll") }}</span>
<it-icon-arrow-right></it-icon-arrow-right>

View File

@ -1,7 +1,9 @@
<script setup lang="ts">
import * as log from "loglevel";
import { onMounted } from "vue";
import { computed, onMounted } from "vue";
import { useRoute } from "vue-router";
import { VV_COURSE_IDS } from "@/constants";
import { useCurrentCourseSession } from "@/composables";
log.debug("CompetenceParentPage created");
@ -19,14 +21,21 @@ function routeInCompetenceCertificate() {
return route.path.includes("/certificate");
}
function routeInPerformanceCriteria() {
return route.path.endsWith("/criteria");
}
function routeInActionCompetences() {
return route.path.endsWith("/competences");
}
function routeInSelfEvaluationAndFeedback() {
return route.path.endsWith("/self-evaluation-and-feedback");
}
// FIXME 22.02.24: To-be-tackled NEXT in a separate PR (shippable member comp.navi)
// -> Do not use the VV_COURSE_ID anymore (discuss with @chrigu) -> We do this next.
const currentCourseSession = useCurrentCourseSession();
const isVVCourse = computed(() => {
return VV_COURSE_IDS.includes(currentCourseSession.value.course.id);
});
onMounted(async () => {
log.debug("CompetenceParentPage mounted", props.courseSlug);
});
@ -45,6 +54,7 @@ onMounted(async () => {
</router-link>
</li>
<li
v-if="!isVVCourse"
class="border-t-2 border-t-transparent lg:ml-12"
:class="{ 'border-b-2 border-b-blue-900': routeInCompetenceCertificate() }"
>
@ -57,16 +67,21 @@ onMounted(async () => {
</li>
<li
class="border-t-2 border-t-transparent lg:ml-12"
:class="{ 'border-b-2 border-b-blue-900': routeInPerformanceCriteria() }"
:class="{
'border-b-2 border-b-blue-900': routeInSelfEvaluationAndFeedback(),
}"
>
<router-link
:to="`/course/${courseSlug}/competence/criteria`"
:to="`/course/${courseSlug}/competence/self-evaluation-and-feedback`"
class="block py-3"
>
{{ $t("a.Selbsteinschätzungen") }}
{{
isVVCourse
? $t("a.Selbst- und Fremdeinschätzungen")
: $t("a.Selbsteinschätzungen")
}}
</router-link>
</li>
<li
class="border-t-2 border-t-transparent lg:ml-12"
:class="{ 'border-b-2 border-b-blue-900': routeInActionCompetences() }"

View File

@ -1,115 +0,0 @@
<script setup lang="ts">
import * as log from "loglevel";
import { computed } from "vue";
import _ from "lodash";
import { useCourseDataWithCompletion } from "@/composables";
const props = defineProps<{
courseSlug: string;
}>();
log.debug("PerformanceCriteriaPage created", props);
const courseCompletionData = useCourseDataWithCompletion(props.courseSlug);
const uniqueLearningUnits = computed(() => {
// FIXME: this complex calculation can go away,
// once the criteria are in its own learning content
// get the learningUnits sorted by circle order in the course
const circles = (courseCompletionData.circles.value ?? []).map((c, index) => {
return { ...c, sortKey: index };
});
return _.orderBy(
_.uniqBy(
(courseCompletionData.flatPerformanceCriteria.value ?? [])
.filter((pc) => Boolean(pc.learning_unit))
.map((pc) => {
return {
luId: pc.learning_unit?.id,
luTitle: pc.learning_unit?.title,
luSlug: pc.learning_unit?.slug,
circleId: pc.circle.id,
circleTitle: pc.circle.title,
url: pc.learning_unit?.evaluate_url,
sortKey: circles.find((c) => c.id === pc.circle.id)?.sortKey,
};
}),
"luId"
),
"sortKey"
);
});
const criteriaByLearningUnit = computed(() => {
return uniqueLearningUnits.value.map((lu) => {
const criteria = (courseCompletionData.flatPerformanceCriteria.value ?? []).filter(
(pc) => pc.learning_unit?.id === lu.luId
);
return {
...lu,
countSuccess: criteria.filter((c) => c.completion_status === "SUCCESS").length,
countFail: criteria.filter((c) => c.completion_status === "FAIL").length,
countUnknown: criteria.filter((c) => c.completion_status === "UNKNOWN").length,
criteria: criteria,
};
});
});
</script>
<template>
<div class="container-large">
<h2 class="mb-4 lg:py-4">{{ $t("a.Selbsteinschätzungen") }}</h2>
<section class="mb-4 bg-white px-4 py-2">
<div
v-for="selfEvaluation in criteriaByLearningUnit"
:key="selfEvaluation.luId"
class="flex flex-col justify-between gap-4 border-b py-4 last:border-b-0 lg:flex-row lg:items-center"
>
<div class="lg:w-1/3">
{{ $t("a.Circle") }}
{{ selfEvaluation.circleTitle }}:
{{ selfEvaluation.luTitle }}
</div>
<div class="flex flex-row items-center lg:w-1/3">
<div class="mr-6 flex flex-row items-center">
<it-icon-smiley-thinking
class="mr-2 inline-block h-8 w-8"
></it-icon-smiley-thinking>
<div class="w-6" :data-cy="`${selfEvaluation.luSlug}-fail`">
{{ selfEvaluation.countFail }}
</div>
</div>
<li class="mr-6 flex flex-row items-center">
<it-icon-smiley-happy
class="mr-2 inline-block h-8 w-8"
></it-icon-smiley-happy>
<div class="w-6" :data-cy="`${selfEvaluation.luSlug}-success`">
{{ selfEvaluation.countSuccess }}
</div>
</li>
<li class="flex flex-row items-center">
<it-icon-smiley-neutral
class="mr-2 inline-block h-8 w-8"
></it-icon-smiley-neutral>
<div class="w-6" :data-cy="`${selfEvaluation.luSlug}-unknown`">
{{ selfEvaluation.countUnknown }}
</div>
</li>
</div>
<div>
<router-link
:to="selfEvaluation.url ?? '/'"
class="link"
:data-cy="`${selfEvaluation.luSlug}-open`"
>
{{ $t("a.Selbsteinschätzung anschauen") }}
</router-link>
</div>
</div>
</section>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,69 @@
<script setup lang="ts">
import { useSelfEvaluationFeedbackSummaries } from "@/services/selfEvaluationFeedback";
import { useCurrentCourseSession } from "@/composables";
import { computed, ref } from "vue";
import FeedbackByLearningUnitSummary from "@/components/selfEvaluationFeedback/FeedbackByLearningUnitSummary.vue";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import { t } from "i18next";
const selfEvaluationFeedbackSummaries = useSelfEvaluationFeedbackSummaries(
useCurrentCourseSession().value.id
);
const isLoaded = computed(() => !selfEvaluationFeedbackSummaries.loading.value);
const selectedCircle = ref({ name: t("a.AlleCircle"), id: "_all" });
const circles = computed(() => [
{ name: t("a.AlleCircle"), id: "_all" },
...selfEvaluationFeedbackSummaries.circles.value.map((circle) => ({
name: `Circle: ${circle.title}`,
id: circle.id,
})),
]);
const summaries = computed(() => {
if (selectedCircle.value.id === "_all") {
return selfEvaluationFeedbackSummaries.summaries.value;
}
return selfEvaluationFeedbackSummaries.summaries.value.filter(
(summary) => summary.circle_id === selectedCircle.value.id
);
});
const headerTitle = computed(() => {
const canHaveFeedback =
selfEvaluationFeedbackSummaries.aggregates.value?.feedback_assessment_visible ??
false;
if (canHaveFeedback) {
return t("a.Selbst- und Fremdeinschätzungen");
} else {
return t("a.Selbsteinschätzungen");
}
});
</script>
<template>
<div v-if="isLoaded">
<div class="container-large">
<div class="col flex items-center justify-between pb-4">
<h2 class="py-4">{{ headerTitle }}</h2>
<ItDropdownSelect
v-model="selectedCircle"
class="text-bold w-24 min-w-[18rem] border-2 border-gray-300"
:items="circles"
borderless
></ItDropdownSelect>
</div>
<div class="space-y-3">
<FeedbackByLearningUnitSummary
v-for="summary in summaries"
:key="summary.id"
:summary="summary"
/>
</div>
</div>
</div>
</template>
<style scoped></style>

View File

@ -45,7 +45,7 @@ const competenceCertificateUrl = computed(() => {
});
const competenceCriteriaUrl = computed(() => {
return `/course/${courseSlug.value}/competence/criteria?courseSessionId=${courseSessionProgress.value?.session_to_continue_id}`;
return `/course/${courseSlug.value}/competence/self-evaluation-and-feedback?courseSessionId=${courseSessionProgress.value?.session_to_continue_id}`;
});
const isVVCourse = computed(() => {

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

@ -92,7 +92,6 @@ const router = createRouter({
props: true,
component: () => import("@/pages/competence/CompetenceIndexPage.vue"),
},
{
path: "certificates",
props: true,
@ -106,9 +105,10 @@ const router = createRouter({
import("@/pages/competence/CompetenceCertificateDetailPage.vue"),
},
{
path: "criteria",
path: "self-evaluation-and-feedback",
props: true,
component: () => import("@/pages/competence/PerformanceCriteriaPage.vue"),
component: () =>
import("@/pages/competence/SelfEvaluationAndFeedbackPage.vue"),
},
{
path: "competences",
@ -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,268 @@
import { useCSRFFetch } from "@/fetchHelpers";
import type { User } from "@/types";
import { toValue } from "@vueuse/core";
import { t } from "i18next";
import log from "loglevel";
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";
}
interface FeedbackSummaryCounts {
pass: number;
fail: number;
unknown: number;
}
export interface FeedbackSummaryAggregates {
// totals across all learning units in the course session
self_assessment: FeedbackSummaryCounts;
feedback_assessment: FeedbackSummaryCounts;
// does this course have any feedback?
feedback_assessment_visible: boolean;
}
interface FeedbackAssessmentSummary {
counts: FeedbackSummaryCounts;
submitted_by_provider: boolean;
provider_user: User;
}
interface SelfAssessmentSummary {
counts: FeedbackSummaryCounts;
}
export interface LearningUnitSummary {
id: string;
title: string;
circle_id: string;
circle_title: string;
feedback_assessment?: FeedbackAssessmentSummary;
self_assessment: SelfAssessmentSummary;
detail_url: string;
}
interface Circle {
id: number;
title: string;
}
/** 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;
log.info("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 function useSelfEvaluationFeedbackSummaries(
courseSessionId: Ref<string> | string
) {
const summaries = ref<LearningUnitSummary[]>([]);
const aggregates = ref<FeedbackSummaryAggregates>();
const circles = ref<Circle[]>([]);
const loading = ref(false);
const error = ref();
const url = computed(
() =>
`/api/self-evaluation-feedback/requester/${courseSessionId}/feedbacks/summaries`
);
const fetchFeedbackSummaries = async () => {
error.value = undefined;
loading.value = true;
log.info("Fetching feedback summaries for course session", courseSessionId);
const { data, error: _error } = await useCSRFFetch(url.value).json();
loading.value = false;
if (_error.value) {
error.value = _error;
summaries.value = [];
circles.value = [];
aggregates.value = undefined;
return;
}
summaries.value = data.value.results;
aggregates.value = data.value.aggregates;
circles.value = data.value.circles;
};
onMounted(fetchFeedbackSummaries);
return {
summaries,
aggregates,
circles,
loading,
error,
};
}
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

@ -1,4 +1,4 @@
import { login } from "../helpers";
import {login} from "../helpers";
describe("selfEvaluation.cy.js", () => {
beforeEach(() => {
@ -28,37 +28,28 @@ describe("selfEvaluation.cy.js", () => {
cy.get('[data-cy="self-evaluation-success"]').should("have.text", "0");
cy.get('[data-cy="self-evaluation-unknown"]').should("have.text", "4");
// learning unit id = 687 also known as:
// Bedarfsanalyse, Ist- und Soll-Situation <<Reisen>>
const identifier = "self-eval-687"
// data in KompetenzNavi/Selbsteinschätzungen is correct
cy.visit("/course/test-lehrgang/competence/criteria");
cy.get('[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-fail"]').should(
"have.text",
"0"
);
cy.get(
'[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-success"]'
).should("have.text", "0");
cy.get(
'[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-unknown"]'
).should("have.text", "2");
cy.visit("/course/test-lehrgang/competence/self-evaluation-and-feedback");
cy.get(`[data-cy="${identifier}-fail"]`).should("not.exist");
cy.get(`[data-cy="${identifier}-pass"]`).should("not.exist");
cy.get(`[data-cy="${identifier}-unknown"]`).should("have.text", "2");
// it can open self evaluation from within KompetenzNavi
cy.get('[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-open"]').click();
cy.get(`[data-cy="${identifier}-detail-url"]`).click();
// starting the self evaluation will return to KompetenzNavi
cy.makeSelfEvaluation([true, false]);
cy.url().should("include", "/course/test-lehrgang/competence/criteria");
cy.url().should("include", "/course/test-lehrgang/competence/self-evaluation-and-feedback");
// check data again on KompetenzNavi
cy.get('[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-fail"]').should(
"have.text",
"1"
);
cy.get(
'[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-success"]'
).should("have.text", "1");
cy.get(
'[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-unknown"]'
).should("have.text", "0");
cy.get(`[data-cy="${identifier}-fail"]`).should("have.text", "1");
cy.get(`[data-cy="${identifier}-pass"]`).should("have.text", "1");
cy.get(`[data-cy="${identifier}-unknown"]`).should("not.exist");
// data in KompetenzNavi/Übersicht is correct
cy.visit("/course/test-lehrgang/competence");
@ -76,19 +67,6 @@ describe("selfEvaluation.cy.js", () => {
// starting the self evaluation from circle should return to circle
cy.url().should("include", "/course/test-lehrgang/learn/reisen");
// data in KompetenzNavi / Selbsteinschätzungen is correct
cy.visit("/course/test-lehrgang/competence/criteria");
cy.get('[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-fail"]').should(
"have.text",
"0"
);
cy.get(
'[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-success"]'
).should("have.text", "2");
cy.get(
'[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-unknown"]'
).should("have.text", "0");
});
it("should be able to make a fail self evaluation", () => {
@ -97,19 +75,6 @@ describe("selfEvaluation.cy.js", () => {
cy.get('[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen"]')
.find('[data-cy="fail"]')
.should("exist");
// data in KompetenzNavi / Selbsteinschätzungen is correct
cy.visit("/course/test-lehrgang/competence/criteria");
cy.get('[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-fail"]').should(
"have.text",
"2"
);
cy.get(
'[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-success"]'
).should("have.text", "0");
cy.get(
'[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-unknown"]'
).should("have.text", "0");
});
it("should be able to make a mixed self evaluation", () => {
@ -118,18 +83,5 @@ describe("selfEvaluation.cy.js", () => {
cy.get('[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen"]')
.find('[data-cy="fail"]')
.should("exist");
// data in KompetenzNavi / Selbsteinschätzungen is correct
cy.visit("/course/test-lehrgang/competence/criteria");
cy.get('[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-fail"]').should(
"have.text",
"1"
);
cy.get(
'[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-success"]'
).should("have.text", "1");
cy.get(
'[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-unknown"]'
).should("have.text", "0");
});
});

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

@ -25,6 +25,7 @@ from vbv_lernwelt.core.views import (
check_rate_limit,
cypress_reset_view,
generate_web_component_icons,
iterativ_test_coursesessions_reset_view,
permission_denied_view,
rate_limit_exceeded_view,
vue_home,
@ -141,6 +142,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/",
@ -206,6 +210,13 @@ urlpatterns = [
name="t2l_sync",
),
# iterativ Test course sessions
path(
r"api/core/resetiterativsessions/",
iterativ_test_coursesessions_reset_view,
name="iterativ_test_coursesessions_reset_view",
),
path("server/graphql/",
csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema))),
# testing and debug

View File

@ -3,7 +3,7 @@ from rest_framework import status
from rest_framework.test import APITestCase
from vbv_lernwelt.core.model_utils import add_countries, add_organisations
from vbv_lernwelt.core.models import Organisation, User
from vbv_lernwelt.core.models import Country, Organisation, User
class EntitiesViewTest(APITestCase):
@ -15,7 +15,7 @@ class EntitiesViewTest(APITestCase):
add_organisations()
add_countries()
def test_list_entities(self) -> None:
def test_list_organisation_entities(self) -> None:
# It seems that different locales handle ordering differently (especially with lower case letters)
# As such we delete entries that start with lower case letters
Organisation.objects.filter(organisation_id__in=[1, 2, 3]).delete()
@ -51,3 +51,49 @@ class EntitiesViewTest(APITestCase):
"name": "Afghanistan",
},
)
def test_list_country_entities_ordered_by_country_id(self) -> None:
# GIVEN
url = reverse("list_entities")
first_country = Country.objects.get(country_id=1)
# WHEN
response = self.client.get(url)
# THEN
self.assertEqual(response.status_code, status.HTTP_200_OK)
countries = response.data["countries"]
self.assertEqual(
countries[0],
{
"id": first_country.country_id,
"name": first_country.name_de,
},
)
def test_list_country_entities_ordered_by_order_id(self) -> None:
# GIVEN
url = reverse("list_entities")
switzerland = Country.objects.get(name_de="Schweiz")
switzerland.order_id = 1
switzerland.save()
# WHEN
response = self.client.get(url)
# THEN
self.assertEqual(response.status_code, status.HTTP_200_OK)
countries = response.data["countries"]
self.assertEqual(
countries[0],
{
"id": switzerland.country_id,
"name": switzerland.name_de,
},
)

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

@ -122,6 +122,7 @@ class OrganisationAdmin(admin.ModelAdmin):
@admin.register(Country)
class CountryAdmin(admin.ModelAdmin):
list_display = (
"order_id",
"country_id",
"name_de",
"name_fr",

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

@ -9,12 +9,16 @@ from vbv_lernwelt.core.constants import (
TEST_COURSE_SESSION_BERN_ID,
TEST_MENTOR1_USER_ID,
TEST_STUDENT1_USER_ID,
TEST_STUDENT1_VV_USER_ID,
TEST_STUDENT2_USER_ID,
TEST_STUDENT3_USER_ID,
TEST_TRAINER1_USER_ID,
)
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.consts import COURSE_TEST_ID
from vbv_lernwelt.core.models import Organisation, User
from vbv_lernwelt.course.consts import (
COURSE_TEST_ID,
COURSE_VERSICHERUNGSVERMITTLERIN_ID,
)
from vbv_lernwelt.course.creators.test_course import (
create_edoniq_test_result_data,
create_feedback_response_data,
@ -39,6 +43,10 @@ from vbv_lernwelt.learnpath.models import (
LearningContentFeedbackVV,
)
from vbv_lernwelt.notify.models import Notification
from vbv_lernwelt.self_evaluation_feedback.models import (
CourseCompletionFeedback,
SelfEvaluationFeedback,
)
@click.command()
@ -107,7 +115,11 @@ def command(
FeedbackResponse.objects.all().delete()
CourseSessionAttendanceCourse.objects.all().update(attendance_user_list=[])
SelfEvaluationFeedback.objects.all().delete()
CourseCompletionFeedback.objects.all().delete()
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 +343,30 @@ 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
),
)
)
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
)
)
mentor.participants.add(csu)
course = Course.objects.get(id=COURSE_TEST_ID)
course.enable_circle_documents = enable_circle_documents

View File

@ -0,0 +1,336 @@
from datetime import datetime, time, timedelta
import djclick as click
import structlog
from django.utils import timezone
from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletion
from vbv_lernwelt.core.admin import User
from vbv_lernwelt.course.consts import (
COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID,
COURSE_VERSICHERUNGSVERMITTLERIN_ID,
COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID,
)
from vbv_lernwelt.course.models import (
Course,
CourseCompletion,
CourseSession,
CourseSessionUser,
)
from vbv_lernwelt.course_session.models import (
CourseSessionAssignment,
CourseSessionAttendanceCourse,
CourseSessionEdoniqTest,
)
from vbv_lernwelt.course_session_group.models import CourseSessionGroup
from vbv_lernwelt.feedback.models import FeedbackResponse
from vbv_lernwelt.learning_mentor.models import LearningMentor
from vbv_lernwelt.learnpath.models import Circle
from vbv_lernwelt.notify.models import Notification
logger = structlog.get_logger(__name__)
from vbv_lernwelt.importer.services import (
create_or_update_course_session,
get_uk_course,
LP_DATA,
TRANSLATIONS,
)
IT_VV_TEST_COURSE = "Iterativ VV Testkurs"
IT_UK_TEST_COURSE = "Iterativ üK Testkurs"
IT_UK_TEST_REGION = "Iterativ Region"
TIME_FORMAT = "%d.%m.%Y, %H:%M"
PASSWORD = "KqaDm3-x8zhCKHLWDV_oiqFrYWHg"
logger = structlog.get_logger(__name__)
@click.command()
def command():
create_or_update_uk()
create_or_update_vv()
def create_or_update_uk(language="de"):
uk_course = get_uk_course(language)
uk_circle_keys = [
"Kickoff",
"Basis",
"Fahrzeug",
"Haushalt Teil 1",
"Haushalt Teil 2",
]
data = create_uk_data(language)
create_or_update_course_session(
uk_course,
data,
language,
circle_keys=uk_circle_keys,
)
cs = CourseSession.objects.get(import_id=data["ID"])
members, trainer, regionenleiter = get_or_create_users_uk()
delete_cs_data(cs, members + [trainer, regionenleiter])
add_to_course_session(cs, members)
add_trainers_to_course_session(cs, [trainer], uk_circle_keys, language)
create_and_add_to_cs_group(cs.course, IT_UK_TEST_REGION, [cs], regionenleiter)
def create_or_update_vv(language="de"):
vv_course = get_vv_course(language)
cs, _created = CourseSession.objects.get_or_create(
course=vv_course, import_id=IT_VV_TEST_COURSE
)
cs.title = IT_VV_TEST_COURSE
cs.save()
create_or_update_assignment_course_session(cs)
members, member_with_mentor, mentor = get_or_create_users_vv()
delete_cs_data(cs, members + [member_with_mentor, mentor])
add_to_course_session(cs, members + [member_with_mentor])
add_mentor_to_course_session(cs, [(mentor, member_with_mentor)])
def delete_cs_data(cs: CourseSession, users: list[User]):
if cs:
CourseCompletion.objects.filter(course_session=cs).delete()
Notification.objects.filter(course_session=cs).delete()
AssignmentCompletion.objects.filter(course_session=cs).delete()
CourseSessionAttendanceCourse.objects.filter(course_session=cs).update(
attendance_user_list=[]
)
CourseSessionEdoniqTest.objects.filter(course_session=cs).delete()
CourseSessionUser.objects.filter(course_session=cs).delete()
learning_mentor_ids = (
LearningMentor.objects.filter(participants__course_session=cs)
.values_list("id", flat=True)
.distinct()
| LearningMentor.objects.filter(mentor__in=users)
.values_list("id", flat=True)
.distinct()
)
# cannot call delete on distinct objects
LearningMentor.objects.filter(id__in=list(learning_mentor_ids)).delete()
else:
logger.info("no_course_session_found", import_id=cs.import_id)
FeedbackResponse.objects.filter(feedback_user__in=users).delete()
def add_to_course_session(
course_session: CourseSession,
members: list[User],
role=CourseSessionUser.Role.MEMBER,
):
if course_session:
for user in members:
csu, _created = CourseSessionUser.objects.get_or_create(
course_session_id=course_session.id, user_id=user.id, role=role
)
csu.save()
def add_mentor_to_course_session(
course_session: CourseSession, mentor_mentee_pairs: list[tuple[User, User]]
):
for mentor, mentee in mentor_mentee_pairs:
lm = LearningMentor.objects.create(
course=course_session.course,
mentor=mentor,
)
lm.participants.add(
CourseSessionUser.objects.get(
user__id=mentee.id,
course_session=course_session,
)
)
def add_trainers_to_course_session(
course_session: CourseSession,
trainers: list[User],
circle_keys: list[str],
language,
):
add_to_course_session(course_session, trainers, CourseSessionUser.Role.EXPERT)
for user in trainers:
for circle_key in circle_keys:
circle_name = LP_DATA[circle_key][language]["title"]
circle = Circle.objects.filter(
slug=f"{course_session.course.slug}-lp-circle-{circle_name.lower()}"
).first()
if course_session and circle:
csu = CourseSessionUser.objects.filter(
course_session_id=course_session.id, user_id=user.id
).first()
if csu:
csu.expert.add(circle)
csu.save()
def get_or_create_users_uk():
members = [
_create_or_update_user(
f"teilnehmer{n}.uk@iterativ.ch", "Teilnehmer üK", "Iterativ", PASSWORD, "de"
)
for n in range(1, 10)
]
trainer = _create_or_update_user(
"trainer1.uk@iterativ.ch", "Trainer üK", "Iterativ", PASSWORD, "de"
)
regionenleiter = _create_or_update_user(
"regionenleiter1.uk@iterativ.ch",
"Regionenleiter üK",
"Iterativ",
PASSWORD,
"de",
)
return (
members,
trainer,
regionenleiter,
)
def get_or_create_users_vv():
members = [
_create_or_update_user(
f"teilnehmer{n}.vv@iterativ.ch", "Teilnehmer VV", "Iterativ", PASSWORD, "de"
)
for n in range(1, 10)
]
member_with_mentor = _create_or_update_user(
"teilnehmer1.vv.lb@iterativ.ch",
"Teilnehmer VV mit LB",
"Iterativ",
PASSWORD,
"de",
)
mentor = _create_or_update_user(
"lernbegleitung1.vv@iterativ.ch",
"Lernbegleitung VV",
"Iterativ",
PASSWORD,
"de",
)
return members, member_with_mentor, mentor
def _create_or_update_user(email, first_name, last_name, password, language):
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
user = User(
email=email,
username=email,
)
user.email = email
user.first_name = first_name or user.first_name
user.last_name = last_name or user.last_name
user.username = email
user.language = language
user.set_password(password)
user.save()
return user
def create_uk_data(language):
return {
"Klasse": IT_UK_TEST_COURSE,
"ID": IT_UK_TEST_COURSE,
"Generation": 2024,
"Region": "Bern",
"Sprache": language,
f"Kickoff {TRANSLATIONS[language]['start']}": timezone.make_aware(
datetime.combine((timezone.now() + timedelta(weeks=2)).date(), time(9, 0))
).strftime("%d.%m.%Y, %H:%M"),
f"Kickoff {TRANSLATIONS[language]['ende']}": timezone.make_aware(
datetime.combine((timezone.now() + timedelta(weeks=2)).date(), time(16, 0))
).strftime("%d.%m.%Y, %H:%M"),
f"Kickoff {TRANSLATIONS[language]['raum']}": "Raum 1",
f"Kickoff {TRANSLATIONS[language]['standort']}": "Bern",
f"Kickoff {TRANSLATIONS[language]['adresse']}": "Musterstrasse 1",
f"Basis {TRANSLATIONS[language]['start']}": timezone.make_aware(
datetime.combine((timezone.now() + timedelta(weeks=4)).date(), time(9, 0))
).strftime("%d.%m.%Y, %H:%M"),
f"Basis {TRANSLATIONS[language]['ende']}": timezone.make_aware(
datetime.combine((timezone.now() + timedelta(weeks=4)).date(), time(16, 0))
).strftime("%d.%m.%Y, %H:%M"),
f"Basis {TRANSLATIONS[language]['raum']}": "Raum 1",
f"Basis {TRANSLATIONS[language]['standort']}": "Bern",
f"Basis {TRANSLATIONS[language]['adresse']}": "Musterstrasse 1",
f"Fahrzeug {TRANSLATIONS[language]['start']}": timezone.make_aware(
datetime.combine((timezone.now() + timedelta(weeks=6)).date(), time(9, 0))
).strftime("%d.%m.%Y, %H:%M"),
f"Fahrzeug {TRANSLATIONS[language]['ende']}": timezone.make_aware(
datetime.combine((timezone.now() + timedelta(weeks=6)).date(), time(16, 0))
).strftime("%d.%m.%Y, %H:%M"),
f"Fahrzeug {TRANSLATIONS[language]['raum']}": "Raum 1",
f"Fahrzeug {TRANSLATIONS[language]['standort']}": "Bern",
f"Fahrzeug {TRANSLATIONS[language]['adresse']}": "Musterstrasse 1",
f"Haushalt Teil 1 {TRANSLATIONS[language]['start']}": timezone.make_aware(
datetime.combine((timezone.now() + timedelta(weeks=8)).date(), time(9, 0))
).strftime("%d.%m.%Y, %H:%M"),
f"Haushalt Teil 1 {TRANSLATIONS[language]['ende']}": timezone.make_aware(
datetime.combine((timezone.now() + timedelta(weeks=8)).date(), time(16, 0))
).strftime("%d.%m.%Y, %H:%M"),
f"Haushalt Teil 1 {TRANSLATIONS[language]['raum']}": "Raum 1",
f"Haushalt Teil 1 {TRANSLATIONS[language]['standort']}": "Bern",
f"Haushalt Teil 1 {TRANSLATIONS[language]['adresse']}": "Musterstrasse 1",
f"Haushalt Teil 2 {TRANSLATIONS[language]['start']}": timezone.make_aware(
datetime.combine((timezone.now() + timedelta(weeks=10)).date(), time(9, 0))
).strftime("%d.%m.%Y, %H:%M"),
f"Haushalt Teil 2 {TRANSLATIONS[language]['ende']}": timezone.make_aware(
datetime.combine((timezone.now() + timedelta(weeks=10)).date(), time(16, 0))
).strftime("%d.%m.%Y, %H:%M"),
f"Haushalt Teil 2 {TRANSLATIONS[language]['raum']}": "Raum 1",
f"Haushalt Teil 2 {TRANSLATIONS[language]['standort']}": "Bern",
f"Haushalt Teil 2 {TRANSLATIONS[language]['adresse']}": "Musterstrasse 1",
}
def create_and_add_to_cs_group(
course: Course, name: str, course_sessions: list[CourseSession], supervisor: User
):
region, _ = CourseSessionGroup.objects.get_or_create(
name=name,
course=course,
)
for cs in course_sessions:
region.course_session.add(cs)
region.supervisor.add(supervisor)
def get_vv_course(language: str) -> Course:
if language == "fr":
course_id = COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID
elif language == "it":
course_id = COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID
else:
course_id = COURSE_VERSICHERUNGSVERMITTLERIN_ID
return Course.objects.get(id=course_id)
def create_or_update_assignment_course_session(cs: CourseSession):
# not nice but works for now
for assignment in Assignment.objects.all():
if assignment.get_course().id == cs.course.id:
logger.debug(
"create_course_session_assigments",
assignment=assignment,
label="reset_test_courses",
)
for lca in assignment.learningcontentassignment_set.all():
_csa, _created = CourseSessionAssignment.objects.get_or_create(
course_session=cs,
learning_content=lca,
)

View File

@ -0,0 +1,25 @@
# Generated by Django 3.2.20 on 2024-02-20 09:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0006_auto_20240125_0915"),
]
operations = [
migrations.AlterModelOptions(
name="country",
options={
"ordering": ["order_id", "country_id"],
"verbose_name": "Country",
"verbose_name_plural": "Countries",
},
),
migrations.AddField(
model_name="country",
name="order_id",
field=models.FloatField(default=20),
),
]

View File

@ -26,6 +26,7 @@ class Country(models.Model):
name_de = models.CharField(max_length=255)
name_fr = models.CharField(max_length=255)
name_it = models.CharField(max_length=255)
order_id = models.FloatField(default=20)
def __str__(self):
return f"{self.name_de} ({self.country_id})"
@ -33,7 +34,7 @@ class Country(models.Model):
class Meta:
verbose_name = "Country"
verbose_name_plural = "Countries"
ordering = ["country_id"]
ordering = ["order_id", "country_id"]
class User(AbstractUser):

View File

@ -173,6 +173,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,
@ -181,6 +185,17 @@ def cypress_reset_view(request):
return HttpResponseRedirect("/server/admin/")
@api_view(["POST"])
@authentication_classes((authentication.SessionAuthentication,))
@permission_classes((IsAdminUser,))
def iterativ_test_coursesessions_reset_view(request):
call_command(
"reset_iterativ_test_sessions",
)
return HttpResponseRedirect("/server/admin/")
@django_view_authentication_exempt
def generate_web_component_icons(request):
svg_files = []

View File

@ -46,6 +46,8 @@ from vbv_lernwelt.learnpath.models import (
LearningContentAssignment,
LearningContentEdoniqTest,
LearningPath,
LearningUnit,
LearningUnitPerformanceFeedbackType,
)
from vbv_lernwelt.learnpath.tests.learning_path_factories import (
CircleFactory,
@ -58,11 +60,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 +272,30 @@ def create_course_session_edoniq_test(
return cset
def create_learning_unit(
circle: Circle,
course: Course,
course_category_title: str = "Course Category",
feedback_user: LearningUnitPerformanceFeedbackType = LearningUnitPerformanceFeedbackType.NO_FEEDBACK,
) -> LearningUnit:
cat, _ = CourseCategory.objects.get_or_create(
course=course,
title=course_category_title,
)
return LearningUnitFactory(
title="Learning Unit",
parent=circle,
course_category=cat,
feedback_user=feedback_user.value,
)
def create_performance_criteria_page(
course: Course,
course_page: CoursePage,
circle: Circle,
learning_unit: LearningUnitFactory | LearningUnit | None = None,
) -> PerformanceCriteria:
competence_navi_page = CompetenceNaviPageFactory(
title="Competence Navi",
@ -290,17 +314,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

@ -209,10 +209,16 @@ def can_view_course_completions(
)
def can_complete_learning_content(user: User, course_session_id: int) -> bool:
return is_course_session_member(
user, course_session_id
) or is_course_session_expert(user, course_session_id)
def course_session_permissions(user: User, course_session_id: int) -> list[str]:
return _action_list(
{
"complete-learning-content": is_course_session_member(
"complete-learning-content": can_complete_learning_content(
user, course_session_id
),
}

View File

@ -33,12 +33,21 @@ class ActionTestCase(TestCase):
role=CourseSessionUser.Role.MEMBER,
)
trainer = create_user("trainer")
add_course_session_user(
self.course_session,
trainer,
role=CourseSessionUser.Role.EXPERT,
)
# WHEN
mentor_actions = course_session_permissions(lm, self.course_session.id)
participant_actions = course_session_permissions(
participant, self.course_session.id
)
trainer_actions = course_session_permissions(trainer, self.course_session.id)
# THEN
self.assertEqual(len(mentor_actions), 0)
self.assertEqual(participant_actions, ["complete-learning-content"])
self.assertEqual(trainer_actions, ["complete-learning-content"])

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

@ -0,0 +1,22 @@
# Generated by Django 3.2.20 on 2024-02-12 09:25
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("learning_mentor", "0004_alter_mentorinvitation_unique_together"),
]
operations = [
migrations.AlterField(
model_name="learningmentor",
name="mentor",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
]

View File

@ -8,7 +8,7 @@ from vbv_lernwelt.course.models import CourseSessionUser
class LearningMentor(models.Model):
mentor = models.OneToOneField(User, on_delete=models.CASCADE)
mentor = models.ForeignKey(User, on_delete=models.CASCADE)
course = models.ForeignKey("course.Course", on_delete=models.CASCADE)
participants = models.ManyToManyField(

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,
)
@ -232,3 +330,15 @@ class LearningMentorAPITest(APITestCase):
self.assertFalse(
LearningMentor.objects.filter(participants=participant_cs_user).exists()
)
def test_mentor_multiple_courses(self) -> None:
# GIVEN
course_a, _ = create_course("Course A")
course_b, _ = create_course("Course B")
# WHEN
LearningMentor.objects.create(mentor=self.mentor, course=course_a)
LearningMentor.objects.create(mentor=self.mentor, course=course_b)
# THEN
self.assertEqual(LearningMentor.objects.count(), 2)

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,13 +48,13 @@ 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)
TopicFactory(title="Beraten und Betreuen von Kunden", parent=lp)
create_circle_fahrzeug(lp)
create_circle_fahrzeug(lp, course_page=course_page)
create_circle_haushalt(lp)
create_circle_rechtsstreitigkeiten(lp)
create_circle_reisen(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,
@ -303,7 +340,7 @@ def create_circle_gewinnen(lp, title="Gewinnen"):
)
def create_circle_fahrzeug(lp, title="Fahrzeug"):
def create_circle_fahrzeug(lp, title="Fahrzeug", course_page=None):
circle = CircleFactory(
title=title,
parent=lp,
@ -367,7 +404,14 @@ def create_circle_fahrzeug(lp, title="Fahrzeug"):
)
LearningSequenceFactory(title="Transfer", parent=circle, icon="it-icon-ls-end")
LearningUnitFactory(title="Transfer", title_hidden=True, parent=circle)
lu_transfer = LearningUnitFactory(
title="Transfer",
title_hidden=True,
parent=circle,
feedback_user=LearningUnitPerformanceFeedbackType.MENTOR_FEEDBACK.name,
)
LearningContentPlaceholderFactory(
title="Praxisauftrag",
parent=circle,
@ -392,6 +436,36 @@ def create_circle_fahrzeug(lp, title="Fahrzeug"):
parent=circle,
)
competence_profile_page = ActionCompetenceListPageFactory(
title="KompetenzNavi",
parent=course_page,
)
ace = ActionCompetenceFactory(
parent=competence_profile_page,
)
PerformanceCriteriaFactory(
parent=ace,
competence_id="VV-Transfer-A",
title="Ich setze das Gelernte in der Praxis um.",
learning_unit=lu_transfer,
)
PerformanceCriteriaFactory(
parent=ace,
competence_id="VV-Transfer-B",
title="Ich kenne den Unterschied zwischen einem Neuwagen und einem Occasionswagen.",
learning_unit=lu_transfer,
)
PerformanceCriteriaFactory(
parent=ace,
competence_id="VV-Transfer-C",
title="Ich kenne den Unterschied zwischen einem Leasing und einem Kauf.",
learning_unit=lu_transfer,
)
def create_circle_haushalt(lp, title="Haushalt"):
circle = CircleFactory(

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,672 @@
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.learnpath.models import LearningUnitPerformanceFeedbackType
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( # noqa
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.assertFalse(feedback["feedback_submitted"])
self.assertEqual(feedback["circle_name"], self.circle.title) # noqa
provider_user = feedback["feedback_provider_user"]
self.assertEqual(provider_user["id"], str(self.mentor.id))
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_feedbacks_with_mixed_completion_statuses(self):
"""Case: CourseCompletion AND feedbacks with mixed completion statuses"""
# GIVEN
learning_unit = create_learning_unit(
course=self.course,
circle=self.circle,
feedback_user=LearningUnitPerformanceFeedbackType.MENTOR_FEEDBACK,
)
feedback = create_self_evaluation_feedback(
learning_unit=learning_unit,
feedback_requester_user=self.member,
feedback_provider_user=self.mentor,
)
feedback.feedback_submitted = True
feedback.save()
for status in [
CourseCompletionStatus.SUCCESS,
CourseCompletionStatus.FAIL,
CourseCompletionStatus.UNKNOWN,
]:
criteria_page = create_performance_criteria_page(
course=self.course,
course_page=self.course_page,
circle=self.circle,
learning_unit=learning_unit,
)
# self assessment
completion = mark_course_completion(
page=criteria_page,
user=self.member,
course_session=self.course_session,
completion_status=status.value,
)
# feedback assessment
CourseCompletionFeedback.objects.create(
feedback=feedback,
course_completion=completion,
feedback_assessment=status.value,
)
self.client.force_login(self.member)
# WHEN
response = self.client.get(
reverse(
"get_self_evaluation_feedbacks_as_requester",
args=[self.course_session.id],
)
)
# THEN
self.assertEqual(response.status_code, 200)
result = response.data["results"][0]
self_assessment = result["self_assessment"]
self.assertEqual(self_assessment["counts"]["pass"], 1)
self.assertEqual(self_assessment["counts"]["fail"], 1)
self.assertEqual(self_assessment["counts"]["unknown"], 1)
feedback_assessment = result["feedback_assessment"]
self.assertEqual(feedback_assessment["counts"]["pass"], 1)
self.assertEqual(feedback_assessment["counts"]["fail"], 1)
self.assertEqual(feedback_assessment["counts"]["unknown"], 1)
self.assertTrue(feedback_assessment["submitted_by_provider"])
self.assertEqual(
feedback_assessment["provider_user"]["id"], str(self.mentor.id)
)
aggregate = response.data["aggregates"]
self.assertEqual(aggregate["self_assessment"]["pass"], 1)
self.assertEqual(aggregate["self_assessment"]["fail"], 1)
self.assertEqual(aggregate["self_assessment"]["unknown"], 1)
self.assertEqual(aggregate["feedback_assessment"]["pass"], 1)
self.assertEqual(aggregate["feedback_assessment"]["fail"], 1)
self.assertEqual(aggregate["feedback_assessment"]["unknown"], 1)
def test_no_feedbacks_but_with_completion_status(self):
"""Case: CourseCompletion but NO feedback"""
# GIVEN
learning_unit_with_success_feedback = create_learning_unit(
course=self.course,
circle=self.circle,
feedback_user=LearningUnitPerformanceFeedbackType.MENTOR_FEEDBACK,
)
performance_criteria_page = create_performance_criteria_page(
course=self.course,
course_page=self.course_page,
circle=self.circle,
learning_unit=learning_unit_with_success_feedback,
)
# IMPORTANT: CourseCompletion but NO feedback!
mark_course_completion(
page=performance_criteria_page,
user=self.member,
course_session=self.course_session,
completion_status=CourseCompletionStatus.SUCCESS.value,
)
self.client.force_login(self.member)
# WHEN
response = self.client.get(
reverse(
"get_self_evaluation_feedbacks_as_requester",
args=[self.course_session.id],
)
)
# THEN
self.assertEqual(response.status_code, 200)
result = response.data["results"][0]
counts = result["self_assessment"]["counts"]
self.assertEqual(counts["pass"], 1)
self.assertEqual(counts["fail"], 0)
self.assertEqual(counts["unknown"], 0)
def test_feedbacks_not_started(self):
"""Case: Learning unit with no completion status and no feedback"""
# GIVEN
learning_unit = create_learning_unit( # noqa
course=self.course,
circle=self.circle,
feedback_user=LearningUnitPerformanceFeedbackType.MENTOR_FEEDBACK,
)
create_performance_criteria_page(
course=self.course,
course_page=self.course_page,
circle=self.circle,
learning_unit=learning_unit,
)
self.client.force_login(self.member)
# WHEN
response = self.client.get(
reverse(
"get_self_evaluation_feedbacks_as_requester",
args=[self.course_session.id],
)
)
# THEN
self.assertEqual(response.status_code, 200)
result = response.data["results"][0]
self.assertEqual(result["self_assessment"]["counts"]["pass"], 0)
self.assertEqual(result["self_assessment"]["counts"]["fail"], 0)
self.assertEqual(result["self_assessment"]["counts"]["unknown"], 1)
def test_feedbacks_metadata(self):
# GIVEN
learning_unit = create_learning_unit( # noqa
course=self.course,
circle=self.circle,
feedback_user=LearningUnitPerformanceFeedbackType.MENTOR_FEEDBACK,
)
create_performance_criteria_page(
course=self.course,
course_page=self.course_page,
circle=self.circle,
learning_unit=learning_unit,
)
self.client.force_login(self.member)
# WHEN
response = self.client.get(
reverse(
"get_self_evaluation_feedbacks_as_requester",
args=[self.course_session.id],
)
)
# THEN
self.assertEqual(response.status_code, 200)
result = response.data["results"][0]
self.assertEqual(result["title"], learning_unit.title)
self.assertEqual(result["id"], learning_unit.id)
self.assertEqual(result["circle_id"], self.circle.id)
self.assertEqual(result["circle_title"], self.circle.title)
self.assertEqual(result["detail_url"], learning_unit.get_evaluate_url())
circles = response.data["circles"]
self.assertEqual(len(circles), 1)
self.assertEqual(circles[0]["id"], self.circle.id)
self.assertEqual(circles[0]["title"], self.circle.title)
def test_get_self_evaluation_feedback_as_provider(self):
"""Tests endpoint of feedback PROVIDER"""
# GIVEN
learning_unit = create_learning_unit( # noqa
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) # noqa
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
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,45 @@
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,
get_self_evaluation_feedbacks_as_requester,
release_provider_self_evaluation_feedback,
start_self_evaluation_feedback,
)
urlpatterns = [
# /requester/* URLs -> For the user who requests feedback
path(
"requester/<signed_int:course_session_id>/feedbacks/summaries",
get_self_evaluation_feedbacks_as_requester,
name="get_self_evaluation_feedbacks_as_requester",
),
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",
),
# /provider/* URLs -> For the user who is providing feedback
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,126 @@
from typing import NamedTuple
from django.db.models import Case, Count, IntegerField, Sum, Value, When
from django.db.models.functions import Coalesce
from vbv_lernwelt.core.admin import User
from vbv_lernwelt.course.models import CourseCompletion, CourseCompletionStatus
from vbv_lernwelt.learnpath.models import LearningUnit
from vbv_lernwelt.self_evaluation_feedback.models import (
CourseCompletionFeedback,
SelfEvaluationFeedback,
)
class AssessmentCounts(NamedTuple):
pass_count: int
fail_count: int
unknown_count: int
@property
def total_count(self):
return self.pass_count + self.fail_count + self.unknown_count
def get_self_evaluation_feedback_counts(
feedback: SelfEvaluationFeedback,
):
course_completion_feedback = CourseCompletionFeedback.objects.filter(
feedback=feedback
).aggregate(
pass_count=Coalesce(
Sum(
Case(
When(
feedback_assessment=CourseCompletionStatus.SUCCESS.value,
then=Value(1),
),
output_field=IntegerField(),
)
),
Value(0),
),
fail_count=Coalesce(
Sum(
Case(
When(
feedback_assessment=CourseCompletionStatus.FAIL.value,
then=Value(1),
),
output_field=IntegerField(),
)
),
Value(0),
),
unknown_count=Coalesce(
Sum(
Case(
When(
feedback_assessment=CourseCompletionStatus.UNKNOWN.value,
then=Value(1),
),
output_field=IntegerField(),
)
),
Value(0),
),
)
return AssessmentCounts(
pass_count=course_completion_feedback.get("pass_count", 0),
fail_count=course_completion_feedback.get("fail_count", 0),
unknown_count=course_completion_feedback.get("unknown_count", 0),
)
def get_self_assessment_counts(
learning_unit: LearningUnit, user: User
) -> AssessmentCounts:
performance_criteria = learning_unit.performancecriteria_set.all()
completion_counts = CourseCompletion.objects.filter(
page__in=performance_criteria, user=user
).aggregate(
pass_count=Count(
Case(
When(completion_status=CourseCompletionStatus.SUCCESS.value, then=1),
output_field=IntegerField(),
)
),
fail_count=Count(
Case(
When(completion_status=CourseCompletionStatus.FAIL.value, then=1),
output_field=IntegerField(),
)
),
unknown_count=Count(
Case(
When(completion_status=CourseCompletionStatus.UNKNOWN.value, then=1),
output_field=IntegerField(),
)
),
)
pass_count = completion_counts.get("pass_count", 0)
fail_count = completion_counts.get("fail_count", 0)
unknown_count = completion_counts.get("unknown_count", 0)
# not yet completed performance criteria are unknown
if pass_count + fail_count + unknown_count < performance_criteria.count():
unknown_count += performance_criteria.count() - (
pass_count + fail_count + unknown_count
)
return AssessmentCounts(
pass_count=pass_count,
fail_count=fail_count,
unknown_count=unknown_count,
)
def calculate_aggregate(counts: [AssessmentCounts]):
return AssessmentCounts(
pass_count=sum(x.pass_count for x in counts),
fail_count=sum(x.fail_count for x in counts),
unknown_count=sum(x.unknown_count for x in counts),
)

View File

@ -0,0 +1,258 @@
import structlog
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.core.serializers import UserSerializer
from vbv_lernwelt.course.models import CourseCompletion, CourseSession
from vbv_lernwelt.learning_mentor.models import LearningMentor
from vbv_lernwelt.learnpath.models import (
Circle,
LearningUnit,
LearningUnitPerformanceFeedbackType,
)
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,
)
from vbv_lernwelt.self_evaluation_feedback.utils import (
AssessmentCounts,
get_self_assessment_counts,
get_self_evaluation_feedback_counts,
)
logger = structlog.get_logger(__name__)
@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_feedbacks_as_requester(request, course_session_id: int):
course_session = get_object_or_404(CourseSession, id=course_session_id)
results = []
circle_ids = set()
all_self_assessment_counts = []
all_feedback_assessment_counts = []
for learning_unit in LearningUnit.objects.filter(
course_category__course=course_session.course,
):
# this is not a problem in real life, but in the test environment
# we have a lot of learning units without self assessment criteria
# -> just skip those learning units
if len(learning_unit.performancecriteria_set.all()) == 0:
continue
circle = learning_unit.get_parent().specific
circle_ids.add(circle.id)
feedback = SelfEvaluationFeedback.objects.filter(
learning_unit=learning_unit,
feedback_requester_user=request.user,
).first()
if not feedback:
# no feedback given yet
feedback_assessment = None
else:
# feedback given
feedback_counts = get_self_evaluation_feedback_counts(feedback)
all_feedback_assessment_counts.append(feedback_counts)
feedback_assessment = {
"submitted_by_provider": feedback.feedback_submitted,
"provider_user": UserSerializer(feedback.feedback_provider_user).data,
"counts": {
"pass": feedback_counts.pass_count,
"fail": feedback_counts.fail_count,
"unknown": feedback_counts.unknown_count,
},
}
self_assessment_counts = get_self_assessment_counts(learning_unit, request.user)
all_self_assessment_counts.append(self_assessment_counts)
results.append(
{
"id": learning_unit.id,
"title": learning_unit.title,
"detail_url": learning_unit.get_evaluate_url(),
"circle_id": circle.id,
"circle_title": circle.title,
"feedback_assessment": feedback_assessment,
"self_assessment": {
"counts": {
"pass": self_assessment_counts.pass_count,
"fail": self_assessment_counts.fail_count,
"unknown": self_assessment_counts.unknown_count,
}
},
}
)
self_assessment_counts_aggregate = AssessmentCounts(
pass_count=sum(x.pass_count for x in all_self_assessment_counts),
fail_count=sum(x.fail_count for x in all_self_assessment_counts),
unknown_count=sum(x.unknown_count for x in all_self_assessment_counts),
)
received_feedback_counts_aggregate = AssessmentCounts(
pass_count=sum(x.pass_count for x in all_feedback_assessment_counts),
fail_count=sum(x.fail_count for x in all_feedback_assessment_counts),
unknown_count=sum(x.unknown_count for x in all_feedback_assessment_counts),
)
# pad the feedback counts with unknowns for the
# learning units where we have no feedback yet
feedback_assessment_counts_aggregate = AssessmentCounts(
pass_count=received_feedback_counts_aggregate.pass_count,
fail_count=received_feedback_counts_aggregate.fail_count,
unknown_count=self_assessment_counts_aggregate.total_count
- received_feedback_counts_aggregate.total_count
+ received_feedback_counts_aggregate.unknown_count,
)
# check if there are any learning units with mentor feedback
feedback_assessment_visible = (
LearningUnit.objects.filter(
feedback_user=LearningUnitPerformanceFeedbackType.MENTOR_FEEDBACK.value,
course_category__course=course_session.course,
).count()
> 0
)
return Response(
{
"results": results,
"circles": list(
Circle.objects.filter(id__in=circle_ids).values("id", "title")
),
"aggregates": {
"feedback_assessment_visible": feedback_assessment_visible,
"feedback_assessment": {
"pass": feedback_assessment_counts_aggregate.pass_count,
"fail": feedback_assessment_counts_aggregate.fail_count,
"unknown": feedback_assessment_counts_aggregate.unknown_count,
},
"self_assessment": {
"pass": self_assessment_counts_aggregate.pass_count,
"fail": self_assessment_counts_aggregate.fail_count,
"unknown": self_assessment_counts_aggregate.unknown_count,
},
},
}
)
@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

@ -43,10 +43,24 @@
<a href="{% url 'edoniq_export_students_and_trainers' %}" class="btn btn-primary">Teilnehmer
und Trainer exportieren</a>
<hr style="margin: 24px 0">
<form action="/api/core/resetiterativsessions/" method="post">
{% csrf_token %}
<p>Zurücksetzen der Iterativ Testdurchführungen (üK: "Iterativ üK Testkurs", VV: "Iterativ VV Testkurs")</p>
<button class="btn">Iterativ Testdurchführungen zurücksetzen</button>
</form>
<hr style="margin: 24px 0">
<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