From 2f3b3be493e5954e9cf31b936cc66396c85bc832 Mon Sep 17 00:00:00 2001 From: Livio Bieri Date: Fri, 9 Feb 2024 11:14:23 +0100 Subject: [PATCH 01/30] fix: trying to enable console.log in cypress --- bitbucket-pipelines.yml | 33 +++++++++++++++++---------------- client/src/stores/circle.ts | 2 ++ 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/bitbucket-pipelines.yml b/bitbucket-pipelines.yml index 11895c35..b229123d 100644 --- a/bitbucket-pipelines.yml +++ b/bitbucket-pipelines.yml @@ -16,6 +16,7 @@ e2e: &e2e - export CURRENTS_RECORD_KEY=etzp5VXtJcSX8Z4H - export IT_SERVE_VUE=false - export IT_ALLOW_LOCAL_LOGIN=true + - export ELECTRON_ENABLE_LOGGING=true - source ./env/bitbucket/prepare_for_test.sh - pip install -r server/requirements/requirements-dev.txt - npm install @@ -97,12 +98,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 +133,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: diff --git a/client/src/stores/circle.ts b/client/src/stores/circle.ts index 5756c775..21f6d8c7 100644 --- a/client/src/stores/circle.ts +++ b/client/src/stores/circle.ts @@ -33,6 +33,8 @@ export const useCircleStore = defineStore({ getters: {}, actions: { openLearningContent(learningContent: LearningContent) { + console.log("💩 openLearningContent", learningContent.frontend_url); + this.router.push({ path: learningContent.frontend_url, }); From 7f8cfcba2472a1c9ebe1646069d62d19c0c74bc8 Mon Sep 17 00:00:00 2001 From: Livio Bieri Date: Thu, 15 Feb 2024 21:49:26 +0100 Subject: [PATCH 02/30] feat: kompnavi api --- .../pages/competence/CompetenceParentPage.vue | 18 ++ .../SelfEvaluationAndFeedbackPage.vue | 9 + client/src/router/index.ts | 7 +- .../course/creators/test_utils.py | 5 +- .../tests/test_api.py | 211 +++++++++++++++++- .../self_evaluation_feedback/urls.py | 8 + .../self_evaluation_feedback/utils.py | 114 ++++++++++ .../self_evaluation_feedback/views.py | 73 +++++- 8 files changed, 434 insertions(+), 11 deletions(-) create mode 100644 client/src/pages/competence/SelfEvaluationAndFeedbackPage.vue create mode 100644 server/vbv_lernwelt/self_evaluation_feedback/utils.py diff --git a/client/src/pages/competence/CompetenceParentPage.vue b/client/src/pages/competence/CompetenceParentPage.vue index 0b96ec43..67044e21 100644 --- a/client/src/pages/competence/CompetenceParentPage.vue +++ b/client/src/pages/competence/CompetenceParentPage.vue @@ -27,6 +27,10 @@ function routeInActionCompetences() { return route.path.endsWith("/competences"); } +function routeInSelfEvaluationAndFeedback() { + return route.path.endsWith("/self-evaluation-and-feedback"); +} + onMounted(async () => { log.debug("CompetenceParentPage mounted", props.courseSlug); }); @@ -79,6 +83,20 @@ onMounted(async () => { +
  • + + {{ $t("a.Selbst- und Fremdeinschätzungen") }} + +
  • +
  • diff --git a/client/src/pages/competence/SelfEvaluationAndFeedbackPage.vue b/client/src/pages/competence/SelfEvaluationAndFeedbackPage.vue new file mode 100644 index 00000000..3a21ee36 --- /dev/null +++ b/client/src/pages/competence/SelfEvaluationAndFeedbackPage.vue @@ -0,0 +1,9 @@ + + + + + diff --git a/client/src/router/index.ts b/client/src/router/index.ts index 5ffa7819..109b624f 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -92,7 +92,6 @@ const router = createRouter({ props: true, component: () => import("@/pages/competence/CompetenceIndexPage.vue"), }, - { path: "certificates", props: true, @@ -110,6 +109,12 @@ const router = createRouter({ props: true, component: () => import("@/pages/competence/PerformanceCriteriaPage.vue"), }, + { + path: "self-evaluation-and-feedback", + props: true, + component: () => + import("@/pages/competence/SelfEvaluationAndFeedbackPage.vue"), + }, { path: "competences", props: true, diff --git a/server/vbv_lernwelt/course/creators/test_utils.py b/server/vbv_lernwelt/course/creators/test_utils.py index 3f945b97..ec22f19b 100644 --- a/server/vbv_lernwelt/course/creators/test_utils.py +++ b/server/vbv_lernwelt/course/creators/test_utils.py @@ -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", diff --git a/server/vbv_lernwelt/self_evaluation_feedback/tests/test_api.py b/server/vbv_lernwelt/self_evaluation_feedback/tests/test_api.py index 98291bbc..b13254b8 100644 --- a/server/vbv_lernwelt/self_evaluation_feedback/tests/test_api.py +++ b/server/vbv_lernwelt/self_evaluation_feedback/tests/test_api.py @@ -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,205 @@ 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["pass"], 1) + self.assertEqual(self_assessment["fail"], 1) + self.assertEqual(self_assessment["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) + ) + + 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] + self.assertEqual(result["self_assessment"]["pass"], 1) + self.assertEqual(result["self_assessment"]["fail"], 0) + self.assertEqual(result["self_assessment"]["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"]["pass"], 0) + self.assertEqual(result["self_assessment"]["fail"], 0) + self.assertEqual(result["self_assessment"]["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) + + 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 +496,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 +601,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] ), diff --git a/server/vbv_lernwelt/self_evaluation_feedback/urls.py b/server/vbv_lernwelt/self_evaluation_feedback/urls.py index 0b0514de..ab9e7c9b 100644 --- a/server/vbv_lernwelt/self_evaluation_feedback/urls.py +++ b/server/vbv_lernwelt/self_evaluation_feedback/urls.py @@ -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//feedbacks", + get_self_evaluation_feedbacks_as_requester, + name="get_self_evaluation_feedbacks_as_requester", + ), path( "requester//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//feedback", get_self_evaluation_feedback_as_provider, diff --git a/server/vbv_lernwelt/self_evaluation_feedback/utils.py b/server/vbv_lernwelt/self_evaluation_feedback/utils.py new file mode 100644 index 00000000..1d3fd5fb --- /dev/null +++ b/server/vbv_lernwelt/self_evaluation_feedback/utils.py @@ -0,0 +1,114 @@ +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 + + +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, + ) diff --git a/server/vbv_lernwelt/self_evaluation_feedback/views.py b/server/vbv_lernwelt/self_evaluation_feedback/views.py index 2bb02376..24e2eb2e 100644 --- a/server/vbv_lernwelt/self_evaluation_feedback/views.py +++ b/server/vbv_lernwelt/self_evaluation_feedback/views.py @@ -5,9 +5,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 +21,10 @@ 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 ( + get_self_assessment_counts, + get_self_evaluation_feedback_counts, +) @api_view(["POST"]) @@ -80,6 +89,66 @@ 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() + + for learning_unit in LearningUnit.objects.filter( + feedback_user=LearningUnitPerformanceFeedbackType.MENTOR_FEEDBACK.value, + course_category__course=course_session.course, + ): + circle = learning_unit.get_parent().specific + circle_ids.add(circle.id) + + feedback_assessment = None + + feedback = SelfEvaluationFeedback.objects.filter( + learning_unit=learning_unit, + feedback_requester_user=request.user, + ).first() + + if feedback: + feedback_counts = get_self_evaluation_feedback_counts(feedback) + + 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) + + results.append( + { + "id": learning_unit.id, + "title": learning_unit.title, + "feedback_assessment": feedback_assessment, + "self_assessment": { + "pass": self_assessment_counts.pass_count, + "fail": self_assessment_counts.fail_count, + "unknown": self_assessment_counts.unknown_count, + }, + } + ) + + return Response( + { + "results": results, + "circles": list( + Circle.objects.filter(id__in=circle_ids).values("id", "title") + ), + } + ) + + @api_view(["GET"]) @permission_classes([IsAuthenticated]) def get_self_evaluation_feedback_as_requester(request, learning_unit_id): From a7ca88da7925f5162d436564a06c75e23a915000 Mon Sep 17 00:00:00 2001 From: Livio Bieri Date: Fri, 16 Feb 2024 16:24:38 +0100 Subject: [PATCH 03/30] feat: missing stuff in VV comp-navi API --- .../tests/test_api.py | 10 ++++++ .../self_evaluation_feedback/urls.py | 2 +- .../self_evaluation_feedback/utils.py | 8 +++++ .../self_evaluation_feedback/views.py | 36 +++++++++++++++++-- 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/server/vbv_lernwelt/self_evaluation_feedback/tests/test_api.py b/server/vbv_lernwelt/self_evaluation_feedback/tests/test_api.py index b13254b8..6ea4ea48 100644 --- a/server/vbv_lernwelt/self_evaluation_feedback/tests/test_api.py +++ b/server/vbv_lernwelt/self_evaluation_feedback/tests/test_api.py @@ -322,6 +322,14 @@ class SelfEvaluationFeedbackAPI(APITestCase): 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""" @@ -432,6 +440,8 @@ class SelfEvaluationFeedbackAPI(APITestCase): 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) circles = response.data["circles"] self.assertEqual(len(circles), 1) diff --git a/server/vbv_lernwelt/self_evaluation_feedback/urls.py b/server/vbv_lernwelt/self_evaluation_feedback/urls.py index ab9e7c9b..356c205e 100644 --- a/server/vbv_lernwelt/self_evaluation_feedback/urls.py +++ b/server/vbv_lernwelt/self_evaluation_feedback/urls.py @@ -12,7 +12,7 @@ from vbv_lernwelt.self_evaluation_feedback.views import ( urlpatterns = [ # /requester/* URLs -> For the user who requests feedback path( - "requester//feedbacks", + "requester//feedbacks/summaries", get_self_evaluation_feedbacks_as_requester, name="get_self_evaluation_feedbacks_as_requester", ), diff --git a/server/vbv_lernwelt/self_evaluation_feedback/utils.py b/server/vbv_lernwelt/self_evaluation_feedback/utils.py index 1d3fd5fb..5d2563b0 100644 --- a/server/vbv_lernwelt/self_evaluation_feedback/utils.py +++ b/server/vbv_lernwelt/self_evaluation_feedback/utils.py @@ -112,3 +112,11 @@ def get_self_assessment_counts( 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), + ) diff --git a/server/vbv_lernwelt/self_evaluation_feedback/views.py b/server/vbv_lernwelt/self_evaluation_feedback/views.py index 24e2eb2e..18aaebc0 100644 --- a/server/vbv_lernwelt/self_evaluation_feedback/views.py +++ b/server/vbv_lernwelt/self_evaluation_feedback/views.py @@ -24,6 +24,7 @@ from vbv_lernwelt.self_evaluation_feedback.serializers import ( from vbv_lernwelt.self_evaluation_feedback.utils import ( get_self_assessment_counts, get_self_evaluation_feedback_counts, + calculate_aggregate, ) @@ -97,6 +98,9 @@ def get_self_evaluation_feedbacks_as_requester(request, course_session_id: int): results = [] circle_ids = set() + all_self_assessment_counts = [] + all_feedback_assessment_counts = [] + for learning_unit in LearningUnit.objects.filter( feedback_user=LearningUnitPerformanceFeedbackType.MENTOR_FEEDBACK.value, course_category__course=course_session.course, @@ -104,15 +108,18 @@ def get_self_evaluation_feedbacks_as_requester(request, course_session_id: int): circle = learning_unit.get_parent().specific circle_ids.add(circle.id) - feedback_assessment = None - feedback = SelfEvaluationFeedback.objects.filter( learning_unit=learning_unit, feedback_requester_user=request.user, ).first() - if feedback: + 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, @@ -125,11 +132,14 @@ def get_self_evaluation_feedbacks_as_requester(request, course_session_id: int): } 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, + "circle_id": circle.id, + "circle_title": circle.title, "feedback_assessment": feedback_assessment, "self_assessment": { "pass": self_assessment_counts.pass_count, @@ -139,12 +149,32 @@ def get_self_evaluation_feedbacks_as_requester(request, course_session_id: int): } ) + self_assessment_counts_aggregate = calculate_aggregate( + counts=all_self_assessment_counts + ) + + feedback_assessment_counts_aggregate = calculate_aggregate( + counts=all_feedback_assessment_counts + ) + return Response( { "results": results, "circles": list( Circle.objects.filter(id__in=circle_ids).values("id", "title") ), + "aggregates": { + "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, + }, + }, } ) From f18b152bb8c3f898f574ca97df8100756e6a657e Mon Sep 17 00:00:00 2001 From: Livio Bieri Date: Fri, 16 Feb 2024 16:25:17 +0100 Subject: [PATCH 04/30] feat: client fetch VV comp-navi stuff --- client/src/services/selfEvaluationFeedback.ts | 85 ++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/client/src/services/selfEvaluationFeedback.ts b/client/src/services/selfEvaluationFeedback.ts index 784beb63..084d7824 100644 --- a/client/src/services/selfEvaluationFeedback.ts +++ b/client/src/services/selfEvaluationFeedback.ts @@ -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,42 @@ 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_counts: FeedbackSummaryCounts; + feedback_assessment_counts: FeedbackSummaryCounts; +} + +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; +} + +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 +84,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 +163,52 @@ export function useSelfEvaluationFeedback( }; } +export function useSelfEvaluationFeedbackSummaries( + courseSessionId: Ref | string +) { + const summaries = ref([]); + const aggregates = ref(); + const circles = ref([]); + 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": From d1cc58ea600ef69b3f67ad25457ad3865f2f8255 Mon Sep 17 00:00:00 2001 From: Livio Bieri Date: Fri, 16 Feb 2024 16:27:21 +0100 Subject: [PATCH 05/30] fix: format code --- server/vbv_lernwelt/self_evaluation_feedback/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/vbv_lernwelt/self_evaluation_feedback/views.py b/server/vbv_lernwelt/self_evaluation_feedback/views.py index 18aaebc0..43bb2719 100644 --- a/server/vbv_lernwelt/self_evaluation_feedback/views.py +++ b/server/vbv_lernwelt/self_evaluation_feedback/views.py @@ -22,9 +22,9 @@ from vbv_lernwelt.self_evaluation_feedback.serializers import ( SelfEvaluationFeedbackSerializer, ) from vbv_lernwelt.self_evaluation_feedback.utils import ( + calculate_aggregate, get_self_assessment_counts, get_self_evaluation_feedback_counts, - calculate_aggregate, ) From 833dc0e7c7d44e2617dbf36c64dd0fa1a93bfa6a Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Tue, 20 Feb 2024 07:05:10 +0100 Subject: [PATCH 06/30] Add tracking code --- server/config/settings/base.py | 1 + server/vbv_lernwelt/core/views.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/server/config/settings/base.py b/server/config/settings/base.py index b073746e..f4721dde 100644 --- a/server/config/settings/base.py +++ b/server/config/settings/base.py @@ -736,6 +736,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 diff --git a/server/vbv_lernwelt/core/views.py b/server/vbv_lernwelt/core/views.py index aeb6825e..c6eb2744 100644 --- a/server/vbv_lernwelt/core/views.py +++ b/server/vbv_lernwelt/core/views.py @@ -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( + "", + f"\n{settings.TRACKING_TAG}\n", + ) return HttpResponse(content) From a1d069186c31898548ba643f1dbd686bde91c48f Mon Sep 17 00:00:00 2001 From: Livio Bieri Date: Tue, 20 Feb 2024 15:22:02 +0100 Subject: [PATCH 07/30] fix: add another learning unit with feedback --- .../learnpath/create_vv_new_learning_path.py | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/server/vbv_lernwelt/learnpath/create_vv_new_learning_path.py b/server/vbv_lernwelt/learnpath/create_vv_new_learning_path.py index a30d93be..a288a5fa 100644 --- a/server/vbv_lernwelt/learnpath/create_vv_new_learning_path.py +++ b/server/vbv_lernwelt/learnpath/create_vv_new_learning_path.py @@ -54,7 +54,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) @@ -340,7 +340,7 @@ def create_circle_gewinnen(lp, title="Gewinnen"): ) -def create_circle_fahrzeug(lp, title="Fahrzeug"): +def create_circle_fahrzeug(lp, title="Fahrzeug", course_page=None): circle = CircleFactory( title=title, parent=lp, @@ -404,7 +404,14 @@ def create_circle_fahrzeug(lp, title="Fahrzeug"): ) LearningSequenceFactory(title="Transfer", parent=circle, icon="it-icon-ls-end") - LearningUnitFactory(title="Transfer", title_hidden=True, parent=circle) + + lu_transfer = LearningUnitFactory( + title="Transfer", + title_hidden=True, + parent=circle, + feedback_user=LearningUnitPerformanceFeedbackType.MENTOR_FEEDBACK.name, + ) + LearningContentPlaceholderFactory( title="Praxisauftrag", parent=circle, @@ -429,6 +436,36 @@ def create_circle_fahrzeug(lp, title="Fahrzeug"): parent=circle, ) + competence_profile_page = ActionCompetenceListPageFactory( + title="KompetenzNavi", + parent=course_page, + ) + + ace = ActionCompetenceFactory( + parent=competence_profile_page, + ) + + PerformanceCriteriaFactory( + parent=ace, + competence_id="VV-Transfer-A", + title="Ich setze das Gelernte in der Praxis um.", + learning_unit=lu_transfer, + ) + + PerformanceCriteriaFactory( + parent=ace, + competence_id="VV-Transfer-B", + title="Ich kenne den Unterschied zwischen einem Neuwagen und einem Occasionswagen.", + learning_unit=lu_transfer, + ) + + PerformanceCriteriaFactory( + parent=ace, + competence_id="VV-Transfer-C", + title="Ich kenne den Unterschied zwischen einem Leasing und einem Kauf.", + learning_unit=lu_transfer, + ) + def create_circle_haushalt(lp, title="Haushalt"): circle = CircleFactory( From cc27ed0dd3d2a662d1807623f20f19217b55502b Mon Sep 17 00:00:00 2001 From: Livio Bieri Date: Tue, 20 Feb 2024 15:22:26 +0100 Subject: [PATCH 08/30] feat: expose detail_url // counts --- .../tests/test_api.py | 20 ++++++++++--------- .../self_evaluation_feedback/views.py | 18 ++++++++++++++--- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/server/vbv_lernwelt/self_evaluation_feedback/tests/test_api.py b/server/vbv_lernwelt/self_evaluation_feedback/tests/test_api.py index 6ea4ea48..440dfb5e 100644 --- a/server/vbv_lernwelt/self_evaluation_feedback/tests/test_api.py +++ b/server/vbv_lernwelt/self_evaluation_feedback/tests/test_api.py @@ -308,9 +308,9 @@ class SelfEvaluationFeedbackAPI(APITestCase): result = response.data["results"][0] self_assessment = result["self_assessment"] - self.assertEqual(self_assessment["pass"], 1) - self.assertEqual(self_assessment["fail"], 1) - self.assertEqual(self_assessment["unknown"], 1) + 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) @@ -370,9 +370,10 @@ class SelfEvaluationFeedbackAPI(APITestCase): self.assertEqual(response.status_code, 200) result = response.data["results"][0] - self.assertEqual(result["self_assessment"]["pass"], 1) - self.assertEqual(result["self_assessment"]["fail"], 0) - self.assertEqual(result["self_assessment"]["unknown"], 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""" @@ -405,9 +406,9 @@ class SelfEvaluationFeedbackAPI(APITestCase): self.assertEqual(response.status_code, 200) result = response.data["results"][0] - self.assertEqual(result["self_assessment"]["pass"], 0) - self.assertEqual(result["self_assessment"]["fail"], 0) - self.assertEqual(result["self_assessment"]["unknown"], 1) + 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 @@ -442,6 +443,7 @@ class SelfEvaluationFeedbackAPI(APITestCase): 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) diff --git a/server/vbv_lernwelt/self_evaluation_feedback/views.py b/server/vbv_lernwelt/self_evaluation_feedback/views.py index 43bb2719..945e71e8 100644 --- a/server/vbv_lernwelt/self_evaluation_feedback/views.py +++ b/server/vbv_lernwelt/self_evaluation_feedback/views.py @@ -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 @@ -27,6 +28,8 @@ from vbv_lernwelt.self_evaluation_feedback.utils import ( get_self_evaluation_feedback_counts, ) +logger = structlog.get_logger(__name__) + @api_view(["POST"]) @permission_classes([IsAuthenticated]) @@ -105,6 +108,12 @@ def get_self_evaluation_feedbacks_as_requester(request, course_session_id: int): feedback_user=LearningUnitPerformanceFeedbackType.MENTOR_FEEDBACK.value, 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) @@ -138,13 +147,16 @@ def get_self_evaluation_feedbacks_as_requester(request, course_session_id: int): { "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": { - "pass": self_assessment_counts.pass_count, - "fail": self_assessment_counts.fail_count, - "unknown": self_assessment_counts.unknown_count, + "counts": { + "pass": self_assessment_counts.pass_count, + "fail": self_assessment_counts.fail_count, + "unknown": self_assessment_counts.unknown_count, + } }, } ) From de351bb3c167d1199be04772f4251f3e1a1d7209 Mon Sep 17 00:00:00 2001 From: Livio Bieri Date: Tue, 20 Feb 2024 15:24:02 +0100 Subject: [PATCH 09/30] feat: kompetenz navi tab --- .../FeedbackByLearningUnitSummary.vue | 107 ++++++++++++++++++ .../selfEvaluationFeedback/SmileyCell.vue | 22 ++++ client/src/components/ui/ItDropdownSelect.vue | 2 +- .../SelfEvaluationAndFeedbackPage.vue | 58 +++++++++- client/src/services/selfEvaluationFeedback.ts | 1 + 5 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 client/src/components/selfEvaluationFeedback/FeedbackByLearningUnitSummary.vue create mode 100644 client/src/components/selfEvaluationFeedback/SmileyCell.vue diff --git a/client/src/components/selfEvaluationFeedback/FeedbackByLearningUnitSummary.vue b/client/src/components/selfEvaluationFeedback/FeedbackByLearningUnitSummary.vue new file mode 100644 index 00000000..a1c9bfcc --- /dev/null +++ b/client/src/components/selfEvaluationFeedback/FeedbackByLearningUnitSummary.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/client/src/components/selfEvaluationFeedback/SmileyCell.vue b/client/src/components/selfEvaluationFeedback/SmileyCell.vue new file mode 100644 index 00000000..3fe09962 --- /dev/null +++ b/client/src/components/selfEvaluationFeedback/SmileyCell.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/client/src/components/ui/ItDropdownSelect.vue b/client/src/components/ui/ItDropdownSelect.vue index c8bf8462..8a9c3139 100644 --- a/client/src/components/ui/ItDropdownSelect.vue +++ b/client/src/components/ui/ItDropdownSelect.vue @@ -37,7 +37,7 @@ const dropdownSelected = computed({