Merge branch 'develop' into feature/circle-page-unify

This commit is contained in:
Reto Aebersold 2024-03-03 22:47:11 +01:00
commit fff16da479
36 changed files with 1690 additions and 314 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

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

@ -37,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="{

View File

@ -1042,7 +1042,7 @@ export type TopicObjectType = CoursePageInterface & {
export type UserObjectType = {
__typename?: 'UserObjectType';
avatar_url: Scalars['String']['output'];
avatar_url?: Maybe<Scalars['String']['output']>;
email: Scalars['String']['output'];
first_name: Scalars['String']['output'];
id: Scalars['UUID']['output'];
@ -1139,7 +1139,7 @@ export type AssignmentCompletionQueryQueryVariables = Exact<{
export type AssignmentCompletionQueryQuery = { __typename?: 'Query', assignment?: { __typename?: 'AssignmentObjectType', assignment_type: AssignmentAssignmentAssignmentTypeChoices, needs_expert_evaluation: boolean, max_points?: number | null, content_type: string, effort_required: string, evaluation_description: string, evaluation_document_url: string, evaluation_tasks?: any | null, id: string, intro_text: string, performance_objectives?: any | null, slug: string, tasks?: any | null, title: string, translation_key: string, solution_sample?: { __typename?: 'ContentDocumentObjectType', id: string, url?: string | null } | null, competence_certificate?: (
{ __typename?: 'CompetenceCertificateObjectType' }
& { ' $fragmentRefs'?: { 'CoursePageFieldsCompetenceCertificateObjectTypeFragment': CoursePageFieldsCompetenceCertificateObjectTypeFragment } }
) | null } | null, assignment_completion?: { __typename?: 'AssignmentCompletionObjectType', id: string, completion_status: AssignmentAssignmentCompletionCompletionStatusChoices, submitted_at?: string | null, evaluation_submitted_at?: string | null, evaluation_points?: number | null, evaluation_max_points?: number | null, evaluation_passed?: boolean | null, edoniq_extended_time_flag: boolean, completion_data?: any | null, task_completion_data?: any | null, evaluation_user?: { __typename?: 'UserObjectType', id: string, first_name: string, last_name: string } | null, assignment_user: { __typename?: 'UserObjectType', avatar_url: string, first_name: string, last_name: string, id: string } } | null };
) | null } | null, assignment_completion?: { __typename?: 'AssignmentCompletionObjectType', id: string, completion_status: AssignmentAssignmentCompletionCompletionStatusChoices, submitted_at?: string | null, evaluation_submitted_at?: string | null, evaluation_points?: number | null, evaluation_max_points?: number | null, evaluation_passed?: boolean | null, edoniq_extended_time_flag: boolean, completion_data?: any | null, task_completion_data?: any | null, evaluation_user?: { __typename?: 'UserObjectType', id: string, first_name: string, last_name: string } | null, assignment_user: { __typename?: 'UserObjectType', avatar_url?: string | null, first_name: string, last_name: string, id: string } } | null };
export type CompetenceCertificateQueryQueryVariables = Exact<{
courseSlug: Scalars['String']['input'];

View File

@ -552,8 +552,6 @@ type AssignmentCompletionObjectType {
submitted_at: DateTime
evaluation_submitted_at: DateTime
evaluation_user: UserObjectType
evaluation_points: Float
evaluation_max_points: Float
evaluation_passed: Boolean
edoniq_extended_time_flag: Boolean!
assignment_user: UserObjectType!
@ -564,6 +562,8 @@ type AssignmentCompletionObjectType {
additional_json_data: JSONString!
task_completion_data: GenericScalar
learning_content_page_id: ID
evaluation_points: Float
evaluation_max_points: Float
}
"""
@ -580,9 +580,9 @@ type UserObjectType {
first_name: String!
last_name: String!
id: UUID!
avatar_url: String!
email: String!
language: CoreUserLanguageChoices!
avatar_url: String
}
"""An enumeration."""

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

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

View File

@ -2,6 +2,7 @@ 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";
@ -24,6 +25,45 @@ export interface Criterion {
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
@ -47,7 +87,7 @@ export function useSelfEvaluationFeedback(
error.value = undefined;
loading.value = true;
console.log("Fetching feedback for learning unit", learningUnitId);
log.info("Fetching feedback for learning unit", learningUnitId);
const { data, statusCode, error: _error } = await useCSRFFetch(url.value).json();
loading.value = false;
@ -126,6 +166,52 @@ export function useSelfEvaluationFeedback(
};
}
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":

View File

@ -24,5 +24,8 @@ fi
# Create Prüfungslehrgang
python /app/manage.py create_vermittler_pruefung
# Create Motorfahrzeug Prüfungslehrgang
python /app/manage.py create_motorfahrzeug_pruefung
# Set the command to run supervisord
/home/django/.local/bin/supervisord -c /app/supervisord.conf

View File

@ -1,4 +1,4 @@
import { login } from "../helpers";
import {login} from "../helpers";
describe("selfEvaluation.cy.js", () => {
beforeEach(() => {
@ -28,108 +28,60 @@ 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");
cy.get("[data-cy=\"self-evaluation-fail\"]").should("have.text", "1");
cy.get("[data-cy=\"self-evaluation-success\"]").should("have.text", "1");
cy.get("[data-cy=\"self-evaluation-unknown\"]").should("have.text", "2");
cy.get('[data-cy="self-evaluation-fail"]').should("have.text", "1");
cy.get('[data-cy="self-evaluation-success"]').should("have.text", "1");
cy.get('[data-cy="self-evaluation-unknown"]').should("have.text", "2");
});
it("should be able to make a happy self evaluation", () => {
cy.get("[data-cy=\"test-lehrgang-lp-circle-reisen-lu-reisen\"]").click();
cy.get('[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen"]').click();
cy.makeSelfEvaluation([true, true]);
cy.get("[data-cy=\"test-lehrgang-lp-circle-reisen-lu-reisen\"]")
.find("[data-cy=\"success\"]")
cy.get('[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen"]')
.find('[data-cy="success"]')
.should("exist");
// 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", () => {
cy.get("[data-cy=\"test-lehrgang-lp-circle-reisen-lu-reisen\"]").click();
cy.get('[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen"]').click();
cy.makeSelfEvaluation([false, false]);
cy.get("[data-cy=\"test-lehrgang-lp-circle-reisen-lu-reisen\"]")
.find("[data-cy=\"fail\"]")
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", () => {
cy.get("[data-cy=\"test-lehrgang-lp-circle-reisen-lu-reisen\"]").click();
cy.get('[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen"]').click();
cy.makeSelfEvaluation([false, true]);
cy.get("[data-cy=\"test-lehrgang-lp-circle-reisen-lu-reisen\"]")
.find("[data-cy=\"fail\"]")
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

@ -737,6 +737,7 @@ CONSTANCE_CONFIG = {
"Default value is empty and will not send any emails. (No regex support!)",
),
}
TRACKING_TAG = env("IT_TRACKING_TAG", default="")
if APP_ENVIRONMENT == "local":
# http://whitenoise.evans.io/en/latest/django.html#using-whitenoise-in-development

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

@ -17,6 +17,10 @@ class AssignmentCompletionObjectType(DjangoObjectType):
task_completion_data = GenericScalar()
learning_content_page_id = graphene.ID(source="learning_content_page_id")
# rounded to sensible representation
evaluation_points = graphene.Float()
evaluation_max_points = graphene.Float()
class Meta:
model = AssignmentCompletion
fields = (
@ -34,12 +38,20 @@ class AssignmentCompletionObjectType(DjangoObjectType):
"evaluation_user",
"additional_json_data",
"edoniq_extended_time_flag",
"evaluation_points",
"evaluation_passed",
"evaluation_max_points",
"task_completion_data",
)
def resolve_evaluation_points(self, info):
if self.evaluation_points:
return round(self.evaluation_points, 1) # noqa
return None
def resolve_evaluation_max_points(self, info):
if self.evaluation_max_points:
return round(self.evaluation_max_points, 1) # noqa
return None
class AssignmentObjectType(DjangoObjectType):
tasks = JSONStreamField()

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 Organisation, User
from vbv_lernwelt.course.consts import COURSE_TEST_ID
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,6 +115,9 @@ 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")
@ -345,19 +356,17 @@ def command(
)
)
# FIXME: Add mentor to VV course as well, once we can:
# -> https://bitbucket.org/iterativ/vbv_lernwelt/pull-requests/287
# vv_course = Course.objects.get(id=COURSE_VERSICHERUNGSVERMITTLERIN_ID)
# vv_course_session = CourseSession.objects.get(course=vv_course)
# vv_mentor = LearningMentor.objects.create(
# course=vv_course,
# mentor=User.objects.get(id=TEST_MENTOR1_USER_ID),
# )
# vv_mentor.participants.add(
# CourseSessionUser.objects.get(
# user__id=TEST_STUDENT1_VV_USER_ID, course_session=vv_course_session
# )
# )
vv_course = Course.objects.get(id=COURSE_VERSICHERUNGSVERMITTLERIN_ID)
vv_course_session = CourseSession.objects.get(course=vv_course)
vv_mentor = LearningMentor.objects.create(
course=vv_course,
mentor=User.objects.get(id=TEST_MENTOR1_USER_ID),
)
vv_mentor.participants.add(
CourseSessionUser.objects.get(
user__id=TEST_STUDENT1_VV_USER_ID, course_session=vv_course_session
)
)
course = Course.objects.get(id=COURSE_TEST_ID)
course.enable_circle_documents = enable_circle_documents

View File

@ -0,0 +1,344 @@
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 = "myvbv1234"
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",
f"Teilnehmer{n} üK",
f"Iterativ{n}",
PASSWORD,
"de",
)
for n in range(1, 10)
]
trainer = _create_or_update_user(
"trainer1.uk@iterativ.ch", "Trainer1 üK", "Iterativ1", PASSWORD, "de"
)
regionenleiter = _create_or_update_user(
"regionenleiter1.uk@iterativ.ch",
"Regionenleiter1 üK",
"Iterativ1",
PASSWORD,
"de",
)
return (
members,
trainer,
regionenleiter,
)
def get_or_create_users_vv():
members = [
_create_or_update_user(
f"teilnehmer{n}.vv@iterativ.ch",
f"Teilnehmer{n} VV ",
f"Iterativ{n}",
PASSWORD,
"de",
)
for n in range(1, 10)
]
member_with_mentor = _create_or_update_user(
"teilnehmer1.mitlb.vv@iterativ.ch",
"Teilnehmer1 VV mit LB",
"Iterativ1",
PASSWORD,
"de",
)
mentor = _create_or_update_user(
"lernbegleitung1.vv@iterativ.ch",
"Lernbegleitung1 VV",
"Iterativ1",
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

@ -58,6 +58,12 @@ def vue_home(request, *args):
# render index.html from `npm run build`
content = loader.render_to_string("vue/index.html", context={}, request=request)
# inject Plausible tracking tag
if settings.TRACKING_TAG:
content = content.replace(
"</head>",
f"\n{settings.TRACKING_TAG}\n</head>",
)
return HttpResponse(content)
@ -179,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

@ -1,8 +1,15 @@
from django.contrib import admin
from vbv_lernwelt.course.models import Course, CourseSession, CourseSessionUser
from vbv_lernwelt.feedback.services import (
get_feedbacks_for_course_sessions,
get_feedbacks_for_courses,
)
from vbv_lernwelt.learnpath.models import Circle
get_feedbacks_for_course_sessions.short_description = "Feedback export"
get_feedbacks_for_courses.short_description = "Feedback export"
@admin.register(Course)
class CourseAdmin(admin.ModelAdmin):
@ -12,6 +19,7 @@ class CourseAdmin(admin.ModelAdmin):
"category_name",
"slug",
]
actions = [get_feedbacks_for_courses]
@admin.register(CourseSession)
@ -26,6 +34,7 @@ class CourseSessionAdmin(admin.ModelAdmin):
"created_at",
"updated_at",
]
actions = [get_feedbacks_for_course_sessions]
@admin.register(CourseSessionUser)

View File

@ -9,3 +9,4 @@ COURSE_UK_TRAINING_IT = -9
COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID = -10
COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID = -11
COURSE_VERSICHERUNGSVERMITTLERIN_PRUEFUNG_ID = -12
COURSE_MOTORFAHRZEUG_PRUEFUNG_ID = -13

View File

@ -47,6 +47,7 @@ from vbv_lernwelt.learnpath.models import (
LearningContentEdoniqTest,
LearningPath,
LearningUnit,
LearningUnitPerformanceFeedbackType,
)
from vbv_lernwelt.learnpath.tests.learning_path_factories import (
CircleFactory,
@ -275,6 +276,7 @@ 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,
@ -285,6 +287,7 @@ def create_learning_unit(
title="Learning Unit",
parent=circle,
course_category=cat,
feedback_user=feedback_user.value,
)
@ -292,7 +295,7 @@ def create_performance_criteria_page(
course: Course,
course_page: CoursePage,
circle: Circle,
learning_unit: LearningUnitFactory | None = None,
learning_unit: LearningUnitFactory | LearningUnit | None = None,
) -> PerformanceCriteria:
competence_navi_page = CompetenceNaviPageFactory(
title="Competence Navi",

View File

@ -49,6 +49,7 @@ from vbv_lernwelt.core.constants import TEST_MENTOR1_USER_ID
from vbv_lernwelt.core.create_default_users import default_users
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.consts import (
COURSE_MOTORFAHRZEUG_PRUEFUNG_ID,
COURSE_TEST_ID,
COURSE_UK,
COURSE_UK_FR,
@ -95,6 +96,7 @@ from vbv_lernwelt.importer.services import (
)
from vbv_lernwelt.learning_mentor.models import LearningMentor
from vbv_lernwelt.learnpath.create_vv_new_learning_path import (
create_vv_motorfahrzeug_pruefung_learning_path,
create_vv_new_learning_path,
create_vv_pruefung_learning_path,
)
@ -309,6 +311,34 @@ def create_versicherungsvermittlerin_pruefung_course(
create_vv_pruefung_learning_path(course_id=course_id)
def create_motorfahrzeug_pruefung_course(
course_id=COURSE_MOTORFAHRZEUG_PRUEFUNG_ID, language="de"
):
names = {
"de": "Motorfahrzeug Versicherungsvermittler/-in VBV Prüfung",
"fr": "Véhicules à moteur Intermédiaire dassurance AFA Examen",
"it": "Veicolo a motore Intermediario/a assicurativo/a AFA Esame",
}
# Versicherungsvermittler/in mit neuen Circles
course = create_versicherungsvermittlerin_with_categories(
course_id=course_id,
title=names[language],
)
# assignments create assignments parent page
_assignment_list_page = AssignmentListPageFactory(
parent=course.coursepage,
)
create_vv_new_competence_profile(course_id=course_id)
create_default_media_library(course_id=course_id)
create_vv_reflection(course_id=course_id)
CourseSession.objects.create(course_id=course_id, title=names[language])
create_vv_motorfahrzeug_pruefung_learning_path(course_id=course_id)
def create_course_uk_de(course_id=COURSE_UK, lang="de"):
names = {
"de": "Überbetriebliche Kurse",

View File

@ -0,0 +1,23 @@
import djclick as click
from vbv_lernwelt.course.consts import COURSE_MOTORFAHRZEUG_PRUEFUNG_ID
from vbv_lernwelt.course.management.commands.create_default_courses import (
create_motorfahrzeug_pruefung_course,
)
from vbv_lernwelt.course.models import Course
ADMIN_EMAILS = ["info@iterativ.ch", "admin"]
@click.command()
def command():
print(
"Creating Motorfahrzeug Vermittler Prüfung course",
COURSE_MOTORFAHRZEUG_PRUEFUNG_ID,
)
if Course.objects.filter(id=COURSE_MOTORFAHRZEUG_PRUEFUNG_ID).exists():
print("Course already exists, skipping")
return
create_motorfahrzeug_pruefung_course()

View File

@ -0,0 +1,18 @@
import djclick as click
import structlog
from vbv_lernwelt.feedback.services import export_feedback
logger = structlog.get_logger(__name__)
@click.command()
@click.argument("course_session_id")
@click.option(
"--save-as-file/--no-save-as-file",
default=True,
help="`save-as-file` to save the file, `no-save-as-file` returns bytes. Default is `save-as-file`.",
)
def command(course_session_id, save_as_file):
# using the output from call_command was a bit cumbersome, so this is just a wrapper for the actual function
export_feedback([course_session_id], save_as_file)

View File

@ -1,6 +1,12 @@
from datetime import datetime
from io import BytesIO
from itertools import groupby
from operator import attrgetter
from typing import Union
import structlog
from django.http import HttpResponse
from openpyxl import Workbook
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseCompletionStatus, CourseSession
@ -13,6 +19,47 @@ from vbv_lernwelt.learnpath.models import (
logger = structlog.get_logger(__name__)
VV_FEEDBACK_QUESTIONS = [
("satisfaction", "Zufriedenheit insgesamt"),
("goal_attainment", "Zielerreichung insgesamt"),
(
"proficiency",
"Wie beurteilst du deine Sicherheit bezüglichen den Themen nach dem Circle?",
),
("preparation_task_clarity", "Waren die Praxisaufträge klar und verständlich?"),
("would_recommend", "Würdest du den Circle weiterempfehlen?"),
("course_positive_feedback", "Was hat dir besonders gut gefallen?"),
("course_negative_feedback", "Wo siehst du Verbesserungspotential?"),
]
UK_FEEDBACK_QUESTIONS = [
("satisfaction", "Zufriedenheit insgesamt"),
("goal_attainment", "Zielerreichung insgesamt"),
(
"proficiency",
"Wie beurteilst du deine Sicherheit bezüglichen den Themen nach dem Kurs?",
),
(
"preparation_task_clarity",
"Waren die Vorbereitungsaufträge klar und verständlich?",
),
(
"instructor_competence",
"Wie beurteilst du die Themensicherheit und Fachkompetenz des Kursleiters/der Kursleiterin?",
),
(
"instructor_respect",
"Wurden Fragen und Anregungen der Kursteilnehmenden ernst genommen und aufgegriffen?",
),
(
"instructor_open_feedback",
"Was möchtest du dem Kursleiter/der Kursleiterin sonst noch sagen?",
),
("would_recommend", "Würdest du den Kurs weiterempfehlen?"),
("course_positive_feedback", "Was hat dir besonders gut gefallen?"),
("course_negative_feedback", "Wo siehst du Verbesserungspotential?"),
]
def update_feedback_response(
feedback_user: User,
@ -100,3 +147,104 @@ def initial_data_for_feedback_page(
"feedback_type": "vv",
}
return {}
def export_feedback(course_session_ids: list[str], save_as_file: bool):
wb = Workbook()
# remove the first sheet is just easier than keeping track of the active sheet
wb.remove_sheet(wb.active)
feedbacks = FeedbackResponse.objects.filter(
course_session_id__in=course_session_ids,
submitted=True,
).order_by("circle", "course_session", "updated_at")
grouped_feedbacks = groupby(feedbacks, key=attrgetter("circle"))
for circle, group_feedbacks in grouped_feedbacks:
group_feedbacks = list(group_feedbacks)
logger.debug(
"export_feedback_for_circle",
data={
"circle": circle.id,
"course_session_ids": course_session_ids,
"count": len(group_feedbacks),
},
label="feedback_export",
)
_create_sheet(wb, circle.title, group_feedbacks)
if save_as_file:
wb.save(make_export_filename())
else:
output = BytesIO()
wb.save(output)
output.seek(0)
return output.getvalue()
def _create_sheet(wb: Workbook, title: str, data: list[FeedbackResponse]):
sheet = wb.create_sheet(title=title)
if len(data) == 0:
return sheet
# we instruct the users not to mix exports of different courses, so we can assume the questions are the same and of the first type
question_data = (
UK_FEEDBACK_QUESTIONS
if data[0].data["feedback_type"] == "uk"
else VV_FEEDBACK_QUESTIONS
)
# add header
sheet.cell(row=1, column=1, value="Durchführung")
sheet.cell(row=1, column=2, value="Datum")
questions = [q[1] for q in question_data]
for col_idx, title in enumerate(questions, start=3):
sheet.cell(row=1, column=col_idx, value=title)
_add_rows(sheet, data, question_data)
return sheet
def _add_rows(sheet, data, question_data):
for row_idx, feedback in enumerate(data, start=2):
sheet.cell(row=row_idx, column=1, value=feedback.course_session.title)
sheet.cell(
row=row_idx, column=2, value=feedback.updated_at.date().strftime("%d.%m.%Y")
)
for col_idx, question in enumerate(question_data, start=3):
response = feedback.data.get(question[0], "")
sheet.cell(row=row_idx, column=col_idx, value=response)
def make_export_filename(name: str = "feedback_export"):
today_date = datetime.today().strftime("%Y-%m-%d")
return f"{name}_{today_date}.xlsx"
# used as admin action, that's why it's not in the views.py
def get_feedbacks_for_course_sessions(_modeladmin, _request, queryset):
file_name = "feedback_export_durchfuehrungen"
return _handle_feedback_export_action(queryset, file_name)
def get_feedbacks_for_courses(_modeladmin, _request, queryset):
course_sessions = CourseSession.objects.filter(course__in=queryset)
file_name = "feedback_export_lehrgaenge"
return _handle_feedback_export_action(course_sessions, file_name)
def _handle_feedback_export_action(course_seesions, file_name):
excel_bytes = export_feedback(course_seesions, False)
response = HttpResponse(
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
response[
"Content-Disposition"
] = f"attachment; filename={make_export_filename(file_name)}"
response.write(excel_bytes)
return response

View File

@ -12,7 +12,11 @@ from vbv_lernwelt.competence.factories import (
)
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.consts import (
COURSE_MOTORFAHRZEUG_PRUEFUNG_ID,
COURSE_VERSICHERUNGSVERMITTLERIN_ID,
COURSE_VERSICHERUNGSVERMITTLERIN_PRUEFUNG_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 (
@ -54,7 +58,7 @@ def create_vv_new_learning_path(
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)
@ -89,7 +93,7 @@ def create_vv_new_learning_path(
def create_vv_pruefung_learning_path(
course_id=COURSE_VERSICHERUNGSVERMITTLERIN_ID, user=None
course_id=COURSE_VERSICHERUNGSVERMITTLERIN_PRUEFUNG_ID, user=None
):
if user is None:
user = User.objects.get(username="info@iterativ.ch")
@ -108,6 +112,25 @@ def create_vv_pruefung_learning_path(
Page.objects.update(owner=user)
def create_vv_motorfahrzeug_pruefung_learning_path(
course_id=COURSE_MOTORFAHRZEUG_PRUEFUNG_ID, user=None
):
if user is None:
user = User.objects.get(username="info@iterativ.ch")
course_page = CoursePage.objects.get(course_id=course_id)
lp = LearningPathFactory(
title="Lernpfad",
parent=course_page,
)
TopicFactory(title="Fahrzeug", parent=lp)
create_circle_fahrzeug(lp, course_page=course_page)
# all pages belong to 'admin' by default
Page.objects.update(owner=user)
def create_circle_basis(lp, title="Basis", course_page=None):
circle = CircleFactory(
title=title,
@ -340,7 +363,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,
@ -404,7 +427,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,
@ -429,6 +459,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

@ -15,6 +15,7 @@ from vbv_lernwelt.course.creators.test_utils import (
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,
@ -154,7 +155,9 @@ class SelfEvaluationFeedbackAPI(APITestCase):
"""Tests endpoint of feedback REQUESTER"""
# GIVEN
learning_unit = create_learning_unit(course=self.course, circle=self.circle)
learning_unit = create_learning_unit( # noqa
course=self.course, circle=self.circle
)
performance_criteria_1 = create_performance_criteria_page(
course=self.course,
@ -204,11 +207,11 @@ class SelfEvaluationFeedbackAPI(APITestCase):
feedback = response.data
self.assertEqual(feedback["learning_unit_id"], learning_unit.id)
self.assertEqual(feedback["feedback_submitted"], False)
self.assertEqual(feedback["circle_name"], self.circle.title)
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)) # noqa
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)
@ -243,11 +246,217 @@ class SelfEvaluationFeedbackAPI(APITestCase):
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(course=self.course, circle=self.circle)
learning_unit = create_learning_unit( # noqa
course=self.course, circle=self.circle
)
performance_criteria_1 = create_performance_criteria_page(
course=self.course,
@ -299,7 +508,7 @@ class SelfEvaluationFeedbackAPI(APITestCase):
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)
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
@ -404,7 +613,7 @@ class SelfEvaluationFeedbackAPI(APITestCase):
self.client.force_login(self.mentor)
# WHEN
response = self.client.put(
self.client.put(
reverse(
"release_self_evaluation_feedback", args=[self_evaluation_feedback.id]
),

View File

@ -4,11 +4,18 @@ 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,
@ -19,6 +26,7 @@ urlpatterns = [
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,

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

@ -1,3 +1,4 @@
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
@ -5,9 +6,14 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseCompletion
from vbv_lernwelt.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 LearningUnit
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,
@ -16,6 +22,13 @@ from vbv_lernwelt.self_evaluation_feedback.models import (
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"])
@ -80,6 +93,126 @@ def get_self_evaluation_feedback_as_provider(request, learning_unit_id):
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):

View File

@ -43,6 +43,14 @@
<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">