Add summary component

This commit is contained in:
Christian Cueni 2023-01-25 14:41:29 +01:00
parent 88848aa292
commit 44ed154814
14 changed files with 243 additions and 63 deletions

View File

@ -0,0 +1,77 @@
<template>
<div class="px-6 py-5 bg-white mb-4">
<h2 class="bg-feedback bg-no-repeat pl-[68px] heading-3 bg-60 leading-[60px] mb-4">{{ $t("general.feedback", 2) }}</h2>
<ol>
<ItRow
v-for="feedbacks in feedbackSummary"
:key="feedbacks.circle_id"
>
<template #firstRow><span class="text-bold">{{ $t("feedback.circleFeedback") }}</span></template>
<template #center>
<div class="flex justify-between w-full">
<div>Circle: {{ feedbacks.circle.title }}</div>
<div>{{ $t("feedback.sentByUsers", feedbacks.count) }}</div>
</div>
</template>
<template #link>
<router-link
:to="`${url}/cockpit/feedback/`"
class="underline w-full text-right"
>
{{ $t("feedback.showDetails") }}
</router-link>
</template>
</ItRow>
</ol>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import ItRow from "@/components/ui/ItRow.vue";
import { itGet } from "@/fetchHelpers";
import type { Circle } from "@/services/circle";
interface FeedbackSummary {
circle_id: number;
count: number;
}
interface FeedbackDisplaySummary extends FeedbackSummary {
circle: Circle;
}
function makeSummary(feedbackData: FeedbackSummary[], circles: Circle[], selectedCircles: string[]) {
const summary: FeedbackDisplaySummary[] = circles.filter(circle => selectedCircles.includes(circle.translation_key))
.reduce((acc: FeedbackDisplaySummary[], circle) => {
const circleFeedbacks = feedbackData
.filter(data => data.circle_id === circle.id)
.map(data => Object.assign({}, data, { circle }));
return acc.concat(circleFeedbacks);
}, []);
return summary;
}
const props = defineProps<{
selctedCircles: string[];
circles: Circle[];
courseId: number;
url: string;
}>();
const feedbackSummary = ref<FeedbackDisplaySummary[]>([]);
let feedbackData: FeedbackSummary[] = [];
onMounted(async () => {
feedbackData = await itGet(`/api/core/feedback/${props.courseId}/summary`);
feedbackSummary.value = makeSummary(feedbackData, props.circles, props.selctedCircles);
})
watch(() => props, () => {
if (feedbackData.length > 0 && props.circles.length > 0 && props.selctedCircles.length > 0) {
feedbackSummary.value = makeSummary(feedbackData, props.circles, props.selctedCircles);
}
}, { deep: true });
</script>

View File

@ -1,6 +1,5 @@
<script setup lang="ts">
import { Dialog, DialogDescription, DialogPanel, DialogTitle } from "@headlessui/vue";
import { ref } from "vue";
interface Props {
modelValue: boolean;

View File

@ -1,4 +1,6 @@
<script setup lang="ts">
import ItRow from "@/components/ui/ItRow.vue";
const props = defineProps<{
avatarUrl: string;
name: string;
@ -6,20 +8,18 @@ const props = defineProps<{
</script>
<template>
<li
class="flex flex-col justify-between border-t border-gray-500 py-4 leading-[45px] lg:flex-row"
>
<div class="flex flex-row items-center md:w-1/4">
<img class="mr-2 h-[45px] rounded-full" :src="avatarUrl" />
<ItRow>
<template #firstRow>
<img class="h-[45px] rounded-full mr-2" :src="avatarUrl" />
<p class="text-bold lg:leading-[45px]">{{ name }}</p>
</div>
<div class="flex flex-1 items-center">
</template>
<template #center>
<slot name="center"></slot>
</div>
<div class="flex items-center">
</template>
<template #link>
<slot name="link"></slot>
</div>
</li>
</template>
</ItRow>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,20 @@
<template>
<li
class="py-4 leading-[45px] border-t border-gray-500 flex flex-col lg:flex-row justify-between"
>
<div class="flex flex-row md:w-1/4 items-center">
<slot name="firstRow"></slot>
</div>
<div class="flex-1 flex items-center">
<slot name="center"></slot>
</div>
<div class="flex items-center md:w-1/4">
<slot name="link"></slot>
</div>
</li>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped></style>

View File

@ -131,7 +131,10 @@
"completionTitle": "Schicke dein Feedback an {name}",
"completionDescription": "Dein Feedback ist anonym. Dein Vor- und Nachname werden bei deiner Trainer/-in nicht angezeigt.",
"sendFeedback": "Feedback abschicken",
"feedbackSent": "Dein Feedback wurde abgeschickt"
"feedbackSent": "Dein Feedback wurde abgeschickt",
"circleFeedback": "Feedback zum Circle",
"showDetails": "Details anzeigen",
"sentByUsers": "Von {count} Teilnehmern ausgefüllt"
},
"constants": {
"yes": "Ja",

View File

@ -2,6 +2,7 @@
import LearningPathDiagram from "@/components/learningPath/LearningPathDiagram.vue";
import ItPersonRow from "@/components/ui/ItPersonRow.vue";
import ItProgress from "@/components/ui/ItProgress.vue";
import FeedbackSummary from "@/components/feedback/feedbackSummary.vue";
import type { LearningPath } from "@/services/learningPath";
import { useCockpitStore } from "@/stores/cockpit";
@ -10,6 +11,7 @@ import { useLearningPathStore } from "@/stores/learningPath";
import { useUserStore } from "@/stores/user";
import log from "loglevel";
import { computed } from "vue";
import { useCourseSessionsStore } from "@/stores/courseSessions";
const props = defineProps<{
courseSlug: string;
@ -21,6 +23,7 @@ const userStore = useUserStore();
const cockpitStore = useCockpitStore();
const competenceStore = useCompetenceStore();
const learningPathStore = useLearningPathStore();
const courseSessionStore = useCourseSessionsStore();
function userCountStatus(userId: number) {
return competenceStore.calcStatusCount(
@ -92,8 +95,8 @@ function setActiveClasses(translationKey: string) {
</ul>
</div>
<!-- Status -->
<div class="mb-4 grid grid-rows-3 gap-4 lg:grid-cols-3 lg:grid-rows-none">
<div class="bg-white px-6 py-5">
<div class="grid gap-4 grid-rows-2 lg:grid-rows-none lg:grid-cols-2 mb-4">
<div class="px-6 py-5 bg-white">
<h1
class="heading-3 mb-4 bg-assignment bg-60 bg-no-repeat pl-[68px] leading-[60px]"
>
@ -106,18 +109,7 @@ function setActiveClasses(translationKey: string) {
</div>
<div class="bg-white px-6 py-5">
<h1
class="heading-3 mb-4 bg-feedback bg-60 bg-no-repeat pl-[68px] leading-[60px]"
>
{{ $t("general.feedback", 2) }}
</h1>
<div class="mb-4">
<ItProgress :status-count="data.transferProgress"></ItProgress>
</div>
<p>{{ $t("cockpit.feedbacksDone") }}</p>
</div>
<div class="bg-white px-6 py-5">
<h1
class="heading-3 mb-4 bg-test bg-60 bg-no-repeat pl-[68px] leading-[60px]"
class="bg-test bg-no-repeat pl-[68px] heading-3 bg-60 leading-[60px] mb-4"
>
{{ $t("general.examResult", 2) }}
</h1>
@ -127,6 +119,13 @@ function setActiveClasses(translationKey: string) {
<p>{{ $t("cockpit.examsDone") }}</p>
</div>
</div>
<!-- Feedback -->
<FeedbackSummary
:selcted-circles="cockpitStore.selectedCircles || []"
:circles="learningPathStore.learningPathForUser(props.courseSlug, userStore.id)?.circles || []"
:course-id="courseSessionStore.courseSessionForRoute?.course.id"
:url="courseSessionStore.courseSessionForRoute.course_url"
></FeedbackSummary>
<div>
<!-- progress -->
<div v-if="cockpitStore.courseSessionUsers?.length" class="bg-white p-6">
@ -140,7 +139,7 @@ function setActiveClasses(translationKey: string) {
>
<template #center>
<div class="flex flex-row items-center justify-between">
<div class="flex flex-row items-center gap-4">
<div class="flex flex-row items-center gap-4 mr-6">
<div class="h-12">
<LearningPathDiagram
v-if="
@ -198,6 +197,7 @@ function setActiveClasses(translationKey: string) {
<template #link>
<router-link
:to="`/course/${props.courseSlug}/cockpit/profile/${csu.user_id}`"
class="underline w-full text-right"
>
{{ $t("general.profileLink") }}
</router-link>

View File

@ -7,9 +7,6 @@ from django.urls import include, path, re_path
from django.views import defaults as default_views
from grapple import urls as grapple_urls
from ratelimit.exceptions import Ratelimited
from wagtail import urls as wagtail_urls
from wagtail.admin import urls as wagtailadmin_urls
from wagtail.documents import urls as wagtaildocs_urls
from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt
from vbv_lernwelt.core.views import (
@ -35,7 +32,13 @@ from vbv_lernwelt.course.views import (
request_course_completion,
request_course_completion_for_user,
)
from vbv_lernwelt.feedback.views import get_expert_feedbacks_for_course, get_feedback_for_circle
from vbv_lernwelt.feedback.views import (
get_expert_feedbacks_for_course,
get_feedback_for_circle,
)
from wagtail import urls as wagtail_urls
from wagtail.admin import urls as wagtailadmin_urls
from wagtail.documents import urls as wagtaildocs_urls
def raise_example_error(request):

View File

@ -27,6 +27,7 @@ from vbv_lernwelt.course.management.commands.create_uk_course import (
)
from vbv_lernwelt.course.models import CourseSession, CourseSessionUser
from vbv_lernwelt.course.services import mark_course_completion
from vbv_lernwelt.feedback.creators.create_demo_feedback import create_feedback
from vbv_lernwelt.learnpath.create_vv_learning_path import create_vv_learning_path
from vbv_lernwelt.learnpath.create_vv_new_learning_path import (
create_vv_new_learning_path,
@ -120,6 +121,7 @@ def command():
for i, circle in enumerate(circles):
expert = experts[i % len(experts)]
expert.expert.add(circle)
create_feedback(circle, cs, 10)
# course session Versicherungsvermittler/in (neu)
# course session Versicherungsvermittler/in
@ -192,6 +194,18 @@ def command():
user=User.objects.get(username="evelyn.schmid@example.com"),
)
create_feedback(
Circle.objects.get(slug="überbetriebliche-kurse-lp-circle-kickoff"), cs, 10
)
create_feedback(
Circle.objects.get(slug="überbetriebliche-kurse-lp-circle-haushalt-teil-2"),
cs,
14,
)
create_feedback(
Circle.objects.get(slug="überbetriebliche-kurse-lp-circle-basis"), cs, 12
)
# course session Überbetriebliche Kurse Lehrjahr 1 - Region Zürich
cs = CourseSession.objects.create(
course_id=COURSE_UK1,

View File

@ -191,6 +191,7 @@ class DocumentUploadApiTestCase(APITestCase):
response = self.client.delete(f"/api/core/document/{document.id}/")
self.assertEqual(response.status_code, 403)
# expert cannot upload in other course
# expert cannot delete other upload
# exper cannot change course

View File

@ -0,0 +1,8 @@
from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.feedback.factories import FeedbackFactory
from vbv_lernwelt.learnpath.models import Circle
def create_feedback(circle: Circle, course_session: CourseSession, amount: int):
for _i in range(amount):
FeedbackFactory(circle=circle, course_session=course_session).save()

View File

@ -1,4 +1,5 @@
from factory.django import DjangoModelFactory
from factory.fuzzy import FuzzyChoice, FuzzyInteger
from vbv_lernwelt.feedback.models import FeedbackResponse
@ -6,3 +7,31 @@ from vbv_lernwelt.feedback.models import FeedbackResponse
class FeedbackFactory(DjangoModelFactory):
class Meta:
model = FeedbackResponse
satisfaction = FuzzyInteger(1, 4)
goal_attainment = FuzzyInteger(1, 4)
proficiency = FuzzyChoice([20, 40, 60, 80])
received_materials = FuzzyChoice([True, False])
materials_rating = FuzzyInteger(1, 4)
instructor_competence = FuzzyInteger(1, 4)
instructor_respect = FuzzyInteger(1, 4)
instructor_open_feedback = FuzzyChoice(
[
"Alles gut, manchmal etwas langfädig",
"Super, bin begeistert",
"Ok, enspricht den Erwartungen",
]
)
would_recommend = FuzzyChoice([True, False])
course_positive_feedback = FuzzyChoice(
[
"Die Präsentation war super",
"Das Beispiel mit der Katze fand ich sehr veranschaulicht!",
]
)
course_negative_feedback = FuzzyChoice(
[
"Es wäre praktisch, Zugang zu einer FAQ zu haben.",
"Es wäre schön, mehr Videos hinzuzufügen.",
]
)

View File

@ -50,7 +50,6 @@ class FeedbackApiBaseTestCase(APITestCase):
class FeedbackSummaryApiTestCase(FeedbackApiBaseTestCase):
def test_can_get_feedback_summary_for_circles(self):
number_basis_feedback = 5
number_analyse_feedback = 10
@ -65,18 +64,24 @@ class FeedbackSummaryApiTestCase(FeedbackApiBaseTestCase):
csu.expert.add(basis_circle)
for i in range(number_basis_feedback):
FeedbackFactory(circle=basis_circle, course_session=csu.course_session).save()
FeedbackFactory(
circle=basis_circle, course_session=csu.course_session
).save()
for i in range(number_analyse_feedback):
FeedbackFactory(circle=analyse_circle, course_session=csu.course_session).save()
FeedbackFactory(
circle=analyse_circle, course_session=csu.course_session
).save()
response = self.client.get(f"/api/core/feedback/{csu.course_session.course.id}/summary/")
response = self.client.get(
f"/api/core/feedback/{csu.course_session.course.id}/summary/"
)
self.assertEqual(response.status_code, 200)
expected = {
analyse_circle.id: {"circle_id": analyse_circle.id, "count": number_analyse_feedback},
basis_circle.id: {"circle_id": basis_circle.id, "count": number_basis_feedback},
}
expected = [
{"circle_id": basis_circle.id, "count": number_basis_feedback},
{"circle_id": analyse_circle.id, "count": number_analyse_feedback},
]
self.assertEqual(response.data, expected)
def test_can_only_see_feedback_from_own_circle(self):
@ -92,17 +97,23 @@ class FeedbackSummaryApiTestCase(FeedbackApiBaseTestCase):
basis_circle = Circle.objects.get(slug="test-lehrgang-lp-circle-basis")
for i in range(number_basis_feedback):
FeedbackFactory(circle=basis_circle, course_session=csu.course_session).save()
FeedbackFactory(
circle=basis_circle, course_session=csu.course_session
).save()
for i in range(number_analyse_feedback):
FeedbackFactory(circle=analyse_circle, course_session=csu.course_session).save()
FeedbackFactory(
circle=analyse_circle, course_session=csu.course_session
).save()
response = self.client.get(f"/api/core/feedback/{csu.course_session.course.id}/summary/")
response = self.client.get(
f"/api/core/feedback/{csu.course_session.course.id}/summary/"
)
self.assertEqual(response.status_code, 200)
expected = {
analyse_circle.id: {"circle_id": analyse_circle.id, "count": number_analyse_feedback},
}
expected = [
{"circle_id": analyse_circle.id, "count": number_analyse_feedback},
]
self.assertEqual(response.data, expected)
def test_student_does_not_see_feedback(self):
@ -114,10 +125,12 @@ class FeedbackSummaryApiTestCase(FeedbackApiBaseTestCase):
analyse_circle = Circle.objects.get(slug="test-lehrgang-lp-circle-analyse")
FeedbackFactory(circle=analyse_circle, course_session=csu.course_session).save()
response = self.client.get(f"/api/core/feedback/{csu.course_session.course.id}/summary/")
response = self.client.get(
f"/api/core/feedback/{csu.course_session.course.id}/summary/"
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data, {})
self.assertEqual(response.data, [])
class FeedbackDetailApiTestCase(FeedbackApiBaseTestCase):
@ -157,7 +170,9 @@ class FeedbackDetailApiTestCase(FeedbackApiBaseTestCase):
course_negative_feedback=expected["course_negative_feedback"][i],
).save()
response = self.client.get(f"/api/core/feedback/{csu.course_session.course.id}/{circle.id}/")
response = self.client.get(
f"/api/core/feedback/{csu.course_session.course.id}/{circle.id}/"
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data, expected)
@ -172,7 +187,9 @@ class FeedbackDetailApiTestCase(FeedbackApiBaseTestCase):
circle = Circle.objects.get(slug="test-lehrgang-lp-circle-basis")
FeedbackFactory(circle=circle, course_session=csu.course_session).save()
response = self.client.get(f"/api/core/feedback/{csu.course_session.course.id}/{circle.id}/")
response = self.client.get(
f"/api/core/feedback/{csu.course_session.course.id}/{circle.id}/"
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data, {})
@ -186,7 +203,9 @@ class FeedbackDetailApiTestCase(FeedbackApiBaseTestCase):
circle = Circle.objects.get(slug="test-lehrgang-lp-circle-analyse")
FeedbackFactory(circle=circle, course_session=csu.course_session).save()
response = self.client.get(f"/api/core/feedback/{csu.course_session.course.id}/{circle.id}/")
response = self.client.get(
f"/api/core/feedback/{csu.course_session.course.id}/{circle.id}/"
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data, {})

View File

@ -1,3 +1,5 @@
import itertools
import structlog
from rest_framework.decorators import api_view
from rest_framework.response import Response
@ -22,26 +24,31 @@ FEEDBACK_FIELDS = [
@api_view(["GET"])
def get_expert_feedbacks_for_course(request, course_id):
feedbacks = FeedbackResponse.objects.filter(course_session__course_id=course_id, circle__expert__user=request.user)
circle_count = {}
feedbacks = FeedbackResponse.objects.filter(
course_session__course_id=course_id, circle__expert__user=request.user
).order_by("circle_id")
circle_count = []
for feedback in feedbacks:
if feedback.circle_id not in circle_count:
circle_count[feedback.circle_id] = {
"circle_id": feedback.circle_id,
"count": 0,
grouped_feedbacks = itertools.groupby(feedbacks, lambda x: x.circle_id)
for key, feedbacks in grouped_feedbacks:
circle_count.append(
{
"circle_id": key,
"count": len(list(feedbacks)),
}
circle_count[feedback.circle_id]["count"] += 1
)
return Response(status=200, data=circle_count)
@api_view(["GET"])
def get_feedback_for_circle(request, course_id, circle_id):
feedbacks = FeedbackResponse.objects.filter(course_session__course_id=course_id,
circle__expert__user=request.user,
circle_id=circle_id)
feedbacks = FeedbackResponse.objects.filter(
course_session__course_id=course_id,
circle__expert__user=request.user,
circle_id=circle_id,
)
# I guess this is ok for the üK case
feedback_data = {}