diff --git a/bitbucket-pipelines.yml b/bitbucket-pipelines.yml
index 11895c35..28e1750d 100644
--- a/bitbucket-pipelines.yml
+++ b/bitbucket-pipelines.yml
@@ -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:
diff --git a/client/src/pages/cockpit/cockpitPage/AssignmentSubmissionProgress.vue b/client/src/components/cockpit/AssignmentSubmissionProgress.vue
similarity index 100%
rename from client/src/pages/cockpit/cockpitPage/AssignmentSubmissionProgress.vue
rename to client/src/components/cockpit/AssignmentSubmissionProgress.vue
diff --git a/client/src/pages/cockpit/cockpitPage/CockpitDates.vue b/client/src/components/cockpit/CockpitDates.vue
similarity index 100%
rename from client/src/pages/cockpit/cockpitPage/CockpitDates.vue
rename to client/src/components/cockpit/CockpitDates.vue
diff --git a/client/src/pages/cockpit/cockpitPage/SubmissionsOverview.vue b/client/src/components/cockpit/SubmissionsOverview.vue
similarity index 96%
rename from client/src/pages/cockpit/cockpitPage/SubmissionsOverview.vue
rename to client/src/components/cockpit/SubmissionsOverview.vue
index 4c4ea0ac..7dc42662 100644
--- a/client/src/pages/cockpit/cockpitPage/SubmissionsOverview.vue
+++ b/client/src/components/cockpit/SubmissionsOverview.vue
@@ -1,6 +1,6 @@
2
+
+
+
+
diff --git a/client/src/pages/learningPath/selfEvaluationPage/SelfEvaluation.vue b/client/src/components/learningPath/SelfEvaluation.vue
similarity index 74%
rename from client/src/pages/learningPath/selfEvaluationPage/SelfEvaluation.vue
rename to client/src/components/learningPath/SelfEvaluation.vue
index 4a5b8c9a..a0f46a5f 100644
--- a/client/src/pages/learningPath/selfEvaluationPage/SelfEvaluation.vue
+++ b/client/src/components/learningPath/SelfEvaluation.vue
@@ -3,7 +3,7 @@ import { useCircleStore } from "@/stores/circle";
import type { CircleType, LearningUnit } from "@/types";
import * as log from "loglevel";
-import { useCurrentCourseSession, useCourseDataWithCompletion } from "@/composables";
+import { useCourseDataWithCompletion, useCurrentCourseSession } from "@/composables";
import LearningContentContainer from "@/pages/learningPath/learningContentPage/LearningContentContainer.vue";
import LearningContentMultiLayout from "@/pages/learningPath/learningContentPage/layouts/LearningContentMultiLayout.vue";
import eventBus from "@/utils/eventBus";
@@ -11,35 +11,56 @@ import { useRouteQuery } from "@vueuse/router";
import { computed, onUnmounted } from "vue";
import { getPreviousRoute } from "@/router/history";
import { getCompetenceNaviUrl } from "@/utils/utils";
+import SelfEvaluationRequestFeedbackPage from "@/pages/learningPath/selfEvaluationPage/SelfEvaluationRequestFeedbackPage.vue";
log.debug("LearningContent.vue setup");
-const circleStore = useCircleStore();
-const courseSession = useCurrentCourseSession();
-const courseCompletionData = useCourseDataWithCompletion();
-
-const questionIndex = useRouteQuery("step", "0", { transform: Number, mode: "push" });
-const previousRoute = getPreviousRoute();
-
const props = defineProps<{
learningUnit: LearningUnit;
circle: CircleType;
}>();
+const circleStore = useCircleStore();
+const courseSession = useCurrentCourseSession();
+const courseCompletionData = useCourseDataWithCompletion();
+
const questions = computed(() => props.learningUnit?.performance_criteria ?? []);
+const numPages = computed(() => {
+ if (learningUnitHasFeedbackPage.value) {
+ return questions.value.length + 1;
+ } else {
+ return questions.value.length;
+ }
+});
+
+const questionIndex = useRouteQuery("step", "0", { transform: Number, mode: "push" });
+const previousRoute = getPreviousRoute();
+
+const learningUnitHasFeedbackPage = computed(
+ () => props.learningUnit?.feedback_user !== "NO_FEEDBACK"
+);
+
const currentQuestion = computed(() => questions.value[questionIndex.value]);
const showPreviousButton = computed(() => questionIndex.value != 0);
+
const showNextButton = computed(
- () => questionIndex.value + 1 < questions.value?.length && questions.value?.length > 1
+ () => questionIndex.value + 1 < numPages.value && numPages.value > 1
);
-const showExitButton = computed(
- () =>
- questions.value?.length === 1 || questions.value?.length === questionIndex.value + 1
+
+const isLastStep = computed(
+ () => questions.value?.length === 1 || numPages.value == questionIndex.value + 1
);
function handleContinue() {
log.debug("handleContinue");
- if (questionIndex.value + 1 < questions.value.length) {
+
+ // not answering a question is allowed especially,
+ // nonetheless we want to still know this state in the backend!
+ if (currentQuestion.value && currentQuestion.value.completion_status === "UNKNOWN") {
+ courseCompletionData.markCompletion(currentQuestion.value, "UNKNOWN");
+ }
+
+ if (questionIndex.value + 1 < numPages.value) {
log.debug("increment questionIndex", questionIndex.value);
questionIndex.value += 1;
} else {
@@ -50,7 +71,7 @@ function handleContinue() {
function handleBack() {
log.debug("handleBack");
- if (questionIndex.value > 0 && questionIndex.value < questions.value.length) {
+ if (questionIndex.value > 0 && questionIndex.value < numPages.value) {
questionIndex.value -= 1;
}
}
@@ -78,16 +99,20 @@ onUnmounted(() => {
:sub-title="$t('a.Selbsteinschätzung')"
:title="`${learningUnit.title}`"
icon="it-icon-lc-learning-module"
- :steps-count="questions.length"
+ :steps-count="numPages"
:show-next-button="showNextButton"
- :show-exit-button="showExitButton"
+ :show-exit-button="isLastStep"
:show-start-button="false"
:show-previous-button="showPreviousButton"
:base-url="props.learningUnit.evaluate_url"
+ :close-button-variant="learningUnitHasFeedbackPage ? 'close' : 'mark_as_done'"
+ :end-badge-text="
+ learningUnitHasFeedbackPage ? $t('general.submission') : undefined
+ "
@previous="handleBack()"
@next="handleContinue()"
>
-
+
{{ currentQuestion.title }}
@@ -137,6 +162,11 @@ onUnmounted(() => {
+
diff --git a/client/src/components/learningPath/assignment/PraxisAssignmentSubmit.vue b/client/src/components/learningPath/assignment/PraxisAssignmentSubmit.vue
index b1c31c88..fe8bbd27 100644
--- a/client/src/components/learningPath/assignment/PraxisAssignmentSubmit.vue
+++ b/client/src/components/learningPath/assignment/PraxisAssignmentSubmit.vue
@@ -2,7 +2,7 @@
import ItButton from "@/components/ui/ItButton.vue";
import ItCheckbox from "@/components/ui/ItCheckbox.vue";
import { ref } from "vue";
-import { bustItGetCache, useCSRFFetch } from "@/fetchHelpers";
+import { bustItGetCache } from "@/fetchHelpers";
import { useUserStore } from "@/stores/user";
import eventBus from "@/utils/eventBus";
import log from "loglevel";
@@ -12,9 +12,8 @@ import { useMutation } from "@urql/vue";
import { UPSERT_ASSIGNMENT_COMPLETION_MUTATION } from "@/graphql/mutations";
import type { Assignment } from "@/types";
import DateEmbedding from "@/components/dueDates/DateEmbedding.vue";
-import { useCurrentCourseSession } from "@/composables";
-
-const currentCourseSession = useCurrentCourseSession();
+import { useLearningMentors } from "@/composables";
+import NoMentorInformationPanel from "@/components/mentor/NoMentorInformationPanel.vue";
const props = defineProps<{
submissionDeadlineStart?: string | null;
@@ -29,10 +28,7 @@ const upsertAssignmentCompletionMutation = useMutation(
UPSERT_ASSIGNMENT_COMPLETION_MUTATION
);
-const { data: learningMentors } = useCSRFFetch(
- `/api/mentor/${props.courseSessionId}/mentors`
-).json();
-
+const learningMentors = useLearningMentors().learningMentors;
const selectedLearningMentor = ref();
const onSubmit = async () => {
@@ -85,27 +81,7 @@ const onSubmit = async () => {
-
-
-
-
- {{
- $t(
- "a.Aktuell hast du noch keine Person als Lernbegleitung eingeladen. Lade jetzt jemanden ein."
- )
- }}
-
-
- {{ $t("a.Lernbegleitung einladen") }}
-
-
-
+
diff --git a/client/src/components/mentor/NoMentorInformationPanel.vue b/client/src/components/mentor/NoMentorInformationPanel.vue
new file mode 100644
index 00000000..d6170bc3
--- /dev/null
+++ b/client/src/components/mentor/NoMentorInformationPanel.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+ {{
+ $t(
+ "a.Aktuell hast du noch keine Person als Lernbegleitung eingeladen. Lade jetzt jemanden ein."
+ )
+ }}
+
+
+ {{ $t("a.Lernbegleitung einladen") }}
+
+
+
+
diff --git a/client/src/components/selfEvaluationFeedback/FeedbackByLearningUnitSummary.vue b/client/src/components/selfEvaluationFeedback/FeedbackByLearningUnitSummary.vue
new file mode 100644
index 00000000..7939e216
--- /dev/null
+++ b/client/src/components/selfEvaluationFeedback/FeedbackByLearningUnitSummary.vue
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+ {{ props.summary.title }}
+ Circle «{{ props.summary.circle_title }}»
+
+
+
+ {{ $t("a.Selbsteinschätzung anschauen") }}
+
+
+
+
+
+
+
+ {{ $t("a.Deine Selbsteinschätzung") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ $t("a.Fremdeinschätzung von FEEDBACK_PROVIDER_NAME", {
+ FEEDBACK_PROVIDER_NAME: feedbackProviderName,
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/selfEvaluationFeedback/FeedbackProvided.vue b/client/src/components/selfEvaluationFeedback/FeedbackProvided.vue
new file mode 100644
index 00000000..f8346fed
--- /dev/null
+++ b/client/src/components/selfEvaluationFeedback/FeedbackProvided.vue
@@ -0,0 +1,79 @@
+
+
+
+
+
{{ criteria.title }}
+
+
+
+
+ {{
+ $t("a.Selbsteinschätzung von FEEDBACK_REQUESTER_NAME", {
+ FEEDBACK_REQUESTER_NAME: feedbackRequesterName,
+ })
+ }}
+
+
+
+
+
+ {{ getSelfEvaluationCaption(criteria.self_assessment) }}
+
+
+
+
+ {{ $t("a.Deine Fremdeinschätzung") }}
+
+
+
+
+
+ {{
+ getFeedbackEvaluationCaption(
+ criteria.feedback_assessment,
+ feedback.feedback_requester_user
+ )
+ }}
+
+
+
+ {{ $t("a.Bearbeiten") }}
+
+
+
+
+
+
+
diff --git a/client/src/components/selfEvaluationFeedback/FeedbackProviderRankCriteria.vue b/client/src/components/selfEvaluationFeedback/FeedbackProviderRankCriteria.vue
new file mode 100644
index 00000000..6374b4a0
--- /dev/null
+++ b/client/src/components/selfEvaluationFeedback/FeedbackProviderRankCriteria.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
{{ $t("a.Leistungsziel") }}:
+
{{ description }}
+
+
+
+ {{ $t("a.Kann FULLNAME das?", { FULLNAME: fullname }) }}
+
+
+
+
+
+
+
+ {{ getFeedbackEvaluationCaption("SUCCESS", requester) }}
+
+
+
+
+
+ {{ getFeedbackEvaluationCaption("FAIL", requester) }}
+
+
+
+
+
+
diff --git a/client/src/components/selfEvaluationFeedback/FeedbackProviderReleaseOverview.vue b/client/src/components/selfEvaluationFeedback/FeedbackProviderReleaseOverview.vue
new file mode 100644
index 00000000..96698ef3
--- /dev/null
+++ b/client/src/components/selfEvaluationFeedback/FeedbackProviderReleaseOverview.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
{{ $t("a.Fremdeinschätzung freigeben") }}
+
+
+ {{
+ $t(
+ "a.Überprüfe deine Eingaben unten und gib anschliessend deine Fremdeinschätzung für FEEDBACK_REQUESTER frei",
+ {
+ FEEDBACK_REQUESTER: feedback.feedback_requester_user.first_name,
+ }
+ )
+ }}
+
+
+ {{ $t("a.Fremdeinschätzung freigeben") }}
+
+
+
+
+
+ {{ $t("a.Du hast deine Fremdeinschätzung freigegeben") }}
+
+
+
+
+
+
+
diff --git a/client/src/components/selfEvaluationFeedback/FeedbackReceived.vue b/client/src/components/selfEvaluationFeedback/FeedbackReceived.vue
new file mode 100644
index 00000000..eedffca1
--- /dev/null
+++ b/client/src/components/selfEvaluationFeedback/FeedbackReceived.vue
@@ -0,0 +1,59 @@
+
+
+
+
+
{{ criteria.title }}
+
+
+
+ {{ $t("a.Deine Selbsteinschätzung") }}
+
+
+
+ {{ getSelfEvaluationCaption(criteria.self_assessment) }}
+
+
+
+
+
+ {{
+ $t("a.Fremdeinschätzung von FEEDBACK_PROVIDER_NAME", {
+ FEEDBACK_PROVIDER_NAME: feedbackProviderName,
+ })
+ }}
+
+
+
+
+
+ {{ getFeedbackReceivedCaption(criteria.feedback_assessment) }}
+
+
+
+
+
+
diff --git a/client/src/components/selfEvaluationFeedback/FeedbackRequested.vue b/client/src/components/selfEvaluationFeedback/FeedbackRequested.vue
new file mode 100644
index 00000000..0f3028f5
--- /dev/null
+++ b/client/src/components/selfEvaluationFeedback/FeedbackRequested.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
+ {{ completion.title }}
+
+ {{ $t("a.Bearbeiten") }}
+
+
+
+
+ {{ $t("selfEvaluation.yes") }}
+
+
+
+ {{ $t("selfEvaluation.no") }}
+
+
+
+ {{ $t("a.Nicht bewertet") }}
+
+
+
+
+
diff --git a/client/src/components/selfEvaluationFeedback/FeedbackRequestedInformationPanel.vue b/client/src/components/selfEvaluationFeedback/FeedbackRequestedInformationPanel.vue
new file mode 100644
index 00000000..9baf746d
--- /dev/null
+++ b/client/src/components/selfEvaluationFeedback/FeedbackRequestedInformationPanel.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+ {{
+ $t("a.Du hast deine Selbsteinschätzung erfolgreich mit FULL_NAME geteilt.", {
+ FULL_NAME: feedbackMentorName,
+ })
+ }}
+
+
+
+ {{
+ $t(
+ "a.FULL_NAME wird eine Fremdeinschätzung für dich vornehmen. Du wirst per Benachrichtigung informiert, sobald die Fremdeinschätzung für dich freigegeben wurde.",
+ { FULL_NAME: feedbackMentorName }
+ )
+ }}
+
+
diff --git a/client/src/components/selfEvaluationFeedback/SmileyCell.vue b/client/src/components/selfEvaluationFeedback/SmileyCell.vue
new file mode 100644
index 00000000..f6ca1601
--- /dev/null
+++ b/client/src/components/selfEvaluationFeedback/SmileyCell.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/ui/ItDropdownSelect.vue b/client/src/components/ui/ItDropdownSelect.vue
index 08f522ea..8a9c3139 100644
--- a/client/src/components/ui/ItDropdownSelect.vue
+++ b/client/src/components/ui/ItDropdownSelect.vue
@@ -1,7 +1,7 @@
+
+
+
+
+
+ {{ $t("a.Selbsteinschätzung") }}: {{ selfEvaluationFeedback.title }}
+
+
+ Circle «{{
+ mentorCockpitStore.getCircleTitleById(selfEvaluationFeedback.circle_id)
+ }}»
+
+
+
+
+ {{ selfEvaluationFeedback.pending_evaluations }}
+
+
{{ $t("a.Selbsteinschätzungen geteilt") }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ getParticipantById(item.user_id)?.first_name }}
+ {{ getParticipantById(item.user_id)?.last_name }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("a.Selbsteinschätzung geteilt") }}
+
+
+
+
+
+
+
+ {{ $t("a.Fremdeinschätzung freigeben") }}
+
+
+
+
+
+ {{ $t("a.Fremdeinschätzung vornehmen") }}
+
+
+ {{ $t("a.Selbsteinschätzung anzeigen") }}
+
+
+
+
+
+
+
diff --git a/client/src/pages/cockpit/cockpitPage/mentor/SelfEvaluationFeedbackPage.vue b/client/src/pages/cockpit/cockpitPage/mentor/SelfEvaluationFeedbackPage.vue
new file mode 100644
index 00000000..e58125a2
--- /dev/null
+++ b/client/src/pages/cockpit/cockpitPage/mentor/SelfEvaluationFeedbackPage.vue
@@ -0,0 +1,161 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/pages/competence/CompetenceIndexPage.vue b/client/src/pages/competence/CompetenceIndexPage.vue
index 5ed995cf..9243279d 100644
--- a/client/src/pages/competence/CompetenceIndexPage.vue
+++ b/client/src/pages/competence/CompetenceIndexPage.vue
@@ -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
+);
-
-
{{ $t("a.KompetenzNavi") }}
-
-
+
+
+
{{ $t("a.Kompetenznachweise") }}
@@ -80,7 +105,7 @@ const performanceCriteriaStatusCount = computed(() => {
@@ -130,59 +155,96 @@ const performanceCriteriaStatusCount = computed(() => {
+
-
- {{ $t("a.Selbsteinschätzungen") }}
-
-
-
+
+ {{ $t("a.Selbsteinschätzungen") }}
+
+
-
+ «{{ $t("selfEvaluation.no") }}»
+
+
+
+ {{ selfAssessmentCounts?.fail }}
+
+
+
+
+ «{{ $t("selfEvaluation.yes") }}»
+
+
+
+ {{ selfAssessmentCounts?.pass }}
+
+
+
+
+ {{ $t("competences.notAssessed") }}
+
+
+
+ {{ selfAssessmentCounts?.unknown }}
+
+
+
+
+
+
+
+
+
+ {{ $t("a.Fremdeinschätzungen") }}
+
+
+
+ «{{ $t("receivedEvaluation.no") }}»
+
+
+
+ {{ feedbackEvaluationCounts?.fail }}
+
+
+
+
+ «{{ $t("receivedEvaluation.yes") }}»
+
+
+
+ {{ feedbackEvaluationCounts?.pass }}
+
+
+
+
+ {{ $t("competences.notAssessed") }}
+
+
+
+ {{ feedbackEvaluationCounts?.unknown }}
+
+
+
+
+
{{ $t("general.showAll") }}
diff --git a/client/src/pages/competence/CompetenceParentPage.vue b/client/src/pages/competence/CompetenceParentPage.vue
index 0b96ec43..6fd99dbc 100644
--- a/client/src/pages/competence/CompetenceParentPage.vue
+++ b/client/src/pages/competence/CompetenceParentPage.vue
@@ -1,7 +1,9 @@
-
-
-
-
{{ $t("a.Selbsteinschätzungen") }}
-
-
-
- {{ $t("a.Circle") }}
- {{ selfEvaluation.circleTitle }}:
- {{ selfEvaluation.luTitle }}
-
-
-
-
-
-
- {{ selfEvaluation.countFail }}
-
-
-
-
-
- {{ selfEvaluation.countSuccess }}
-
-
-
-
-
- {{ selfEvaluation.countUnknown }}
-
-
-
-
-
-
- {{ $t("a.Selbsteinschätzung anschauen") }}
-
-
-
-
-
-
-
-
diff --git a/client/src/pages/competence/SelfEvaluationAndFeedbackPage.vue b/client/src/pages/competence/SelfEvaluationAndFeedbackPage.vue
new file mode 100644
index 00000000..6e655cb0
--- /dev/null
+++ b/client/src/pages/competence/SelfEvaluationAndFeedbackPage.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
{{ headerTitle }}
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/pages/dashboard/ProgressPage.vue b/client/src/pages/dashboard/ProgressPage.vue
index b3aad0e1..c1087ab8 100644
--- a/client/src/pages/dashboard/ProgressPage.vue
+++ b/client/src/pages/dashboard/ProgressPage.vue
@@ -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(() => {
diff --git a/client/src/pages/learningPath/learningContentPage/LearningContentContainer.vue b/client/src/pages/learningPath/learningContentPage/LearningContentContainer.vue
index 83004cf4..194a9864 100644
--- a/client/src/pages/learningPath/learningContentPage/LearningContentContainer.vue
+++ b/client/src/pages/learningPath/learningContentPage/LearningContentContainer.vue
@@ -12,7 +12,7 @@ defineEmits(["exit"]);
-
+
import * as log from "loglevel";
-import SelfEvaluation from "@/pages/learningPath/selfEvaluationPage/SelfEvaluation.vue";
+import SelfEvaluation from "@/components/learningPath/SelfEvaluation.vue";
import { computed } from "vue";
import { useCourseDataWithCompletion } from "@/composables";
@@ -14,9 +14,9 @@ const props = defineProps<{
}>();
const courseData = useCourseDataWithCompletion(props.courseSlug);
-const learningUnit = computed(() =>
- courseData.findLearningUnit(props.learningUnitSlug, props.circleSlug)
-);
+const learningUnit = computed(() => {
+ return courseData.findLearningUnit(props.learningUnitSlug, props.circleSlug);
+});
const circle = computed(() => {
return courseData.findCircle(props.circleSlug);
});
diff --git a/client/src/pages/learningPath/selfEvaluationPage/SelfEvaluationRequestFeedbackPage.vue b/client/src/pages/learningPath/selfEvaluationPage/SelfEvaluationRequestFeedbackPage.vue
new file mode 100644
index 00000000..737a9878
--- /dev/null
+++ b/client/src/pages/learningPath/selfEvaluationPage/SelfEvaluationRequestFeedbackPage.vue
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+ {{ $t("a.Selbsteinschätzung teilen") }}
+
+
+
+
+
+ {{
+ $t(
+ "a.Du kannst deine Selbsteinschätzung mit deiner Lernbegleitung teilen, damit sie eine Fremdeinschätzung vornimmt."
+ )
+ }}
+
+
+
+
+ {{ $t("a.Selbsteinschätzung teilen") }}
+
+
+ {{
+ $t("a.Selbsteinschätzung mit MENTOR_NAME teilen", {
+ MENTOR_NAME: currentSessionRequestedMentor?.name,
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/router/index.ts b/client/src/router/index.ts
index e157b92d..3a46207c 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,
@@ -106,9 +105,10 @@ const router = createRouter({
import("@/pages/competence/CompetenceCertificateDetailPage.vue"),
},
{
- path: "criteria",
+ path: "self-evaluation-and-feedback",
props: true,
- component: () => import("@/pages/competence/PerformanceCriteriaPage.vue"),
+ component: () =>
+ import("@/pages/competence/SelfEvaluationAndFeedbackPage.vue"),
},
{
path: "competences",
@@ -180,7 +180,7 @@ const router = createRouter({
{
path: "",
component: () =>
- import("@/pages/cockpit/cockpitPage/mentor/MentorOverview.vue"),
+ import("@/pages/cockpit/cockpitPage/mentor/MentorOverviewPage.vue"),
name: "mentorCockpitOverview",
meta: {
cockpitType: "mentor",
@@ -189,12 +189,24 @@ const router = createRouter({
{
path: "participants",
component: () =>
- import("@/pages/cockpit/cockpitPage/mentor/MentorParticipants.vue"),
+ import("@/pages/cockpit/cockpitPage/mentor/MentorParticipantsPage.vue"),
name: "mentorCockpitParticipants",
meta: {
cockpitType: "mentor",
},
},
+ {
+ path: "self-evaluation-feedback/:learningUnitId",
+ component: () =>
+ import(
+ "@/pages/cockpit/cockpitPage/mentor/SelfEvaluationFeedbackPage.vue"
+ ),
+ name: "mentorSelfEvaluationFeedback",
+ meta: {
+ cockpitType: "mentor",
+ },
+ props: true,
+ },
{
path: "details",
component: () =>
@@ -207,7 +219,7 @@ const router = createRouter({
path: "praxis-assignments/:praxisAssignmentId",
component: () =>
import(
- "@/pages/cockpit/cockpitPage/mentor/MentorPraxisAssignment.vue"
+ "@/pages/cockpit/cockpitPage/mentor/MentorPraxisAssignmentPage.vue"
),
name: "mentorCockpitPraxisAssignments",
meta: {
@@ -215,6 +227,18 @@ const router = createRouter({
},
props: true,
},
+ {
+ path: "self-evaluation-feedback-assignments/:learningUnitId",
+ component: () =>
+ import(
+ "@/pages/cockpit/cockpitPage/mentor/MentorSelfEvaluationFeedbackAssignmentPage.vue"
+ ),
+ name: "mentorCockpitSelfEvaluationFeedbackAssignments",
+ meta: {
+ cockpitType: "mentor",
+ },
+ props: true,
+ },
],
},
],
diff --git a/client/src/services/mentorCockpit.ts b/client/src/services/mentorCockpit.ts
index 0818ae81..d2578974 100644
--- a/client/src/services/mentorCockpit.ts
+++ b/client/src/services/mentorCockpit.ts
@@ -30,7 +30,7 @@ interface Completion {
url: string;
}
-export interface PraxisAssignment {
+export interface Assignment {
id: string;
title: string;
circle_id: string;
@@ -42,7 +42,7 @@ export interface PraxisAssignment {
interface Summary {
participants: Participant[];
circles: Circle[];
- assignments: PraxisAssignment[];
+ assignments: Assignment[];
}
export const useMentorCockpit = (
@@ -60,7 +60,7 @@ export const useMentorCockpit = (
return "";
};
- const getPraxisAssignmentById = (id: string): PraxisAssignment | null => {
+ const getAssignmentById = (id: string): Assignment | null => {
if (summary.value?.assignments) {
const found = summary.value.assignments.find(
(assignment) => assignment.id === id
@@ -93,7 +93,7 @@ export const useMentorCockpit = (
summary,
error,
getCircleTitleById,
- getPraxisAssignmentById,
fetchData,
+ getAssignmentById,
};
};
diff --git a/client/src/services/selfEvaluationFeedback.ts b/client/src/services/selfEvaluationFeedback.ts
new file mode 100644
index 00000000..5e300690
--- /dev/null
+++ b/client/src/services/selfEvaluationFeedback.ts
@@ -0,0 +1,268 @@
+import { useCSRFFetch } from "@/fetchHelpers";
+import type { User } from "@/types";
+import { toValue } from "@vueuse/core";
+import { t } from "i18next";
+import log from "loglevel";
+import type { Ref } from "vue";
+import { computed, onMounted, ref } from "vue";
+
+export interface FeedbackRequest {
+ feedback_id: string;
+ learning_unit_id: number;
+ circle_name: string;
+ title: string;
+ // submitted => provider submitted (released) his/her feedback
+ feedback_submitted: boolean;
+ feedback_requester_user: User;
+ feedback_provider_user: User;
+ criteria: Criterion[];
+}
+
+export interface Criterion {
+ course_completion_id: string;
+ title: string;
+ self_assessment: "FAIL" | "SUCCESS" | "UNKNOWN";
+ feedback_assessment: "FAIL" | "SUCCESS" | "UNKNOWN";
+}
+
+interface FeedbackSummaryCounts {
+ pass: number;
+ fail: number;
+ unknown: number;
+}
+
+export interface FeedbackSummaryAggregates {
+ // totals across all learning units in the course session
+ self_assessment: FeedbackSummaryCounts;
+ feedback_assessment: FeedbackSummaryCounts;
+ // does this course have any feedback?
+ feedback_assessment_visible: boolean;
+}
+
+interface FeedbackAssessmentSummary {
+ counts: FeedbackSummaryCounts;
+ submitted_by_provider: boolean;
+ provider_user: User;
+}
+
+interface SelfAssessmentSummary {
+ counts: FeedbackSummaryCounts;
+}
+
+export interface LearningUnitSummary {
+ id: string;
+ title: string;
+ circle_id: string;
+ circle_title: string;
+ feedback_assessment?: FeedbackAssessmentSummary;
+ self_assessment: SelfAssessmentSummary;
+ detail_url: string;
+}
+
+interface Circle {
+ id: number;
+ title: string;
+}
+
+/** To keep the backend permissions model simple, we have two endpoints:
+ * 1. /requester/: for the user who requested the feedback
+ * 2. /provider/: for the user who provides the feedback
+ *
+ * Design decision: We generally just re-fetch the whole feedback from the backend
+ * after each action (e.g. request, release, add-assessment) to keep the frontend simple.
+ */
+export function useSelfEvaluationFeedback(
+ learningUnitId: Ref
| string,
+ feedbackRole: "requester" | "provider"
+) {
+ const feedback = ref();
+ const loading = ref(false);
+ const error = ref();
+
+ const url = computed(
+ () => `/api/self-evaluation-feedback/${feedbackRole}/${learningUnitId}/feedback`
+ );
+
+ const fetchFeedback = async () => {
+ error.value = undefined;
+ loading.value = true;
+
+ log.info("Fetching feedback for learning unit", learningUnitId);
+ const { data, statusCode, error: _error } = await useCSRFFetch(url.value).json();
+ loading.value = false;
+
+ if (_error.value) {
+ error.value = _error;
+ feedback.value = undefined;
+ return;
+ }
+
+ if (statusCode.value === 404) {
+ feedback.value = undefined;
+ } else {
+ feedback.value = data.value;
+ }
+ };
+
+ const requestFeedback = async (fromProviderUserId: string) => {
+ if (feedbackRole !== "requester") {
+ console.warn("Cannot request feedback");
+ return;
+ }
+
+ const url = `/api/self-evaluation-feedback/requester/${toValue(
+ learningUnitId
+ )}/feedback/start`;
+ await useCSRFFetch(url).post({
+ feedback_provider_user_id: fromProviderUserId,
+ });
+
+ await fetchFeedback();
+ };
+
+ const addFeedbackAssessment = async (
+ courseCompletionId: string,
+ assessment: "FAIL" | "SUCCESS"
+ ) => {
+ if (feedbackRole !== "provider" || !feedback.value) {
+ console.warn("Cannot add feedback assessment");
+ return;
+ }
+
+ await useCSRFFetch(
+ `/api/self-evaluation-feedback/provider/feedback/${feedback.value.feedback_id}/add-assessment`
+ ).put({
+ course_completion_id: courseCompletionId,
+ feedback_assessment: assessment,
+ });
+
+ await fetchFeedback();
+ };
+
+ const releaseFeedback = async () => {
+ if (feedbackRole !== "provider" || !feedback.value) {
+ console.warn("Cannot release feedback");
+ return;
+ }
+
+ await useCSRFFetch(
+ `/api/self-evaluation-feedback/provider/feedback/${feedback.value.feedback_id}/release`
+ ).put({});
+
+ await fetchFeedback();
+ };
+
+ onMounted(fetchFeedback);
+
+ return {
+ feedback,
+ error,
+ loading,
+ // feedback requester actions
+ requestFeedback,
+ // feedback provider actions
+ addFeedbackAssessment,
+ releaseFeedback,
+ };
+}
+
+export function useSelfEvaluationFeedbackSummaries(
+ courseSessionId: Ref | string
+) {
+ 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":
+ return "it-icon-smiley-happy";
+ case "FAIL":
+ return "it-icon-smiley-thinking";
+ default:
+ return "it-icon-smiley-neutral";
+ }
+};
+
+export const getSelfEvaluationCaption = (
+ assessment: "FAIL" | "SUCCESS" | "UNKNOWN"
+) => {
+ switch (assessment) {
+ case "SUCCESS":
+ return t("selfEvaluation.yes");
+ case "FAIL":
+ return t("selfEvaluation.no");
+ case "UNKNOWN":
+ return t("a.Nicht bewertet");
+ }
+};
+
+export const getFeedbackReceivedCaption = (
+ assessment: "FAIL" | "SUCCESS" | "UNKNOWN"
+) => {
+ switch (assessment) {
+ case "SUCCESS":
+ return t("receivedEvaluation.yes");
+ case "FAIL":
+ return t("receivedEvaluation.no");
+ case "UNKNOWN":
+ return t("a.Nicht bewertet");
+ }
+};
+
+export const getFeedbackEvaluationCaption = (
+ assessment: "FAIL" | "SUCCESS" | "UNKNOWN",
+ requester: User
+) => {
+ switch (assessment) {
+ case "SUCCESS":
+ return t("a.Ja, NAME kann das.", {
+ NAME: requester.first_name,
+ });
+ case "FAIL":
+ return t("a.Nein, NAME muss das nochmals anschauen.", {
+ NAME: requester.first_name,
+ });
+ case "UNKNOWN":
+ return t("a.Nicht bewertet");
+ }
+};
diff --git a/client/src/types.ts b/client/src/types.ts
index 519df057..ebde7f0d 100644
--- a/client/src/types.ts
+++ b/client/src/types.ts
@@ -457,6 +457,17 @@ export interface ExpertSessionUser extends CourseSessionUser {
role: "EXPERT";
}
+export interface Mentor {
+ id: number;
+ first_name: string;
+ last_name: string;
+}
+
+export interface LearningMentor {
+ id: number;
+ mentor: Mentor;
+}
+
export type CourseSessionDetail = CourseSessionObjectType;
// document upload
@@ -579,3 +590,16 @@ export interface FeedbackData {
};
feedbackType: FeedbackType;
}
+
+export type User = {
+ id: string;
+ first_name: string;
+ last_name: string;
+ email: string;
+ username: string;
+ avatar_url: string;
+ organisation: string | null;
+ is_superuser: boolean;
+ course_session_experts: any[];
+ language: string;
+};
diff --git a/cypress/e2e/circle.cy.js b/cypress/e2e/circle.cy.js
index de1185a1..98d41b73 100644
--- a/cypress/e2e/circle.cy.js
+++ b/cypress/e2e/circle.cy.js
@@ -9,71 +9,71 @@ describe("circle.cy.js", () => {
});
it("can open circle page", () => {
- cy.get("[data-cy=\"circle-title\"]").should("contain", "Fahrzeug");
+ cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug");
});
it("can toggle learning content", () => {
- cy.get("[data-cy=\"circle-title\"]").should("contain", "Fahrzeug");
+ cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug");
cy.get(
- "[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox\"]"
+ '[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox"]'
).should("have.class", "cy-unchecked");
cy.get(
- "[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox\"]"
+ '[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox"]'
).click();
cy.get(
- "[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox\"]"
+ '[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox"]'
).should("have.class", "cy-checked");
// completion data should still be there after reload
cy.reload();
cy.get(
- "[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox\"]"
+ '[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox"]'
).should("have.class", "cy-checked");
});
it("can open learning contents and complete them by continuing", () => {
cy.get(
- "[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-verschaffe-dir-einen-überblick\"]"
+ '[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-verschaffe-dir-einen-überblick"]'
).click();
- cy.get("[data-cy=\"lc-title\"]").should(
+ cy.get('[data-cy="lc-title"]').should(
"contain",
"Verschaffe dir einen Überblick"
);
- cy.get("[data-cy=\"complete-and-continue\"]").click({ force: true });
- cy.get("[data-cy=\"circle-title\"]").should("contain", "Fahrzeug");
+ cy.get('[data-cy="complete-and-continue"]').click({ force: true });
+ cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug");
- cy.get("[data-cy=\"ls-continue-button\"]").click({ force: true });
- cy.get("[data-cy=\"lc-title\"]").should(
+ cy.get('[data-cy="ls-continue-button"]').click();
+ cy.get('[data-cy="lc-title"]').should(
"contain",
"Handlungsfeld «Fahrzeug»"
);
- cy.get("[data-cy=\"complete-and-continue\"]").click({ force: true });
- cy.get("[data-cy=\"circle-title\"]").should("contain", "Fahrzeug");
+ cy.get('[data-cy="complete-and-continue"]').click({ force: true });
+ cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug");
cy.get(
- "[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-verschaffe-dir-einen-überblick-checkbox\"]"
+ '[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-verschaffe-dir-einen-überblick-checkbox"]'
).should("have.class", "cy-checked");
cy.get(
- "[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox\"]"
+ '[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox"]'
).should("have.class", "cy-checked");
});
it("continue button works", () => {
- cy.get("[data-cy=\"ls-continue-button\"]").should("contain", "Los geht's");
- cy.get("[data-cy=\"ls-continue-button\"]").click();
+ cy.get('[data-cy="ls-continue-button"]').should("contain", "Los geht's");
+ cy.get('[data-cy="ls-continue-button"]').click();
- cy.get("[data-cy=\"lc-title\"]").should(
+ cy.get('[data-cy="lc-title"]').should(
"contain",
"Verschaffe dir einen Überblick"
);
- cy.get("[data-cy=\"complete-and-continue\"]").click({ force: true });
+ cy.get('[data-cy="complete-and-continue"]').click({ force: true });
- cy.get("[data-cy=\"ls-continue-button\"]").should("contain", "Weiter geht's");
- cy.get("[data-cy=\"ls-continue-button\"]").click();
- cy.get("[data-cy=\"lc-title\"]").should(
+ cy.get('[data-cy="ls-continue-button"]').should("contain", "Weiter geht's");
+ cy.get('[data-cy="ls-continue-button"]').click();
+ cy.get('[data-cy="lc-title"]').should(
"contain",
"Handlungsfeld «Fahrzeug»"
);
@@ -81,43 +81,43 @@ describe("circle.cy.js", () => {
it("can open learning content by url", () => {
cy.visit("/course/test-lehrgang/learn/fahrzeug/handlungsfeld-fahrzeug");
- cy.get("[data-cy=\"lc-title\"]").should(
+ cy.get('[data-cy="lc-title"]').should(
"contain",
"Handlungsfeld «Fahrzeug»"
);
- cy.get("[data-cy=\"close-learning-content\"]").click();
- cy.get("[data-cy=\"circle-title\"]").should("contain", "Fahrzeug");
+ cy.get('[data-cy="close-learning-content"]').click();
+ cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug");
});
it("checks number of sequences and contents", () => {
- cy.get("[data-cy=\"lp-learning-sequence\"]").should("have.length", 3);
- cy.get("[data-cy=\"lp-learning-sequence\"]")
+ cy.get('[data-cy="lp-learning-sequence"]').should("have.length", 3);
+ cy.get('[data-cy="lp-learning-sequence"]')
.first()
.should("contain", "Vorbereitung");
- cy.get("[data-cy=\"lp-learning-sequence\"]")
+ cy.get('[data-cy="lp-learning-sequence"]')
.eq(1)
.should("contain", "Training");
- cy.get("[data-cy=\"lp-learning-sequence\"]")
+ cy.get('[data-cy="lp-learning-sequence"]')
.last()
.should("contain", "Transfer");
- cy.get("[data-cy=\"lp-learning-content\"]").should("have.length", 10);
- cy.get("[data-cy=\"lp-learning-content\"]")
+ cy.get('[data-cy="lp-learning-content"]').should("have.length", 10);
+ cy.get('[data-cy="lp-learning-content"]')
.first()
.should("contain", "Verschaffe dir einen Überblick");
- cy.get("[data-cy=\"lp-learning-content\"]")
+ cy.get('[data-cy="lp-learning-content"]')
.eq(4)
.should("contain", "Präsenzkurs Fahrzeug");
- cy.get("[data-cy=\"lp-learning-content\"]")
+ cy.get('[data-cy="lp-learning-content"]')
.eq(7)
.should("contain", "Reflexion");
- cy.get("[data-cy=\"lp-learning-content\"]")
+ cy.get('[data-cy="lp-learning-content"]')
.last()
.should("contain", "Feedback");
cy.visit("/course/test-lehrgang/learn/reisen");
- cy.get("[data-cy=\"lp-learning-sequence\"]").should("have.length", 3);
- cy.get("[data-cy=\"lp-learning-content\"]").should("have.length", 9);
+ cy.get('[data-cy="lp-learning-sequence"]').should("have.length", 3);
+ cy.get('[data-cy="lp-learning-content"]').should("have.length", 9);
});
});
diff --git a/cypress/e2e/competenceNavi/selfEvaluation.cy.js b/cypress/e2e/competenceNavi/selfEvaluation.cy.js
index 76b128c0..60ff51b5 100644
--- a/cypress/e2e/competenceNavi/selfEvaluation.cy.js
+++ b/cypress/e2e/competenceNavi/selfEvaluation.cy.js
@@ -1,4 +1,4 @@
-import { login } from "../helpers";
+import {login} from "../helpers";
describe("selfEvaluation.cy.js", () => {
beforeEach(() => {
@@ -28,37 +28,28 @@ describe("selfEvaluation.cy.js", () => {
cy.get('[data-cy="self-evaluation-success"]').should("have.text", "0");
cy.get('[data-cy="self-evaluation-unknown"]').should("have.text", "4");
+
+ // learning unit id = 687 also known as:
+ // Bedarfsanalyse, Ist- und Soll-Situation <>
+ const identifier = "self-eval-687"
+
// data in KompetenzNavi/Selbsteinschätzungen is correct
- cy.visit("/course/test-lehrgang/competence/criteria");
- cy.get('[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-fail"]').should(
- "have.text",
- "0"
- );
- cy.get(
- '[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-success"]'
- ).should("have.text", "0");
- cy.get(
- '[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-unknown"]'
- ).should("have.text", "2");
+ cy.visit("/course/test-lehrgang/competence/self-evaluation-and-feedback");
+ cy.get(`[data-cy="${identifier}-fail"]`).should("not.exist");
+ cy.get(`[data-cy="${identifier}-pass"]`).should("not.exist");
+ cy.get(`[data-cy="${identifier}-unknown"]`).should("have.text", "2");
// it can open self evaluation from within KompetenzNavi
- cy.get('[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-open"]').click();
+ cy.get(`[data-cy="${identifier}-detail-url"]`).click();
// starting the self evaluation will return to KompetenzNavi
cy.makeSelfEvaluation([true, false]);
- cy.url().should("include", "/course/test-lehrgang/competence/criteria");
+ cy.url().should("include", "/course/test-lehrgang/competence/self-evaluation-and-feedback");
// check data again on KompetenzNavi
- cy.get('[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-fail"]').should(
- "have.text",
- "1"
- );
- cy.get(
- '[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-success"]'
- ).should("have.text", "1");
- cy.get(
- '[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-unknown"]'
- ).should("have.text", "0");
+ cy.get(`[data-cy="${identifier}-fail"]`).should("have.text", "1");
+ cy.get(`[data-cy="${identifier}-pass"]`).should("have.text", "1");
+ cy.get(`[data-cy="${identifier}-unknown"]`).should("not.exist");
// data in KompetenzNavi/Übersicht is correct
cy.visit("/course/test-lehrgang/competence");
@@ -76,19 +67,6 @@ describe("selfEvaluation.cy.js", () => {
// starting the self evaluation from circle should return to circle
cy.url().should("include", "/course/test-lehrgang/learn/reisen");
-
- // data in KompetenzNavi / Selbsteinschätzungen is correct
- cy.visit("/course/test-lehrgang/competence/criteria");
- cy.get('[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-fail"]').should(
- "have.text",
- "0"
- );
- cy.get(
- '[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-success"]'
- ).should("have.text", "2");
- cy.get(
- '[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-unknown"]'
- ).should("have.text", "0");
});
it("should be able to make a fail self evaluation", () => {
@@ -97,19 +75,6 @@ describe("selfEvaluation.cy.js", () => {
cy.get('[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen"]')
.find('[data-cy="fail"]')
.should("exist");
-
- // data in KompetenzNavi / Selbsteinschätzungen is correct
- cy.visit("/course/test-lehrgang/competence/criteria");
- cy.get('[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-fail"]').should(
- "have.text",
- "2"
- );
- cy.get(
- '[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-success"]'
- ).should("have.text", "0");
- cy.get(
- '[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-unknown"]'
- ).should("have.text", "0");
});
it("should be able to make a mixed self evaluation", () => {
@@ -118,18 +83,5 @@ describe("selfEvaluation.cy.js", () => {
cy.get('[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen"]')
.find('[data-cy="fail"]')
.should("exist");
-
- // data in KompetenzNavi / Selbsteinschätzungen is correct
- cy.visit("/course/test-lehrgang/competence/criteria");
- cy.get('[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-fail"]').should(
- "have.text",
- "1"
- );
- cy.get(
- '[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-success"]'
- ).should("have.text", "1");
- cy.get(
- '[data-cy="test-lehrgang-lp-circle-reisen-lu-reisen-unknown"]'
- ).should("have.text", "0");
});
});
diff --git a/server/config/settings/base.py b/server/config/settings/base.py
index f4721dde..358cccb1 100644
--- a/server/config/settings/base.py
+++ b/server/config/settings/base.py
@@ -133,6 +133,7 @@ LOCAL_APPS = [
"vbv_lernwelt.course_session_group",
"vbv_lernwelt.shop",
"vbv_lernwelt.learning_mentor",
+ "vbv_lernwelt.self_evaluation_feedback",
]
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
diff --git a/server/config/urls.py b/server/config/urls.py
index 56b9ad07..42917ad9 100644
--- a/server/config/urls.py
+++ b/server/config/urls.py
@@ -25,6 +25,7 @@ from vbv_lernwelt.core.views import (
check_rate_limit,
cypress_reset_view,
generate_web_component_icons,
+ iterativ_test_coursesessions_reset_view,
permission_denied_view,
rate_limit_exceeded_view,
vue_home,
@@ -141,6 +142,9 @@ urlpatterns = [
path("api/mentor//", include("vbv_lernwelt.learning_mentor.urls")),
+ # self evaluation feedback
+ path("api/self-evaluation-feedback/", include("vbv_lernwelt.self_evaluation_feedback.urls")),
+
# assignment
path(
r"api/assignment///status/",
@@ -206,6 +210,13 @@ urlpatterns = [
name="t2l_sync",
),
+ # iterativ Test course sessions
+ path(
+ r"api/core/resetiterativsessions/",
+ iterativ_test_coursesessions_reset_view,
+ name="iterativ_test_coursesessions_reset_view",
+ ),
+
path("server/graphql/",
csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema))),
# testing and debug
diff --git a/server/vbv_lernwelt/api/tests/test_entities_api.py b/server/vbv_lernwelt/api/tests/test_entities_api.py
index 3ca843fc..ba1d0f9f 100644
--- a/server/vbv_lernwelt/api/tests/test_entities_api.py
+++ b/server/vbv_lernwelt/api/tests/test_entities_api.py
@@ -3,7 +3,7 @@ from rest_framework import status
from rest_framework.test import APITestCase
from vbv_lernwelt.core.model_utils import add_countries, add_organisations
-from vbv_lernwelt.core.models import Organisation, User
+from vbv_lernwelt.core.models import Country, Organisation, User
class EntitiesViewTest(APITestCase):
@@ -15,7 +15,7 @@ class EntitiesViewTest(APITestCase):
add_organisations()
add_countries()
- def test_list_entities(self) -> None:
+ def test_list_organisation_entities(self) -> None:
# It seems that different locales handle ordering differently (especially with lower case letters)
# As such we delete entries that start with lower case letters
Organisation.objects.filter(organisation_id__in=[1, 2, 3]).delete()
@@ -51,3 +51,49 @@ class EntitiesViewTest(APITestCase):
"name": "Afghanistan",
},
)
+
+ def test_list_country_entities_ordered_by_country_id(self) -> None:
+ # GIVEN
+ url = reverse("list_entities")
+
+ first_country = Country.objects.get(country_id=1)
+
+ # WHEN
+ response = self.client.get(url)
+
+ # THEN
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ countries = response.data["countries"]
+
+ self.assertEqual(
+ countries[0],
+ {
+ "id": first_country.country_id,
+ "name": first_country.name_de,
+ },
+ )
+
+ def test_list_country_entities_ordered_by_order_id(self) -> None:
+ # GIVEN
+ url = reverse("list_entities")
+
+ switzerland = Country.objects.get(name_de="Schweiz")
+ switzerland.order_id = 1
+ switzerland.save()
+
+ # WHEN
+ response = self.client.get(url)
+
+ # THEN
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ countries = response.data["countries"]
+
+ self.assertEqual(
+ countries[0],
+ {
+ "id": switzerland.country_id,
+ "name": switzerland.name_de,
+ },
+ )
diff --git a/server/vbv_lernwelt/assignment/migrations/0012_auto_20240124_1004.py b/server/vbv_lernwelt/assignment/migrations/0012_auto_20240124_1004.py
new file mode 100644
index 00000000..0489bb0b
--- /dev/null
+++ b/server/vbv_lernwelt/assignment/migrations/0012_auto_20240124_1004.py
@@ -0,0 +1,36 @@
+# Generated by Django 3.2.20 on 2024-01-24 09:04
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("assignment", "0011_assignment_solution_sample"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="assignment",
+ name="assignment_type",
+ field=models.CharField(
+ choices=[
+ ("PRAXIS_ASSIGNMENT", "PRAXIS_ASSIGNMENT"),
+ ("CASEWORK", "CASEWORK"),
+ ("PREP_ASSIGNMENT", "PREP_ASSIGNMENT"),
+ ("REFLECTION", "REFLECTION"),
+ ("CONDITION_ACCEPTANCE", "CONDITION_ACCEPTANCE"),
+ ("EDONIQ_TEST", "EDONIQ_TEST"),
+ ],
+ default="CASEWORK",
+ max_length=50,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="assignment",
+ name="needs_expert_evaluation",
+ field=models.BooleanField(
+ default=False,
+ help_text="Muss der Auftrag durch eine/n Experten/in oder eine Lernbegleitung beurteilt werden?",
+ ),
+ ),
+ ]
diff --git a/server/vbv_lernwelt/core/admin.py b/server/vbv_lernwelt/core/admin.py
index d5a98ca1..bb09bea4 100644
--- a/server/vbv_lernwelt/core/admin.py
+++ b/server/vbv_lernwelt/core/admin.py
@@ -122,6 +122,7 @@ class OrganisationAdmin(admin.ModelAdmin):
@admin.register(Country)
class CountryAdmin(admin.ModelAdmin):
list_display = (
+ "order_id",
"country_id",
"name_de",
"name_fr",
diff --git a/server/vbv_lernwelt/core/constants.py b/server/vbv_lernwelt/core/constants.py
index 56f0495f..599ff03b 100644
--- a/server/vbv_lernwelt/core/constants.py
+++ b/server/vbv_lernwelt/core/constants.py
@@ -25,6 +25,7 @@ TEST_STUDENT1_USER_ID = "65c73ad0-6d53-43a9-a4a4-64143f27b03a"
TEST_STUDENT2_USER_ID = "19c40d94-15cc-4198-aaad-ef707c4b0900"
TEST_STUDENT3_USER_ID = "bcf94dba-53bc-474b-a22d-e4af39aa042b"
TEST_MENTOR1_USER_ID = "d1f5f5a9-5b0a-4e1a-9e1a-9e9b5b5e1b1b"
+TEST_STUDENT1_VV_USER_ID = "5ff59857-8de5-415e-a387-4449f9a0337a"
TEST_COURSE_SESSION_BERN_ID = -1
TEST_COURSE_SESSION_ZURICH_ID = -2
diff --git a/server/vbv_lernwelt/core/create_default_users.py b/server/vbv_lernwelt/core/create_default_users.py
index a401ba6c..e0560d34 100644
--- a/server/vbv_lernwelt/core/create_default_users.py
+++ b/server/vbv_lernwelt/core/create_default_users.py
@@ -13,6 +13,7 @@ from vbv_lernwelt.core.constants import (
ADMIN_USER_ID,
TEST_MENTOR1_USER_ID,
TEST_STUDENT1_USER_ID,
+ TEST_STUDENT1_VV_USER_ID,
TEST_STUDENT2_USER_ID,
TEST_STUDENT3_USER_ID,
TEST_SUPERVISOR1_USER_ID,
@@ -210,9 +211,10 @@ def create_default_users(default_password="test", set_avatar=False):
last_name="Expert3",
)
_create_student_user(
+ id=TEST_STUDENT1_VV_USER_ID,
email="student-vv@eiger-versicherungen.ch",
- first_name="Student",
- last_name="VV",
+ first_name="Viktor",
+ last_name="Vollgas",
)
_create_student_user(
email="patrizia.huggel@eiger-versicherungen.ch",
@@ -364,10 +366,11 @@ def create_default_users(default_password="test", set_avatar=False):
_create_user(
_id=TEST_MENTOR1_USER_ID,
email="test-mentor1@example.com",
- first_name="[Mentor]",
- last_name="Mentor",
+ first_name="Micheala",
+ last_name="Weber-Mentor",
password=default_password,
language="de",
+ avatar_image="uk1.patrizia.huggel.jpg",
)
diff --git a/server/vbv_lernwelt/core/management/commands/cypress_reset.py b/server/vbv_lernwelt/core/management/commands/cypress_reset.py
index 8c3cd409..a495939a 100644
--- a/server/vbv_lernwelt/core/management/commands/cypress_reset.py
+++ b/server/vbv_lernwelt/core/management/commands/cypress_reset.py
@@ -9,12 +9,16 @@ from vbv_lernwelt.core.constants import (
TEST_COURSE_SESSION_BERN_ID,
TEST_MENTOR1_USER_ID,
TEST_STUDENT1_USER_ID,
+ TEST_STUDENT1_VV_USER_ID,
TEST_STUDENT2_USER_ID,
TEST_STUDENT3_USER_ID,
TEST_TRAINER1_USER_ID,
)
-from vbv_lernwelt.core.models import User
-from vbv_lernwelt.course.consts import COURSE_TEST_ID
+from vbv_lernwelt.core.models import Organisation, User
+from vbv_lernwelt.course.consts import (
+ COURSE_TEST_ID,
+ COURSE_VERSICHERUNGSVERMITTLERIN_ID,
+)
from vbv_lernwelt.course.creators.test_course import (
create_edoniq_test_result_data,
create_feedback_response_data,
@@ -39,6 +43,10 @@ from vbv_lernwelt.learnpath.models import (
LearningContentFeedbackVV,
)
from vbv_lernwelt.notify.models import Notification
+from vbv_lernwelt.self_evaluation_feedback.models import (
+ CourseCompletionFeedback,
+ SelfEvaluationFeedback,
+)
@click.command()
@@ -107,7 +115,11 @@ def command(
FeedbackResponse.objects.all().delete()
CourseSessionAttendanceCourse.objects.all().update(attendance_user_list=[])
+ SelfEvaluationFeedback.objects.all().delete()
+ CourseCompletionFeedback.objects.all().delete()
+
LearningMentor.objects.all().delete()
+ User.objects.all().update(organisation=Organisation.objects.first())
User.objects.all().update(language="de")
User.objects.all().update(additional_json_data={})
@@ -331,16 +343,30 @@ def command(
attendance_course.save()
if create_learning_mentor:
- print("Create learning mentor")
- mentor = LearningMentor.objects.create(
+ uk_mentor = LearningMentor.objects.create(
course=Course.objects.get(id=COURSE_TEST_ID),
mentor=User.objects.get(id=TEST_MENTOR1_USER_ID),
)
- course_session = CourseSession.objects.get(id=TEST_COURSE_SESSION_BERN_ID)
- csu = CourseSessionUser.objects.get(
- user__id=TEST_STUDENT1_USER_ID, course_session=course_session
+ uk_mentor.participants.add(
+ CourseSessionUser.objects.get(
+ user__id=TEST_STUDENT1_USER_ID,
+ course_session=CourseSession.objects.get(
+ id=TEST_COURSE_SESSION_BERN_ID
+ ),
+ )
+ )
+
+ vv_course = Course.objects.get(id=COURSE_VERSICHERUNGSVERMITTLERIN_ID)
+ vv_course_session = CourseSession.objects.get(course=vv_course)
+ vv_mentor = LearningMentor.objects.create(
+ course=vv_course,
+ mentor=User.objects.get(id=TEST_MENTOR1_USER_ID),
+ )
+ vv_mentor.participants.add(
+ CourseSessionUser.objects.get(
+ user__id=TEST_STUDENT1_VV_USER_ID, course_session=vv_course_session
+ )
)
- mentor.participants.add(csu)
course = Course.objects.get(id=COURSE_TEST_ID)
course.enable_circle_documents = enable_circle_documents
diff --git a/server/vbv_lernwelt/core/management/commands/reset_iterativ_test_sessions.py b/server/vbv_lernwelt/core/management/commands/reset_iterativ_test_sessions.py
new file mode 100644
index 00000000..ed690011
--- /dev/null
+++ b/server/vbv_lernwelt/core/management/commands/reset_iterativ_test_sessions.py
@@ -0,0 +1,336 @@
+from datetime import datetime, time, timedelta
+
+import djclick as click
+import structlog
+from django.utils import timezone
+
+from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletion
+from vbv_lernwelt.core.admin import User
+from vbv_lernwelt.course.consts import (
+ COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID,
+ COURSE_VERSICHERUNGSVERMITTLERIN_ID,
+ COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID,
+)
+from vbv_lernwelt.course.models import (
+ Course,
+ CourseCompletion,
+ CourseSession,
+ CourseSessionUser,
+)
+from vbv_lernwelt.course_session.models import (
+ CourseSessionAssignment,
+ CourseSessionAttendanceCourse,
+ CourseSessionEdoniqTest,
+)
+from vbv_lernwelt.course_session_group.models import CourseSessionGroup
+from vbv_lernwelt.feedback.models import FeedbackResponse
+from vbv_lernwelt.learning_mentor.models import LearningMentor
+from vbv_lernwelt.learnpath.models import Circle
+from vbv_lernwelt.notify.models import Notification
+
+logger = structlog.get_logger(__name__)
+from vbv_lernwelt.importer.services import (
+ create_or_update_course_session,
+ get_uk_course,
+ LP_DATA,
+ TRANSLATIONS,
+)
+
+IT_VV_TEST_COURSE = "Iterativ VV Testkurs"
+IT_UK_TEST_COURSE = "Iterativ üK Testkurs"
+IT_UK_TEST_REGION = "Iterativ Region"
+TIME_FORMAT = "%d.%m.%Y, %H:%M"
+PASSWORD = "KqaDm3-x8zhCKHLWDV_oiqFrYWHg"
+
+logger = structlog.get_logger(__name__)
+
+
+@click.command()
+def command():
+ create_or_update_uk()
+ create_or_update_vv()
+
+
+def create_or_update_uk(language="de"):
+ uk_course = get_uk_course(language)
+ uk_circle_keys = [
+ "Kickoff",
+ "Basis",
+ "Fahrzeug",
+ "Haushalt Teil 1",
+ "Haushalt Teil 2",
+ ]
+
+ data = create_uk_data(language)
+ create_or_update_course_session(
+ uk_course,
+ data,
+ language,
+ circle_keys=uk_circle_keys,
+ )
+ cs = CourseSession.objects.get(import_id=data["ID"])
+
+ members, trainer, regionenleiter = get_or_create_users_uk()
+ delete_cs_data(cs, members + [trainer, regionenleiter])
+
+ add_to_course_session(cs, members)
+ add_trainers_to_course_session(cs, [trainer], uk_circle_keys, language)
+ create_and_add_to_cs_group(cs.course, IT_UK_TEST_REGION, [cs], regionenleiter)
+
+
+def create_or_update_vv(language="de"):
+ vv_course = get_vv_course(language)
+
+ cs, _created = CourseSession.objects.get_or_create(
+ course=vv_course, import_id=IT_VV_TEST_COURSE
+ )
+ cs.title = IT_VV_TEST_COURSE
+ cs.save()
+
+ create_or_update_assignment_course_session(cs)
+ members, member_with_mentor, mentor = get_or_create_users_vv()
+ delete_cs_data(cs, members + [member_with_mentor, mentor])
+
+ add_to_course_session(cs, members + [member_with_mentor])
+ add_mentor_to_course_session(cs, [(mentor, member_with_mentor)])
+
+
+def delete_cs_data(cs: CourseSession, users: list[User]):
+ if cs:
+ CourseCompletion.objects.filter(course_session=cs).delete()
+ Notification.objects.filter(course_session=cs).delete()
+ AssignmentCompletion.objects.filter(course_session=cs).delete()
+ CourseSessionAttendanceCourse.objects.filter(course_session=cs).update(
+ attendance_user_list=[]
+ )
+ CourseSessionEdoniqTest.objects.filter(course_session=cs).delete()
+ CourseSessionUser.objects.filter(course_session=cs).delete()
+ learning_mentor_ids = (
+ LearningMentor.objects.filter(participants__course_session=cs)
+ .values_list("id", flat=True)
+ .distinct()
+ | LearningMentor.objects.filter(mentor__in=users)
+ .values_list("id", flat=True)
+ .distinct()
+ )
+ # cannot call delete on distinct objects
+ LearningMentor.objects.filter(id__in=list(learning_mentor_ids)).delete()
+ else:
+ logger.info("no_course_session_found", import_id=cs.import_id)
+
+ FeedbackResponse.objects.filter(feedback_user__in=users).delete()
+
+
+def add_to_course_session(
+ course_session: CourseSession,
+ members: list[User],
+ role=CourseSessionUser.Role.MEMBER,
+):
+ if course_session:
+ for user in members:
+ csu, _created = CourseSessionUser.objects.get_or_create(
+ course_session_id=course_session.id, user_id=user.id, role=role
+ )
+ csu.save()
+
+
+def add_mentor_to_course_session(
+ course_session: CourseSession, mentor_mentee_pairs: list[tuple[User, User]]
+):
+ for mentor, mentee in mentor_mentee_pairs:
+ lm = LearningMentor.objects.create(
+ course=course_session.course,
+ mentor=mentor,
+ )
+ lm.participants.add(
+ CourseSessionUser.objects.get(
+ user__id=mentee.id,
+ course_session=course_session,
+ )
+ )
+
+
+def add_trainers_to_course_session(
+ course_session: CourseSession,
+ trainers: list[User],
+ circle_keys: list[str],
+ language,
+):
+ add_to_course_session(course_session, trainers, CourseSessionUser.Role.EXPERT)
+ for user in trainers:
+ for circle_key in circle_keys:
+ circle_name = LP_DATA[circle_key][language]["title"]
+ circle = Circle.objects.filter(
+ slug=f"{course_session.course.slug}-lp-circle-{circle_name.lower()}"
+ ).first()
+
+ if course_session and circle:
+ csu = CourseSessionUser.objects.filter(
+ course_session_id=course_session.id, user_id=user.id
+ ).first()
+ if csu:
+ csu.expert.add(circle)
+ csu.save()
+
+
+def get_or_create_users_uk():
+ members = [
+ _create_or_update_user(
+ f"teilnehmer{n}.uk@iterativ.ch", "Teilnehmer üK", "Iterativ", PASSWORD, "de"
+ )
+ for n in range(1, 10)
+ ]
+ trainer = _create_or_update_user(
+ "trainer1.uk@iterativ.ch", "Trainer üK", "Iterativ", PASSWORD, "de"
+ )
+ regionenleiter = _create_or_update_user(
+ "regionenleiter1.uk@iterativ.ch",
+ "Regionenleiter üK",
+ "Iterativ",
+ PASSWORD,
+ "de",
+ )
+ return (
+ members,
+ trainer,
+ regionenleiter,
+ )
+
+
+def get_or_create_users_vv():
+ members = [
+ _create_or_update_user(
+ f"teilnehmer{n}.vv@iterativ.ch", "Teilnehmer VV", "Iterativ", PASSWORD, "de"
+ )
+ for n in range(1, 10)
+ ]
+ member_with_mentor = _create_or_update_user(
+ "teilnehmer1.vv.lb@iterativ.ch",
+ "Teilnehmer VV mit LB",
+ "Iterativ",
+ PASSWORD,
+ "de",
+ )
+ mentor = _create_or_update_user(
+ "lernbegleitung1.vv@iterativ.ch",
+ "Lernbegleitung VV",
+ "Iterativ",
+ PASSWORD,
+ "de",
+ )
+ return members, member_with_mentor, mentor
+
+
+def _create_or_update_user(email, first_name, last_name, password, language):
+ try:
+ user = User.objects.get(email=email)
+ except User.DoesNotExist:
+ user = User(
+ email=email,
+ username=email,
+ )
+
+ user.email = email
+ user.first_name = first_name or user.first_name
+ user.last_name = last_name or user.last_name
+ user.username = email
+ user.language = language
+ user.set_password(password)
+ user.save()
+ return user
+
+
+def create_uk_data(language):
+ return {
+ "Klasse": IT_UK_TEST_COURSE,
+ "ID": IT_UK_TEST_COURSE,
+ "Generation": 2024,
+ "Region": "Bern",
+ "Sprache": language,
+ f"Kickoff {TRANSLATIONS[language]['start']}": timezone.make_aware(
+ datetime.combine((timezone.now() + timedelta(weeks=2)).date(), time(9, 0))
+ ).strftime("%d.%m.%Y, %H:%M"),
+ f"Kickoff {TRANSLATIONS[language]['ende']}": timezone.make_aware(
+ datetime.combine((timezone.now() + timedelta(weeks=2)).date(), time(16, 0))
+ ).strftime("%d.%m.%Y, %H:%M"),
+ f"Kickoff {TRANSLATIONS[language]['raum']}": "Raum 1",
+ f"Kickoff {TRANSLATIONS[language]['standort']}": "Bern",
+ f"Kickoff {TRANSLATIONS[language]['adresse']}": "Musterstrasse 1",
+ f"Basis {TRANSLATIONS[language]['start']}": timezone.make_aware(
+ datetime.combine((timezone.now() + timedelta(weeks=4)).date(), time(9, 0))
+ ).strftime("%d.%m.%Y, %H:%M"),
+ f"Basis {TRANSLATIONS[language]['ende']}": timezone.make_aware(
+ datetime.combine((timezone.now() + timedelta(weeks=4)).date(), time(16, 0))
+ ).strftime("%d.%m.%Y, %H:%M"),
+ f"Basis {TRANSLATIONS[language]['raum']}": "Raum 1",
+ f"Basis {TRANSLATIONS[language]['standort']}": "Bern",
+ f"Basis {TRANSLATIONS[language]['adresse']}": "Musterstrasse 1",
+ f"Fahrzeug {TRANSLATIONS[language]['start']}": timezone.make_aware(
+ datetime.combine((timezone.now() + timedelta(weeks=6)).date(), time(9, 0))
+ ).strftime("%d.%m.%Y, %H:%M"),
+ f"Fahrzeug {TRANSLATIONS[language]['ende']}": timezone.make_aware(
+ datetime.combine((timezone.now() + timedelta(weeks=6)).date(), time(16, 0))
+ ).strftime("%d.%m.%Y, %H:%M"),
+ f"Fahrzeug {TRANSLATIONS[language]['raum']}": "Raum 1",
+ f"Fahrzeug {TRANSLATIONS[language]['standort']}": "Bern",
+ f"Fahrzeug {TRANSLATIONS[language]['adresse']}": "Musterstrasse 1",
+ f"Haushalt Teil 1 {TRANSLATIONS[language]['start']}": timezone.make_aware(
+ datetime.combine((timezone.now() + timedelta(weeks=8)).date(), time(9, 0))
+ ).strftime("%d.%m.%Y, %H:%M"),
+ f"Haushalt Teil 1 {TRANSLATIONS[language]['ende']}": timezone.make_aware(
+ datetime.combine((timezone.now() + timedelta(weeks=8)).date(), time(16, 0))
+ ).strftime("%d.%m.%Y, %H:%M"),
+ f"Haushalt Teil 1 {TRANSLATIONS[language]['raum']}": "Raum 1",
+ f"Haushalt Teil 1 {TRANSLATIONS[language]['standort']}": "Bern",
+ f"Haushalt Teil 1 {TRANSLATIONS[language]['adresse']}": "Musterstrasse 1",
+ f"Haushalt Teil 2 {TRANSLATIONS[language]['start']}": timezone.make_aware(
+ datetime.combine((timezone.now() + timedelta(weeks=10)).date(), time(9, 0))
+ ).strftime("%d.%m.%Y, %H:%M"),
+ f"Haushalt Teil 2 {TRANSLATIONS[language]['ende']}": timezone.make_aware(
+ datetime.combine((timezone.now() + timedelta(weeks=10)).date(), time(16, 0))
+ ).strftime("%d.%m.%Y, %H:%M"),
+ f"Haushalt Teil 2 {TRANSLATIONS[language]['raum']}": "Raum 1",
+ f"Haushalt Teil 2 {TRANSLATIONS[language]['standort']}": "Bern",
+ f"Haushalt Teil 2 {TRANSLATIONS[language]['adresse']}": "Musterstrasse 1",
+ }
+
+
+def create_and_add_to_cs_group(
+ course: Course, name: str, course_sessions: list[CourseSession], supervisor: User
+):
+ region, _ = CourseSessionGroup.objects.get_or_create(
+ name=name,
+ course=course,
+ )
+
+ for cs in course_sessions:
+ region.course_session.add(cs)
+
+ region.supervisor.add(supervisor)
+
+
+def get_vv_course(language: str) -> Course:
+ if language == "fr":
+ course_id = COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID
+ elif language == "it":
+ course_id = COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID
+ else:
+ course_id = COURSE_VERSICHERUNGSVERMITTLERIN_ID
+
+ return Course.objects.get(id=course_id)
+
+
+def create_or_update_assignment_course_session(cs: CourseSession):
+ # not nice but works for now
+ for assignment in Assignment.objects.all():
+ if assignment.get_course().id == cs.course.id:
+ logger.debug(
+ "create_course_session_assigments",
+ assignment=assignment,
+ label="reset_test_courses",
+ )
+ for lca in assignment.learningcontentassignment_set.all():
+ _csa, _created = CourseSessionAssignment.objects.get_or_create(
+ course_session=cs,
+ learning_content=lca,
+ )
diff --git a/server/vbv_lernwelt/core/migrations/0007_auto_20240220_1058.py b/server/vbv_lernwelt/core/migrations/0007_auto_20240220_1058.py
new file mode 100644
index 00000000..6992cfd0
--- /dev/null
+++ b/server/vbv_lernwelt/core/migrations/0007_auto_20240220_1058.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.2.20 on 2024-02-20 09:58
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("core", "0006_auto_20240125_0915"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="country",
+ options={
+ "ordering": ["order_id", "country_id"],
+ "verbose_name": "Country",
+ "verbose_name_plural": "Countries",
+ },
+ ),
+ migrations.AddField(
+ model_name="country",
+ name="order_id",
+ field=models.FloatField(default=20),
+ ),
+ ]
diff --git a/server/vbv_lernwelt/core/models.py b/server/vbv_lernwelt/core/models.py
index 1221dee3..41b1a487 100644
--- a/server/vbv_lernwelt/core/models.py
+++ b/server/vbv_lernwelt/core/models.py
@@ -26,6 +26,7 @@ class Country(models.Model):
name_de = models.CharField(max_length=255)
name_fr = models.CharField(max_length=255)
name_it = models.CharField(max_length=255)
+ order_id = models.FloatField(default=20)
def __str__(self):
return f"{self.name_de} ({self.country_id})"
@@ -33,7 +34,7 @@ class Country(models.Model):
class Meta:
verbose_name = "Country"
verbose_name_plural = "Countries"
- ordering = ["country_id"]
+ ordering = ["order_id", "country_id"]
class User(AbstractUser):
diff --git a/server/vbv_lernwelt/core/views.py b/server/vbv_lernwelt/core/views.py
index c6eb2744..c6f9a8cf 100644
--- a/server/vbv_lernwelt/core/views.py
+++ b/server/vbv_lernwelt/core/views.py
@@ -173,6 +173,10 @@ def cypress_reset_view(request):
request.data.get("create_attendance_days") == "true"
)
+ options["create_learning_mentor"] = (
+ request.data.get("create_learning_mentor") == "true"
+ )
+
call_command(
"cypress_reset",
**options,
@@ -181,6 +185,17 @@ def cypress_reset_view(request):
return HttpResponseRedirect("/server/admin/")
+@api_view(["POST"])
+@authentication_classes((authentication.SessionAuthentication,))
+@permission_classes((IsAdminUser,))
+def iterativ_test_coursesessions_reset_view(request):
+ call_command(
+ "reset_iterativ_test_sessions",
+ )
+
+ return HttpResponseRedirect("/server/admin/")
+
+
@django_view_authentication_exempt
def generate_web_component_icons(request):
svg_files = []
diff --git a/server/vbv_lernwelt/course/creators/test_utils.py b/server/vbv_lernwelt/course/creators/test_utils.py
index 3a396754..ec22f19b 100644
--- a/server/vbv_lernwelt/course/creators/test_utils.py
+++ b/server/vbv_lernwelt/course/creators/test_utils.py
@@ -46,6 +46,8 @@ from vbv_lernwelt.learnpath.models import (
LearningContentAssignment,
LearningContentEdoniqTest,
LearningPath,
+ LearningUnit,
+ LearningUnitPerformanceFeedbackType,
)
from vbv_lernwelt.learnpath.tests.learning_path_factories import (
CircleFactory,
@@ -58,11 +60,13 @@ from vbv_lernwelt.learnpath.tests.learning_path_factories import (
)
-def create_course(title: str, _id=None) -> Tuple[Course, CoursePage]:
+def create_course(
+ title: str = "Course Title", _id=None, course_page_title: str = "Test Lehrgang"
+) -> Tuple[Course, CoursePage]:
course = Course.objects.create(id=_id, title=title, category_name="Handlungsfeld")
course_page = CoursePageFactory(
- title="Test Lehrgang",
+ title=course_page_title,
parent=get_wagtail_default_site().root_page,
course=course,
)
@@ -268,10 +272,30 @@ def create_course_session_edoniq_test(
return cset
+def create_learning_unit(
+ circle: Circle,
+ course: Course,
+ course_category_title: str = "Course Category",
+ feedback_user: LearningUnitPerformanceFeedbackType = LearningUnitPerformanceFeedbackType.NO_FEEDBACK,
+) -> LearningUnit:
+ cat, _ = CourseCategory.objects.get_or_create(
+ course=course,
+ title=course_category_title,
+ )
+
+ return LearningUnitFactory(
+ title="Learning Unit",
+ parent=circle,
+ course_category=cat,
+ feedback_user=feedback_user.value,
+ )
+
+
def create_performance_criteria_page(
course: Course,
course_page: CoursePage,
circle: Circle,
+ learning_unit: LearningUnitFactory | LearningUnit | None = None,
) -> PerformanceCriteria:
competence_navi_page = CompetenceNaviPageFactory(
title="Competence Navi",
@@ -290,17 +314,14 @@ def create_performance_criteria_page(
items=[("item", "Action Competence Item")],
)
- cat, _ = CourseCategory.objects.get_or_create(
- course=course, title="Course Category"
- )
-
- lu = LearningUnitFactory(title="Learning Unit", parent=circle, course_category=cat)
+ if not learning_unit:
+ learning_unit = create_learning_unit(circle=circle, course=course)
return PerformanceCriteriaFactory(
parent=action_competence,
competence_id="X1.1",
title="Performance Criteria",
- learning_unit=lu,
+ learning_unit=learning_unit,
)
diff --git a/server/vbv_lernwelt/course_session/migrations/0006_auto_20240124_1004.py b/server/vbv_lernwelt/course_session/migrations/0006_auto_20240124_1004.py
new file mode 100644
index 00000000..9aab3304
--- /dev/null
+++ b/server/vbv_lernwelt/course_session/migrations/0006_auto_20240124_1004.py
@@ -0,0 +1,36 @@
+# Generated by Django 3.2.20 on 2024-01-24 09:04
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("duedate", "0008_auto_20231108_0747"),
+ ("course_session", "0005_auto_20230825_1723"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="coursesessionassignment",
+ name="evaluation_deadline",
+ field=models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="assignment_evaluation_deadline",
+ to="duedate.duedate",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="coursesessionassignment",
+ name="submission_deadline",
+ field=models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="assignment_submission_deadline",
+ to="duedate.duedate",
+ ),
+ ),
+ ]
diff --git a/server/vbv_lernwelt/duedate/migrations/0005_auto_20230925_1648.py b/server/vbv_lernwelt/duedate/migrations/0005_auto_20230925_1648.py
index 1f3192a8..9dcd192b 100644
--- a/server/vbv_lernwelt/duedate/migrations/0005_auto_20230925_1648.py
+++ b/server/vbv_lernwelt/duedate/migrations/0005_auto_20230925_1648.py
@@ -45,6 +45,7 @@ class Migration(migrations.Migration):
dependencies = [
("duedate", "0004_alter_duedate_start"),
("learnpath", "0008_add_edoniq_sequence_id"),
+ ("course_session", "0005_auto_20230825_1723"),
]
operations = [
diff --git a/server/vbv_lernwelt/iam/permissions.py b/server/vbv_lernwelt/iam/permissions.py
index ecbdc2f7..4ed6be5b 100644
--- a/server/vbv_lernwelt/iam/permissions.py
+++ b/server/vbv_lernwelt/iam/permissions.py
@@ -209,10 +209,16 @@ def can_view_course_completions(
)
+def can_complete_learning_content(user: User, course_session_id: int) -> bool:
+ return is_course_session_member(
+ user, course_session_id
+ ) or is_course_session_expert(user, course_session_id)
+
+
def course_session_permissions(user: User, course_session_id: int) -> list[str]:
return _action_list(
{
- "complete-learning-content": is_course_session_member(
+ "complete-learning-content": can_complete_learning_content(
user, course_session_id
),
}
diff --git a/server/vbv_lernwelt/iam/tests/test_actions.py b/server/vbv_lernwelt/iam/tests/test_actions.py
index 2a87cf82..3d8bcedf 100644
--- a/server/vbv_lernwelt/iam/tests/test_actions.py
+++ b/server/vbv_lernwelt/iam/tests/test_actions.py
@@ -33,12 +33,21 @@ class ActionTestCase(TestCase):
role=CourseSessionUser.Role.MEMBER,
)
+ trainer = create_user("trainer")
+ add_course_session_user(
+ self.course_session,
+ trainer,
+ role=CourseSessionUser.Role.EXPERT,
+ )
+
# WHEN
mentor_actions = course_session_permissions(lm, self.course_session.id)
participant_actions = course_session_permissions(
participant, self.course_session.id
)
+ trainer_actions = course_session_permissions(trainer, self.course_session.id)
# THEN
self.assertEqual(len(mentor_actions), 0)
self.assertEqual(participant_actions, ["complete-learning-content"])
+ self.assertEqual(trainer_actions, ["complete-learning-content"])
diff --git a/server/vbv_lernwelt/learning_mentor/content/praxis_assignment.py b/server/vbv_lernwelt/learning_mentor/content/praxis_assignment.py
index b1746f5f..6e74e3f1 100644
--- a/server/vbv_lernwelt/learning_mentor/content/praxis_assignment.py
+++ b/server/vbv_lernwelt/learning_mentor/content/praxis_assignment.py
@@ -10,9 +10,10 @@ from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.course_session.models import CourseSessionAssignment
from vbv_lernwelt.learning_mentor.entities import (
- CompletionStatus,
- PraxisAssignmentCompletion,
- PraxisAssignmentStatus,
+ MentorAssignmentCompletion,
+ MentorAssignmentStatus,
+ MentorAssignmentStatusType,
+ MentorCompletionStatus,
)
@@ -21,7 +22,7 @@ def get_assignment_completions(
assignment: Assignment,
participants: List[User],
evaluation_user: User,
-) -> List[PraxisAssignmentCompletion]:
+) -> List[MentorAssignmentCompletion]:
evaluation_results = AssignmentCompletion.objects.filter(
assignment_user__in=participants,
course_session=course_session,
@@ -34,14 +35,14 @@ def get_assignment_completions(
completion_status = result["completion_status"]
if completion_status == AssignmentCompletionStatus.EVALUATION_SUBMITTED.value:
- status = CompletionStatus.EVALUATED
+ status = MentorCompletionStatus.EVALUATED
elif completion_status in [
AssignmentCompletionStatus.SUBMITTED.value,
AssignmentCompletionStatus.EVALUATION_IN_PROGRESS.value,
]:
- status = CompletionStatus.SUBMITTED
+ status = MentorCompletionStatus.SUBMITTED
else:
- status = CompletionStatus.UNKNOWN
+ status = MentorCompletionStatus.UNKNOWN
user_status_map[result["assignment_user"]] = (
status,
@@ -49,25 +50,25 @@ def get_assignment_completions(
)
status_priority = {
- CompletionStatus.SUBMITTED: 1,
- CompletionStatus.EVALUATED: 2,
- CompletionStatus.UNKNOWN: 3,
+ MentorCompletionStatus.SUBMITTED: 1,
+ MentorCompletionStatus.EVALUATED: 2,
+ MentorCompletionStatus.UNKNOWN: 3,
}
sorted_participants = sorted(
participants,
key=lambda u: (
status_priority.get(
- user_status_map.get(u.id, (CompletionStatus.UNKNOWN, ""))[0]
+ user_status_map.get(u.id, (MentorCompletionStatus.UNKNOWN, ""))[0]
),
user_status_map.get(u.id, ("", u.last_name))[1],
),
)
return [
- PraxisAssignmentCompletion(
+ MentorAssignmentCompletion(
status=user_status_map.get(
- user.id, (CompletionStatus.UNKNOWN, user.last_name)
+ user.id, (MentorCompletionStatus.UNKNOWN, user.last_name)
)[0],
user_id=user.id,
last_name=user.last_name,
@@ -79,7 +80,7 @@ def get_assignment_completions(
def get_praxis_assignments(
course_session: CourseSession, participants: List[User], evaluation_user: User
-) -> Tuple[List[PraxisAssignmentStatus], Set[int]]:
+) -> Tuple[List[MentorAssignmentStatus], Set[int]]:
records = []
circle_ids = set()
@@ -105,19 +106,20 @@ def get_praxis_assignments(
[
completion
for completion in completions
- if completion.status == CompletionStatus.SUBMITTED
+ if completion.status == MentorCompletionStatus.SUBMITTED
]
)
circle_id = learning_content.get_circle().id
records.append(
- PraxisAssignmentStatus(
+ MentorAssignmentStatus(
id=course_session_assignment.id,
title=learning_content.content_assignment.title,
circle_id=circle_id,
pending_evaluations=submitted_count,
completions=completions,
+ type=MentorAssignmentStatusType.PRAXIS_ASSIGNMENT,
)
)
diff --git a/server/vbv_lernwelt/learning_mentor/content/self_evaluation_feedback.py b/server/vbv_lernwelt/learning_mentor/content/self_evaluation_feedback.py
new file mode 100644
index 00000000..163cf8c2
--- /dev/null
+++ b/server/vbv_lernwelt/learning_mentor/content/self_evaluation_feedback.py
@@ -0,0 +1,108 @@
+from typing import List, Set, Tuple
+
+import structlog
+
+from vbv_lernwelt.core.models import User
+from vbv_lernwelt.course.models import Course
+from vbv_lernwelt.learning_mentor.entities import (
+ MentorAssignmentCompletion,
+ MentorAssignmentStatus,
+ MentorAssignmentStatusType,
+ MentorCompletionStatus,
+)
+from vbv_lernwelt.learnpath.models import (
+ LearningUnit,
+ LearningUnitPerformanceFeedbackType,
+)
+from vbv_lernwelt.self_evaluation_feedback.models import SelfEvaluationFeedback
+
+logger = structlog.get_logger(__name__)
+
+
+def create_blank_completions_non_requesters(
+ completions: List[MentorAssignmentCompletion],
+ participants: List[User],
+) -> List[MentorAssignmentCompletion]:
+ non_requester_completions = []
+
+ participants_user_ids = set([str(p.id) for p in participants])
+ completion_seen_user_ids = set([str(c.user_id) for c in completions])
+
+ user_by_id = {str(p.id): p for p in participants}
+ for non_requester_user_id in participants_user_ids - completion_seen_user_ids:
+ non_requester_user = user_by_id[non_requester_user_id]
+
+ non_requester_completions.append(
+ MentorAssignmentCompletion(
+ status=MentorCompletionStatus.UNKNOWN,
+ user_id=non_requester_user.id,
+ last_name=non_requester_user.last_name,
+ url="",
+ )
+ )
+
+ return non_requester_completions
+
+
+def get_self_feedback_evaluation(
+ participants: List[User],
+ evaluation_user: User,
+ course: Course,
+) -> Tuple[List[MentorAssignmentStatus], Set[int]]:
+ records: List[MentorAssignmentStatus] = []
+ circle_ids: Set[int] = set()
+
+ if not participants:
+ return records, circle_ids
+
+ # very unfortunate: we can't simply get all SelfEvaluationFeedback objects since then
+ # we would miss the one where no feedback was requested -> so we get all learning units
+ # and check if we have to take them into account (course, feedback type, etc.)
+ for learning_unit in LearningUnit.objects.filter(
+ feedback_user=LearningUnitPerformanceFeedbackType.MENTOR_FEEDBACK.value,
+ course_category__course_id=course.id,
+ ):
+ feedbacks = SelfEvaluationFeedback.objects.filter(
+ learning_unit=learning_unit,
+ feedback_requester_user__in=participants,
+ feedback_provider_user=evaluation_user,
+ )
+
+ circle_id = learning_unit.get_circle().id
+ circle_ids.add(circle_id)
+
+ pending_evaluations = len([f for f in feedbacks if not f.feedback_submitted])
+
+ completions = [
+ MentorAssignmentCompletion(
+ # feedback_submitted as seen from the perspective of the evaluation user (feedback provider)
+ # means that the feedback has been evaluated by the feedback provider, hence the status is EVALUATED
+ status=MentorCompletionStatus.EVALUATED
+ if f.feedback_submitted
+ else MentorCompletionStatus.SUBMITTED,
+ user_id=f.feedback_requester_user.id,
+ last_name=f.feedback_requester_user.last_name,
+ url=f"/course/{course.slug}/cockpit/mentor/self-evaluation-feedback/{f.learning_unit.id}",
+ )
+ for f in feedbacks
+ ]
+
+ # requesting feedback is optional, so we need to add blank completions
+ # for those mentees who did not request a feedback
+ completions += create_blank_completions_non_requesters(
+ completions=completions,
+ participants=participants,
+ )
+
+ records.append(
+ MentorAssignmentStatus(
+ id=learning_unit.id,
+ title=learning_unit.title,
+ circle_id=circle_id,
+ pending_evaluations=pending_evaluations,
+ completions=completions,
+ type=MentorAssignmentStatusType.SELF_EVALUATION_FEEDBACK,
+ )
+ )
+
+ return records, circle_ids
diff --git a/server/vbv_lernwelt/learning_mentor/entities.py b/server/vbv_lernwelt/learning_mentor/entities.py
index 2b4a9a04..d0a93152 100644
--- a/server/vbv_lernwelt/learning_mentor/entities.py
+++ b/server/vbv_lernwelt/learning_mentor/entities.py
@@ -3,24 +3,30 @@ from enum import Enum
from typing import List
-class CompletionStatus(str, Enum):
+class MentorCompletionStatus(str, Enum):
UNKNOWN = "UNKNOWN"
SUBMITTED = "SUBMITTED"
EVALUATED = "EVALUATED"
+class MentorAssignmentStatusType(str, Enum):
+ PRAXIS_ASSIGNMENT = "praxis_assignment"
+ SELF_EVALUATION_FEEDBACK = "self_evaluation_feedback"
+
+
@dataclass
-class PraxisAssignmentCompletion:
- status: CompletionStatus
+class MentorAssignmentCompletion:
+ status: MentorCompletionStatus
user_id: str
last_name: str
url: str
@dataclass
-class PraxisAssignmentStatus:
+class MentorAssignmentStatus:
id: str
title: str
circle_id: str
pending_evaluations: int
- completions: List[PraxisAssignmentCompletion]
+ completions: List[MentorAssignmentCompletion]
+ type: MentorAssignmentStatusType
diff --git a/server/vbv_lernwelt/learning_mentor/migrations/0005_alter_learningmentor_mentor.py b/server/vbv_lernwelt/learning_mentor/migrations/0005_alter_learningmentor_mentor.py
new file mode 100644
index 00000000..bcbed084
--- /dev/null
+++ b/server/vbv_lernwelt/learning_mentor/migrations/0005_alter_learningmentor_mentor.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.2.20 on 2024-02-12 09:25
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("learning_mentor", "0004_alter_mentorinvitation_unique_together"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="learningmentor",
+ name="mentor",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
+ ),
+ ),
+ ]
diff --git a/server/vbv_lernwelt/learning_mentor/models.py b/server/vbv_lernwelt/learning_mentor/models.py
index c6e8aa2e..4db8e770 100644
--- a/server/vbv_lernwelt/learning_mentor/models.py
+++ b/server/vbv_lernwelt/learning_mentor/models.py
@@ -8,7 +8,7 @@ from vbv_lernwelt.course.models import CourseSessionUser
class LearningMentor(models.Model):
- mentor = models.OneToOneField(User, on_delete=models.CASCADE)
+ mentor = models.ForeignKey(User, on_delete=models.CASCADE)
course = models.ForeignKey("course.Course", on_delete=models.CASCADE)
participants = models.ManyToManyField(
diff --git a/server/vbv_lernwelt/learning_mentor/serializers.py b/server/vbv_lernwelt/learning_mentor/serializers.py
index 20333aaf..8c079cbe 100644
--- a/server/vbv_lernwelt/learning_mentor/serializers.py
+++ b/server/vbv_lernwelt/learning_mentor/serializers.py
@@ -4,7 +4,7 @@ from vbv_lernwelt.core.serializers import UserSerializer
from vbv_lernwelt.learning_mentor.models import LearningMentor, MentorInvitation
-class PraxisAssignmentCompletionSerializer(serializers.Serializer):
+class MentorAssignmentCompletionSerializer(serializers.Serializer):
status = serializers.SerializerMethodField()
user_id = serializers.CharField()
last_name = serializers.CharField()
@@ -15,13 +15,13 @@ class PraxisAssignmentCompletionSerializer(serializers.Serializer):
return obj.status.value
-class PraxisAssignmentStatusSerializer(serializers.Serializer):
+class MentorAssignmentStatusSerializer(serializers.Serializer):
id = serializers.CharField()
title = serializers.CharField()
circle_id = serializers.CharField()
pending_evaluations = serializers.IntegerField()
- completions = PraxisAssignmentCompletionSerializer(many=True)
- type = serializers.ReadOnlyField(default="praxis_assignment")
+ completions = MentorAssignmentCompletionSerializer(many=True)
+ type = serializers.ReadOnlyField()
class InvitationSerializer(serializers.ModelSerializer):
diff --git a/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py b/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py
index b60d78cc..794e721a 100644
--- a/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py
+++ b/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py
@@ -18,7 +18,7 @@ from vbv_lernwelt.learning_mentor.content.praxis_assignment import (
get_assignment_completions,
get_praxis_assignments,
)
-from vbv_lernwelt.learning_mentor.entities import CompletionStatus
+from vbv_lernwelt.learning_mentor.entities import MentorCompletionStatus
class AttendanceServicesTestCase(TestCase):
@@ -74,10 +74,10 @@ class AttendanceServicesTestCase(TestCase):
# THEN
expected_order = ["Beta", "Alpha", "Gamma", "Kappa"]
expected_statuses = {
- "Alpha": CompletionStatus.EVALUATED, # user1
- "Beta": CompletionStatus.SUBMITTED, # user2
- "Gamma": CompletionStatus.UNKNOWN, # user4 (no AssignmentCompletion)
- "Kappa": CompletionStatus.UNKNOWN, # user3 (IN_PROGRESS should be PENDING)
+ "Alpha": MentorCompletionStatus.EVALUATED, # user1
+ "Beta": MentorCompletionStatus.SUBMITTED, # user2
+ "Gamma": MentorCompletionStatus.UNKNOWN, # user4 (no AssignmentCompletion)
+ "Kappa": MentorCompletionStatus.UNKNOWN, # user3 (IN_PROGRESS should be PENDING)
}
self.assertEqual(len(results), len(participants))
diff --git a/server/vbv_lernwelt/learning_mentor/tests/test_mentor.py b/server/vbv_lernwelt/learning_mentor/tests/test_mentor.py
index 13b1748a..60b280c7 100644
--- a/server/vbv_lernwelt/learning_mentor/tests/test_mentor.py
+++ b/server/vbv_lernwelt/learning_mentor/tests/test_mentor.py
@@ -1,3 +1,5 @@
+from typing import Dict, List, Optional
+
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
@@ -7,6 +9,7 @@ from vbv_lernwelt.assignment.models import (
AssignmentCompletionStatus,
AssignmentType,
)
+from vbv_lernwelt.core.admin import User
from vbv_lernwelt.course.creators.test_utils import (
add_course_session_user,
create_assignment,
@@ -15,10 +18,22 @@ from vbv_lernwelt.course.creators.test_utils import (
create_course,
create_course_session,
create_course_session_assignment,
+ create_learning_unit,
create_user,
)
from vbv_lernwelt.course.models import CourseSessionUser
from vbv_lernwelt.learning_mentor.models import LearningMentor
+from vbv_lernwelt.learnpath.models import LearningUnitPerformanceFeedbackType
+from vbv_lernwelt.self_evaluation_feedback.models import SelfEvaluationFeedback
+
+
+def get_completion_for_user(
+ completions: List[Dict[str, str]], user: User
+) -> Optional[Dict[str, str]]:
+ for completion in completions:
+ if completion["user_id"] == str(user.id):
+ return completion
+ return None
class LearningMentorAPITest(APITestCase):
@@ -28,15 +43,6 @@ class LearningMentorAPITest(APITestCase):
self.circle, _ = create_circle(title="Circle", course_page=self.course_page)
- self.assignment = create_assignment(
- course=self.course, assignment_type=AssignmentType.PRAXIS_ASSIGNMENT
- )
-
- lca = create_assignment_learning_content(self.circle, self.assignment)
- create_course_session_assignment(
- course_session=self.course_session, learning_content_assignment=lca
- )
-
self.mentor = create_user("mentor")
self.participant_1 = add_course_session_user(
self.course_session,
@@ -109,7 +115,7 @@ class LearningMentorAPITest(APITestCase):
self.assertEqual(participant_1["first_name"], "Test")
self.assertEqual(participant_1["last_name"], "Participant_1")
- def test_api_praxis_assignments(self) -> None:
+ def test_api_self_evaluation_feedback(self) -> None:
# GIVEN
participants = [self.participant_1, self.participant_2, self.participant_3]
self.client.force_login(self.mentor)
@@ -118,12 +124,104 @@ class LearningMentorAPITest(APITestCase):
mentor=self.mentor,
course=self.course_session.course,
)
+
+ mentor.participants.set(participants)
+
+ learning_unit = create_learning_unit(
+ circle=self.circle,
+ course=self.course,
+ )
+
+ # performance criteria under this learning unit shall be evaluated by the mentor
+ learning_unit.feedback_user = (
+ LearningUnitPerformanceFeedbackType.MENTOR_FEEDBACK.name
+ )
+
+ learning_unit.save()
+
+ # 1: we already evaluated
+ SelfEvaluationFeedback.objects.create(
+ feedback_requester_user=self.participant_1.user,
+ feedback_provider_user=self.mentor,
+ learning_unit=learning_unit,
+ feedback_submitted=True,
+ )
+
+ # 2: we have not evaluated yet
+ SelfEvaluationFeedback.objects.create(
+ feedback_requester_user=self.participant_2.user,
+ feedback_provider_user=self.mentor,
+ learning_unit=learning_unit,
+ feedback_submitted=False,
+ )
+
+ # 3: did not request feedback
+ # ...
+
+ # WHEN
+ response = self.client.get(self.url)
+
+ # THEN
+ assignments = response.data["assignments"]
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ self.assertEqual(
+ response.data["circles"],
+ [{"id": self.circle.id, "title": self.circle.title}],
+ )
+
+ self.assertEqual(len(assignments), 1)
+ assignment = assignments[0]
+
+ self.assertEqual(assignment["type"], "self_evaluation_feedback")
+ self.assertEqual(assignment["pending_evaluations"], 1)
+
+ completions = assignment["completions"]
+ self.assertEqual(
+ len(completions),
+ 3,
+ )
+
+ completion_1 = get_completion_for_user(completions, self.participant_1.user)
+ self.assertEqual(completion_1["status"], "EVALUATED")
+ self.assertEqual(completion_1["last_name"], "Participant_1")
+ self.assertEqual(completion_1["user_id"], str(self.participant_1.user.id))
+
+ completion_2 = get_completion_for_user(completions, self.participant_2.user)
+ self.assertEqual(completion_2["status"], "SUBMITTED")
+ self.assertEqual(completion_2["last_name"], "Participant_2")
+ self.assertEqual(completion_2["user_id"], str(self.participant_2.user.id))
+
+ completion_3 = get_completion_for_user(completions, self.participant_3.user)
+ self.assertEqual(completion_3["status"], "UNKNOWN")
+ self.assertEqual(completion_3["last_name"], "Participant_3")
+ self.assertEqual(completion_3["user_id"], str(self.participant_3.user.id))
+
+ def test_api_praxis_assignments(self) -> None:
+ # GIVEN
+ self.client.force_login(self.mentor)
+
+ assignment = create_assignment(
+ course=self.course, assignment_type=AssignmentType.PRAXIS_ASSIGNMENT
+ )
+
+ lca = create_assignment_learning_content(self.circle, assignment)
+ create_course_session_assignment(
+ course_session=self.course_session, learning_content_assignment=lca
+ )
+
+ mentor = LearningMentor.objects.create(
+ mentor=self.mentor,
+ course=self.course_session.course,
+ )
+
+ participants = [self.participant_1, self.participant_2, self.participant_3]
mentor.participants.set(participants)
AssignmentCompletion.objects.create(
assignment_user=self.participant_1.user,
course_session=self.course_session,
- assignment=self.assignment,
+ assignment=assignment,
completion_status=AssignmentCompletionStatus.EVALUATION_SUBMITTED.value,
evaluation_user=self.mentor,
)
@@ -131,7 +229,7 @@ class LearningMentorAPITest(APITestCase):
AssignmentCompletion.objects.create(
assignment_user=self.participant_3.user,
course_session=self.course_session,
- assignment=self.assignment,
+ assignment=assignment,
completion_status=AssignmentCompletionStatus.SUBMITTED.value,
evaluation_user=self.mentor,
)
@@ -232,3 +330,15 @@ class LearningMentorAPITest(APITestCase):
self.assertFalse(
LearningMentor.objects.filter(participants=participant_cs_user).exists()
)
+
+ def test_mentor_multiple_courses(self) -> None:
+ # GIVEN
+ course_a, _ = create_course("Course A")
+ course_b, _ = create_course("Course B")
+
+ # WHEN
+ LearningMentor.objects.create(mentor=self.mentor, course=course_a)
+ LearningMentor.objects.create(mentor=self.mentor, course=course_b)
+
+ # THEN
+ self.assertEqual(LearningMentor.objects.count(), 2)
diff --git a/server/vbv_lernwelt/learning_mentor/views.py b/server/vbv_lernwelt/learning_mentor/views.py
index 04abb592..e56c33fc 100644
--- a/server/vbv_lernwelt/learning_mentor/views.py
+++ b/server/vbv_lernwelt/learning_mentor/views.py
@@ -12,11 +12,14 @@ from vbv_lernwelt.iam.permissions import has_role_in_course, is_course_session_m
from vbv_lernwelt.learning_mentor.content.praxis_assignment import (
get_praxis_assignments,
)
+from vbv_lernwelt.learning_mentor.content.self_evaluation_feedback import (
+ get_self_feedback_evaluation,
+)
from vbv_lernwelt.learning_mentor.models import LearningMentor, MentorInvitation
from vbv_lernwelt.learning_mentor.serializers import (
InvitationSerializer,
+ MentorAssignmentStatusSerializer,
MentorSerializer,
- PraxisAssignmentStatusSerializer,
)
from vbv_lernwelt.learnpath.models import Circle
from vbv_lernwelt.notify.email.email_services import EmailTemplate, send_email
@@ -37,24 +40,40 @@ def mentor_summary(request, course_session_id: int):
assignments = []
circle_ids = set()
- praxis_assignments, _circle_ids = get_praxis_assignments(
- course_session=course_session, participants=users, evaluation_user=request.user
+ praxis_assignments, praxis_assignments_circle_ids = get_praxis_assignments(
+ course_session=course_session,
+ participants=users,
+ evaluation_user=request.user, # noqa
+ )
+
+ (
+ self_evaluation_feedbacks,
+ self_evaluation_feedback_circle_ids,
+ ) = get_self_feedback_evaluation(
+ participants=users,
+ evaluation_user=request.user, # noqa
+ course=course_session.course,
+ )
+
+ circle_ids.update(praxis_assignments_circle_ids)
+ circle_ids.update(self_evaluation_feedback_circle_ids)
+
+ assignments.extend(
+ MentorAssignmentStatusSerializer(praxis_assignments, many=True).data
)
assignments.extend(
- PraxisAssignmentStatusSerializer(praxis_assignments, many=True).data
+ MentorAssignmentStatusSerializer(self_evaluation_feedbacks, many=True).data
)
- circle_ids.update(_circle_ids)
-
- circles = Circle.objects.filter(id__in=circle_ids).values("id", "title")
assignments.sort(
key=lambda x: (-x.get("pending_evaluations", 0), x.get("title", "").lower())
)
-
return Response(
{
"participants": [UserSerializer(user).data for user in users],
- "circles": list(circles),
+ "circles": list(
+ Circle.objects.filter(id__in=circle_ids).values("id", "title")
+ ),
"assignments": assignments,
}
)
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 29eee41e..a288a5fa 100644
--- a/server/vbv_lernwelt/learnpath/create_vv_new_learning_path.py
+++ b/server/vbv_lernwelt/learnpath/create_vv_new_learning_path.py
@@ -5,11 +5,16 @@ from wagtail.rich_text import RichText
from wagtail_localize.models import LocaleSynchronization
from vbv_lernwelt.assignment.models import Assignment
-from vbv_lernwelt.competence.factories import PerformanceCriteriaFactory
+from vbv_lernwelt.competence.factories import (
+ ActionCompetenceFactory,
+ ActionCompetenceListPageFactory,
+ PerformanceCriteriaFactory,
+)
from vbv_lernwelt.competence.models import ActionCompetence
from vbv_lernwelt.core.admin import User
from vbv_lernwelt.course.consts import COURSE_VERSICHERUNGSVERMITTLERIN_ID
from vbv_lernwelt.course.models import CourseCategory, CoursePage
+from vbv_lernwelt.learnpath.models import LearningUnitPerformanceFeedbackType
from vbv_lernwelt.learnpath.tests.learning_path_factories import (
CircleFactory,
LearningContentAssignmentFactory,
@@ -43,13 +48,13 @@ def create_vv_new_learning_path(
)
TopicFactory(title="Basis", is_visible=False, parent=lp)
- create_circle_basis(lp)
+ create_circle_basis(lp, course_page=course_page)
TopicFactory(title="Gewinnen von Kunden", parent=lp)
create_circle_gewinnen(lp)
TopicFactory(title="Beraten und Betreuen von Kunden", parent=lp)
- create_circle_fahrzeug(lp)
+ create_circle_fahrzeug(lp, course_page=course_page)
create_circle_haushalt(lp)
create_circle_rechtsstreitigkeiten(lp)
create_circle_reisen(lp)
@@ -103,7 +108,7 @@ def create_vv_pruefung_learning_path(
Page.objects.update(owner=user)
-def create_circle_basis(lp, title="Basis"):
+def create_circle_basis(lp, title="Basis", course_page=None):
circle = CircleFactory(
title=title,
parent=lp,
@@ -145,10 +150,42 @@ def create_circle_basis(lp, title="Basis"):
)
LearningSequenceFactory(title="Arbeitsalltag", parent=circle)
- LearningUnitFactory(
+ lu = LearningUnitFactory(
title="Mein neuer Job, Arbeitstechnik, Soziale Medien, Datenschutz und Beratungspflichten",
+ feedback_user=LearningUnitPerformanceFeedbackType.MENTOR_FEEDBACK.name,
parent=circle,
)
+
+ competence_profile_page = ActionCompetenceListPageFactory(
+ title="KompetenzNavi",
+ parent=course_page,
+ )
+
+ ace = ActionCompetenceFactory(
+ parent=competence_profile_page,
+ )
+
+ PerformanceCriteriaFactory(
+ parent=ace,
+ competence_id="VV-Arbeitsalltag-A",
+ title="Ich kenne die wichtigsten Aspekte des Arbeitsalltags als Versicherungsvermittler/-in.",
+ learning_unit=lu,
+ )
+
+ PerformanceCriteriaFactory(
+ parent=ace,
+ competence_id="VV-Arbeitsalltag-B",
+ title="Ich identifiziere und analysiere neue Markttrends im Versicherungssektor.",
+ learning_unit=lu,
+ )
+
+ PerformanceCriteriaFactory(
+ parent=ace,
+ competence_id="VV-Arbeitsalltag-C",
+ title="Ich nutze digitale Tools zur Optimierung der Kundenbetreuung und -beratung im Versicherungswesen.",
+ learning_unit=lu,
+ )
+
LearningContentPlaceholderFactory(
title="Mediathek",
parent=circle,
@@ -303,7 +340,7 @@ def create_circle_gewinnen(lp, title="Gewinnen"):
)
-def create_circle_fahrzeug(lp, title="Fahrzeug"):
+def create_circle_fahrzeug(lp, title="Fahrzeug", course_page=None):
circle = CircleFactory(
title=title,
parent=lp,
@@ -367,7 +404,14 @@ def create_circle_fahrzeug(lp, title="Fahrzeug"):
)
LearningSequenceFactory(title="Transfer", parent=circle, icon="it-icon-ls-end")
- LearningUnitFactory(title="Transfer", title_hidden=True, parent=circle)
+
+ lu_transfer = LearningUnitFactory(
+ title="Transfer",
+ title_hidden=True,
+ parent=circle,
+ feedback_user=LearningUnitPerformanceFeedbackType.MENTOR_FEEDBACK.name,
+ )
+
LearningContentPlaceholderFactory(
title="Praxisauftrag",
parent=circle,
@@ -392,6 +436,36 @@ def create_circle_fahrzeug(lp, title="Fahrzeug"):
parent=circle,
)
+ competence_profile_page = ActionCompetenceListPageFactory(
+ title="KompetenzNavi",
+ parent=course_page,
+ )
+
+ ace = ActionCompetenceFactory(
+ parent=competence_profile_page,
+ )
+
+ PerformanceCriteriaFactory(
+ parent=ace,
+ competence_id="VV-Transfer-A",
+ title="Ich setze das Gelernte in der Praxis um.",
+ learning_unit=lu_transfer,
+ )
+
+ PerformanceCriteriaFactory(
+ parent=ace,
+ competence_id="VV-Transfer-B",
+ title="Ich kenne den Unterschied zwischen einem Neuwagen und einem Occasionswagen.",
+ learning_unit=lu_transfer,
+ )
+
+ PerformanceCriteriaFactory(
+ parent=ace,
+ competence_id="VV-Transfer-C",
+ title="Ich kenne den Unterschied zwischen einem Leasing und einem Kauf.",
+ learning_unit=lu_transfer,
+ )
+
def create_circle_haushalt(lp, title="Haushalt"):
circle = CircleFactory(
diff --git a/server/vbv_lernwelt/learnpath/graphql/types.py b/server/vbv_lernwelt/learnpath/graphql/types.py
index 4e12cc4b..0cd23953 100644
--- a/server/vbv_lernwelt/learnpath/graphql/types.py
+++ b/server/vbv_lernwelt/learnpath/graphql/types.py
@@ -234,7 +234,7 @@ class LearningUnitObjectType(DjangoObjectType):
class Meta:
model = LearningUnit
interfaces = (CoursePageInterface,)
- fields = ["evaluate_url", "title_hidden"]
+ fields = ["evaluate_url", "title_hidden", "feedback_user"]
def resolve_evaluate_url(self: LearningUnit, info, **kwargs):
return self.get_evaluate_url()
diff --git a/server/vbv_lernwelt/learnpath/migrations/0013_auto_20240117_1400.py b/server/vbv_lernwelt/learnpath/migrations/0013_auto_20240117_1400.py
new file mode 100644
index 00000000..b8a11cb8
--- /dev/null
+++ b/server/vbv_lernwelt/learnpath/migrations/0013_auto_20240117_1400.py
@@ -0,0 +1,41 @@
+# Generated by Django 3.2.20 on 2024-01-17 13:00
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("learnpath", "0012_auto_20231129_0827"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="learningunit",
+ name="feedback_user",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("NO_FEEDBACK", "NO_FEEDBACK"),
+ ("MENTOR_FEEDBACK", "MENTOR_FEEDBACK"),
+ ],
+ default="NO_FEEDBACK",
+ max_length=255,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="learningcontentassignment",
+ name="assignment_type",
+ field=models.CharField(
+ choices=[
+ ("PRAXIS_ASSIGNMENT", "PRAXIS_ASSIGNMENT"),
+ ("CASEWORK", "CASEWORK"),
+ ("PREP_ASSIGNMENT", "PREP_ASSIGNMENT"),
+ ("REFLECTION", "REFLECTION"),
+ ("CONDITION_ACCEPTANCE", "CONDITION_ACCEPTANCE"),
+ ("EDONIQ_TEST", "EDONIQ_TEST"),
+ ],
+ default="CASEWORK",
+ max_length=50,
+ ),
+ ),
+ ]
diff --git a/server/vbv_lernwelt/learnpath/migrations/0014_alter_learningunit_feedback_user.py b/server/vbv_lernwelt/learnpath/migrations/0014_alter_learningunit_feedback_user.py
new file mode 100644
index 00000000..91dd1d39
--- /dev/null
+++ b/server/vbv_lernwelt/learnpath/migrations/0014_alter_learningunit_feedback_user.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.2.20 on 2024-01-17 14:50
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("learnpath", "0013_auto_20240117_1400"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="learningunit",
+ name="feedback_user",
+ field=models.CharField(
+ choices=[
+ ("NO_FEEDBACK", "NO_FEEDBACK"),
+ ("MENTOR_FEEDBACK", "MENTOR_FEEDBACK"),
+ ],
+ default="NO_FEEDBACK",
+ max_length=255,
+ ),
+ ),
+ ]
diff --git a/server/vbv_lernwelt/learnpath/migrations/0015_set_feedback_user_mentor_for_vv.py b/server/vbv_lernwelt/learnpath/migrations/0015_set_feedback_user_mentor_for_vv.py
new file mode 100644
index 00000000..58bb55ec
--- /dev/null
+++ b/server/vbv_lernwelt/learnpath/migrations/0015_set_feedback_user_mentor_for_vv.py
@@ -0,0 +1,50 @@
+# Generated by Django 3.2.20 on 2024-02-12 15:19
+
+from django.db import migrations
+
+
+VV_COURSE_IDS_WITH_MENTOR_FEEDBACK = [
+ -4, # vv-de
+ -10, # vv-fr
+ -11, # vv-it
+]
+
+
+def is_learning_unit_in_vv_course(learning_unit):
+ return learning_unit.course_category.course_id in VV_COURSE_IDS_WITH_MENTOR_FEEDBACK
+
+
+def mutate_data(apps, schema_editor):
+ """
+ Enable feedback for learning units in VV courses, this means that on the self-assessment page
+ of the learning unit, the user can request feedback from the mentor.
+ """
+
+ LearningUnit = apps.get_model("learnpath", "LearningUnit") # noqa
+ for learning_unit in LearningUnit.objects.all():
+ if is_learning_unit_in_vv_course(learning_unit):
+ learning_unit.feedback_user = "MENTOR_FEEDBACK"
+ learning_unit.save()
+
+
+def rollback_data(apps, schema_editor):
+ """
+ Disable feedback for learning units in VV courses, this means that on the self-assessment page
+ of the learning unit, the user can not request feedback from the mentor. -> Default behaviour.
+ """
+
+ LearningUnit = apps.get_model("learnpath", "LearningUnit") # noqa
+ for learning_unit in LearningUnit.objects.all():
+ if is_learning_unit_in_vv_course(learning_unit):
+ learning_unit.feedback_user = "NO_FEEDBACK"
+ learning_unit.save()
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("learnpath", "0014_alter_learningunit_feedback_user"),
+ ]
+
+ operations = [
+ migrations.RunPython(mutate_data, rollback_data),
+ ]
diff --git a/server/vbv_lernwelt/learnpath/models.py b/server/vbv_lernwelt/learnpath/models.py
index 64d260cc..42b76a18 100644
--- a/server/vbv_lernwelt/learnpath/models.py
+++ b/server/vbv_lernwelt/learnpath/models.py
@@ -1,8 +1,10 @@
import re
+from enum import Enum
+from typing import Tuple
from django.db import models
from django.utils.text import slugify
-from wagtail.admin.panels import FieldPanel, PageChooserPanel
+from wagtail.admin.panels import FieldPanel, HelpPanel, PageChooserPanel
from wagtail.fields import RichTextField, StreamField
from wagtail.models import Page
@@ -117,6 +119,13 @@ class Circle(CourseBasePage):
return f"{self.title}"
+class LearningUnitPerformanceFeedbackType(Enum):
+ """Defines how feedback on the performance criteria (n) of a learning unit are given."""
+
+ NO_FEEDBACK = "NO_FEEDBACK"
+ MENTOR_FEEDBACK = "MENTOR_FEEDBACK"
+
+
class LearningSequence(CourseBasePage):
serialize_field_names = ["icon"]
@@ -169,10 +178,21 @@ class LearningUnit(CourseBasePage):
"course.CourseCategory", on_delete=models.SET_NULL, null=True, blank=True
)
title_hidden = models.BooleanField(default=False)
+ feedback_user = models.CharField(
+ max_length=255,
+ choices=[(tag.name, tag.name) for tag in LearningUnitPerformanceFeedbackType],
+ default=LearningUnitPerformanceFeedbackType.NO_FEEDBACK.name,
+ )
content_panels = Page.content_panels + [
FieldPanel("course_category"),
FieldPanel("title_hidden"),
+ FieldPanel("feedback_user"),
+ HelpPanel(
+ content="👆 Feedback zur Selbsteinschätzung: Normalerweise NO_FEEDBACK, "
+ "ausser bei den Lerninhalten Selbsteinschätzungen, die eine Bewertung haben von einer "
+ "Lernbegleitung haben sollen (z.B. VV)."
+ ),
]
class Meta:
@@ -200,21 +220,29 @@ class LearningUnit(CourseBasePage):
)
super(LearningUnit, self).save(clean, user, log_action, **kwargs)
- def get_frontend_url(self):
+ def get_frontend_url_parts(self) -> Tuple[str, str, str]:
+ """
+ Extracts the course, circle and learning unit part from the slug.
+ :return: Tuple of course, circle and learning unit part
+ """
+
r = re.compile(
r"^(?P.+?)-lp-circle-(?P.+?)-lu-(?P.+?)$"
)
m = r.match(self.slug)
+
if m is None:
- return "ERROR: could not parse slug"
- return f"/course/{m.group('coursePart')}/learn/{m.group('circlePart')}#lu-{m.group('luPart')}"
+ ValueError(f"Could not parse slug: {self.slug}")
+
+ return m.group("coursePart"), m.group("circlePart"), m.group("luPart")
+
+ def get_frontend_url(self):
+ course, circle, learning_unit = self.get_frontend_url_parts()
+ return f"/course/{course}/learn/{circle}#lu-{learning_unit}"
def get_evaluate_url(self):
- r = re.compile(
- r"^(?P.+?)-lp-circle-(?P.+?)-lu-(?P.+?)$"
- )
- m = r.match(self.slug)
- return f"/course/{m.group('coursePart')}/learn/{m.group('circlePart')}/evaluate/{m.group('luPart')}"
+ course, circle, learning_unit = self.get_frontend_url_parts()
+ return f"/course/{course}/learn/{circle}/evaluate/{learning_unit}"
def get_admin_display_title(self):
return f"LE: {self.draft_title}"
diff --git a/server/vbv_lernwelt/learnpath/serializers.py b/server/vbv_lernwelt/learnpath/serializers.py
index 3f6a3255..af616d38 100644
--- a/server/vbv_lernwelt/learnpath/serializers.py
+++ b/server/vbv_lernwelt/learnpath/serializers.py
@@ -20,6 +20,7 @@ class LearningUnitSerializer(
"course_category",
"children",
"title_hidden",
+ "feedback_user",
],
)
):
diff --git a/server/vbv_lernwelt/learnpath/tests/test_models.py b/server/vbv_lernwelt/learnpath/tests/test_models.py
index 416f5ce4..a5c198c4 100644
--- a/server/vbv_lernwelt/learnpath/tests/test_models.py
+++ b/server/vbv_lernwelt/learnpath/tests/test_models.py
@@ -2,6 +2,12 @@ from django.test import TestCase
from vbv_lernwelt.core.create_default_users import create_default_users
from vbv_lernwelt.course.creators.test_course import create_test_course
+from vbv_lernwelt.course.creators.test_utils import (
+ create_circle,
+ create_course,
+ create_course_session,
+ create_learning_unit,
+)
from vbv_lernwelt.learnpath.models import LearningContentPlaceholder
@@ -31,3 +37,22 @@ class SaveSlugTestCase(TestCase):
self.assertEqual(
lc_fachcheck.slug, "test-lehrgang-lp-circle-reisen-lc-fachcheck-foobar"
)
+
+ def test_learning_unit_frontend_url_parts(self):
+ # GIVEN
+ course, course_page = create_course(course_page_title="What Ever Course Page")
+ course_session = create_course_session(course=course, title=":)")
+ circle, _ = create_circle(title="A-nice Circle", course_page=course_page)
+
+ # WHEN
+ cut = create_learning_unit(
+ course_category_title="course category title",
+ circle=circle,
+ course=course,
+ )
+ course_part, circle_part, learning_unit_part = cut.get_frontend_url_parts()
+
+ # THEN
+ self.assertEqual(course_part, "what-ever-course-page")
+ self.assertEqual(circle_part, "a-nice-circle")
+ self.assertEqual(learning_unit_part, "course-category-title")
diff --git a/server/vbv_lernwelt/media_files/views.py b/server/vbv_lernwelt/media_files/views.py
index 96d624a4..55f37610 100644
--- a/server/vbv_lernwelt/media_files/views.py
+++ b/server/vbv_lernwelt/media_files/views.py
@@ -34,6 +34,8 @@ def user_image(request, image_id):
rendition.file.open("rb")
image_format = imghdr.what(rendition.file)
+
return StreamingHttpResponse(
- FileWrapper(rendition.file), content_type="image/" + image_format
+ FileWrapper(rendition.file),
+ content_type=f"image/{image_format}" if image_format else "binary/octet-stream",
)
diff --git a/server/vbv_lernwelt/notify/email/email_services.py b/server/vbv_lernwelt/notify/email/email_services.py
index 45144894..3dba1007 100644
--- a/server/vbv_lernwelt/notify/email/email_services.py
+++ b/server/vbv_lernwelt/notify/email/email_services.py
@@ -76,6 +76,20 @@ class EmailTemplate(Enum):
"it": "d-30c6aa9accda4973a940dd25703cb4a9",
}
+ # Fremdeinschätzung (Requester → Provider)
+ SELF_EVALUATION_FEEDBACK_REQUESTED = {
+ "de": "d-cf9c6681991d4293a7baccaa5b043c5c",
+ "fr": "d-6b103876807a4a0db6a0c31651c1e8ba",
+ "it": "d-403b2f9d09bb41dc9dc85eed6c35c942",
+ }
+
+ # Fremdeinschätzung (Provider → Requester)
+ SELF_EVALUATION_FEEDBACK_PROVIDED = {
+ "de": "d-e547bff40252458fa802759f2c502e3a",
+ "fr": "d-62aa7ce8639c49319f92edb858bbb1cd",
+ "it": "d-de2b5dfaf5d2470dbeea5d3ea2a6c442",
+ }
+
def send_email(
recipient_email: str,
diff --git a/server/vbv_lernwelt/notify/models.py b/server/vbv_lernwelt/notify/models.py
index 4e87ffea..70a846de 100644
--- a/server/vbv_lernwelt/notify/models.py
+++ b/server/vbv_lernwelt/notify/models.py
@@ -24,6 +24,13 @@ class NotificationTrigger(models.TextChoices):
CASEWORK_EVALUATED = "CASEWORK_EVALUATED", _("Casework Evaluated")
NEW_FEEDBACK = "NEW_FEEDBACK", _("New Feedback")
+ SELF_EVALUATION_FEEDBACK_REQUESTED = "SELF_EVALUATION_FEEDBACK_REQUESTED", _(
+ "Self Evaluation Feedback Requested"
+ )
+ SELF_EVALUATION_FEEDBACK_PROVIDED = "SELF_EVALUATION_FEEDBACK_PROVIDED", _(
+ "Self Evaluation Feedback Provided"
+ )
+
class Notification(AbstractNotification):
# UUIDs are not supported by the notifications app...
diff --git a/server/vbv_lernwelt/notify/services.py b/server/vbv_lernwelt/notify/services.py
index 8ce851d7..6f515b2b 100644
--- a/server/vbv_lernwelt/notify/services.py
+++ b/server/vbv_lernwelt/notify/services.py
@@ -23,6 +23,7 @@ from vbv_lernwelt.notify.models import (
NotificationCategory,
NotificationTrigger,
)
+from vbv_lernwelt.self_evaluation_feedback.models import SelfEvaluationFeedback
if TYPE_CHECKING:
from vbv_lernwelt.assignment.models import AssignmentCompletion
@@ -73,6 +74,74 @@ class NotificationService:
email_template=EmailTemplate.CASEWORK_SUBMITTED,
)
+ @classmethod
+ def send_self_evaluation_feedback_request_feedback_notification(
+ cls,
+ self_evaluation_feedback: SelfEvaluationFeedback,
+ ):
+ """Requester -> Provider"""
+ requester_user = self_evaluation_feedback.feedback_requester_user
+ provider_user = self_evaluation_feedback.feedback_provider_user
+
+ texts = {
+ "de": "%(requester)s hat eine Selbsteinschätzung mit dir geteilt",
+ "fr": "%(requester)s a partagé une auto-évaluation avec vous",
+ "it": "%(requester)s ha condiviso una valutazione personale con te",
+ }
+
+ verb = texts.get(provider_user.language, "de") % {
+ "requester": requester_user.get_full_name(),
+ }
+
+ return cls._send_notification(
+ recipient=provider_user,
+ verb=verb,
+ notification_category=NotificationCategory.USER_INTERACTION,
+ notification_trigger=NotificationTrigger.SELF_EVALUATION_FEEDBACK_REQUESTED,
+ sender=requester_user,
+ target_url=self_evaluation_feedback.feedback_provider_evaluation_url,
+ action_object=self_evaluation_feedback,
+ email_template=EmailTemplate.SELF_EVALUATION_FEEDBACK_REQUESTED,
+ template_data={
+ "mentee_name": requester_user.get_full_name(),
+ "mentee_email": requester_user.email,
+ },
+ )
+
+ @classmethod
+ def send_self_evaluation_feedback_received_notification(
+ cls,
+ self_evaluation_feedback: SelfEvaluationFeedback,
+ ):
+ """Provider -> Requester"""
+ requester_user = self_evaluation_feedback.feedback_requester_user
+ provider_user = self_evaluation_feedback.feedback_provider_user
+
+ texts = {
+ "de": "%(provider)s hat dir eine Fremdeinschätzung gegeben",
+ "fr": "%(provider)s vous a donné une évaluation externe",
+ "it": "%(provider)s ti ha dato una valutazione esterna",
+ }
+
+ verb = texts.get(requester_user.language, "de") % {
+ "provider": provider_user.get_full_name(),
+ }
+
+ return cls._send_notification(
+ recipient=requester_user,
+ verb=verb,
+ notification_category=NotificationCategory.USER_INTERACTION,
+ notification_trigger=NotificationTrigger.SELF_EVALUATION_FEEDBACK_PROVIDED,
+ sender=provider_user,
+ target_url=self_evaluation_feedback.feedback_requester_results_url,
+ action_object=self_evaluation_feedback,
+ email_template=EmailTemplate.SELF_EVALUATION_FEEDBACK_PROVIDED,
+ template_data={
+ "mentor_name": provider_user.get_full_name(),
+ "mentor_email": provider_user.email,
+ },
+ )
+
@classmethod
def send_assignment_evaluated_notification(
cls,
diff --git a/server/vbv_lernwelt/self_evaluation_feedback/__init__.py b/server/vbv_lernwelt/self_evaluation_feedback/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/vbv_lernwelt/self_evaluation_feedback/admin.py b/server/vbv_lernwelt/self_evaluation_feedback/admin.py
new file mode 100644
index 00000000..acabf04e
--- /dev/null
+++ b/server/vbv_lernwelt/self_evaluation_feedback/admin.py
@@ -0,0 +1,49 @@
+from django.contrib import admin
+
+from vbv_lernwelt.self_evaluation_feedback.models import (
+ CourseCompletionFeedback,
+ SelfEvaluationFeedback,
+)
+
+
+@admin.register(SelfEvaluationFeedback)
+class CourseSessionAdmin(admin.ModelAdmin):
+ list_display = (
+ "id",
+ "feedback_submitted",
+ "feedback_requester_user",
+ "feedback_provider_user",
+ "learning_unit",
+ )
+ list_filter = (
+ "feedback_submitted",
+ "feedback_requester_user",
+ "feedback_provider_user",
+ "learning_unit",
+ )
+ search_fields = (
+ "feedback_submitted",
+ "feedback_requester_user",
+ "feedback_provider_user",
+ "learning_unit",
+ )
+
+
+@admin.register(CourseCompletionFeedback)
+class CourseSessionAdmin(admin.ModelAdmin):
+ list_display = (
+ "id",
+ "feedback",
+ "course_completion",
+ "feedback_assessment",
+ )
+ list_filter = (
+ "feedback",
+ "course_completion",
+ "feedback_assessment",
+ )
+ search_fields = (
+ "feedback",
+ "course_completion",
+ "feedback_assessment",
+ )
diff --git a/server/vbv_lernwelt/self_evaluation_feedback/apps.py b/server/vbv_lernwelt/self_evaluation_feedback/apps.py
new file mode 100644
index 00000000..d0cec9eb
--- /dev/null
+++ b/server/vbv_lernwelt/self_evaluation_feedback/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class SelfEvaluationFeedbackConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "vbv_lernwelt.self_evaluation_feedback"
diff --git a/server/vbv_lernwelt/self_evaluation_feedback/migrations/0001_initial.py b/server/vbv_lernwelt/self_evaluation_feedback/migrations/0001_initial.py
new file mode 100644
index 00000000..faa03afb
--- /dev/null
+++ b/server/vbv_lernwelt/self_evaluation_feedback/migrations/0001_initial.py
@@ -0,0 +1,113 @@
+# Generated by Django 3.2.20 on 2024-01-21 18:42
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+import vbv_lernwelt.course.models
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = [
+ ("course", "0006_auto_20231221_1411"),
+ ("learnpath", "0014_alter_learningunit_feedback_user"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="SelfEvaluationFeedback",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("feedback_submitted", models.BooleanField(default=False)),
+ (
+ "feedback_provider_user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="feedback_provider_user",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "feedback_requester_user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="feedback_requester_user",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "learning_unit",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="learnpath.learningunit",
+ ),
+ ),
+ ],
+ ),
+ migrations.CreateModel(
+ name="CourseCompletionFeedback",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "provider_evaluation_feedback",
+ models.CharField(
+ choices=[
+ (
+ vbv_lernwelt.course.models.CourseCompletionStatus[
+ "SUCCESS"
+ ],
+ "SUCCESS",
+ ),
+ (
+ vbv_lernwelt.course.models.CourseCompletionStatus[
+ "FAIL"
+ ],
+ "FAIL",
+ ),
+ (
+ vbv_lernwelt.course.models.CourseCompletionStatus[
+ "UNKNOWN"
+ ],
+ "UNKNOWN",
+ ),
+ ],
+ default="UNKNOWN",
+ max_length=255,
+ ),
+ ),
+ (
+ "feedback",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="self_evaluation_feedback.selfevaluationfeedback",
+ ),
+ ),
+ (
+ "requester_evaluation",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="course.coursecompletion",
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/server/vbv_lernwelt/self_evaluation_feedback/migrations/0002_rename_requester_evaluation_coursecompletionfeedback_course_completion.py b/server/vbv_lernwelt/self_evaluation_feedback/migrations/0002_rename_requester_evaluation_coursecompletionfeedback_course_completion.py
new file mode 100644
index 00000000..66eea762
--- /dev/null
+++ b/server/vbv_lernwelt/self_evaluation_feedback/migrations/0002_rename_requester_evaluation_coursecompletionfeedback_course_completion.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.2.20 on 2024-01-22 13:20
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("self_evaluation_feedback", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name="coursecompletionfeedback",
+ old_name="requester_evaluation",
+ new_name="course_completion",
+ ),
+ ]
diff --git a/server/vbv_lernwelt/self_evaluation_feedback/migrations/0003_rename_provider_evaluation_feedback_coursecompletionfeedback_feedback_assessment.py b/server/vbv_lernwelt/self_evaluation_feedback/migrations/0003_rename_provider_evaluation_feedback_coursecompletionfeedback_feedback_assessment.py
new file mode 100644
index 00000000..1c803c26
--- /dev/null
+++ b/server/vbv_lernwelt/self_evaluation_feedback/migrations/0003_rename_provider_evaluation_feedback_coursecompletionfeedback_feedback_assessment.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.2.20 on 2024-01-23 15:46
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ (
+ "self_evaluation_feedback",
+ "0002_rename_requester_evaluation_coursecompletionfeedback_course_completion",
+ ),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name="coursecompletionfeedback",
+ old_name="provider_evaluation_feedback",
+ new_name="feedback_assessment",
+ ),
+ ]
diff --git a/server/vbv_lernwelt/self_evaluation_feedback/migrations/__init__.py b/server/vbv_lernwelt/self_evaluation_feedback/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/vbv_lernwelt/self_evaluation_feedback/models.py b/server/vbv_lernwelt/self_evaluation_feedback/models.py
new file mode 100644
index 00000000..2bbee343
--- /dev/null
+++ b/server/vbv_lernwelt/self_evaluation_feedback/models.py
@@ -0,0 +1,44 @@
+from django.db import models
+
+from vbv_lernwelt.core.admin import User
+from vbv_lernwelt.course.models import CourseCompletion, CourseCompletionStatus
+
+
+class SelfEvaluationFeedback(models.Model):
+ feedback_submitted = models.BooleanField(default=False)
+
+ feedback_requester_user = models.ForeignKey(
+ User, on_delete=models.CASCADE, related_name="feedback_requester_user"
+ )
+
+ feedback_provider_user = models.ForeignKey(
+ User, on_delete=models.CASCADE, related_name="feedback_provider_user"
+ )
+
+ learning_unit = models.ForeignKey(
+ "learnpath.LearningUnit", on_delete=models.CASCADE
+ )
+
+ @property
+ def feedback_requester_results_url(self) -> str:
+ url = self.learning_unit.get_evaluate_url()
+ received_evaluation_step = len(self.learning_unit.performancecriteria_set.all())
+ return f"{url}?step={received_evaluation_step}"
+
+ @property
+ def feedback_provider_evaluation_url(self) -> str:
+ course, _, __ = self.learning_unit.get_frontend_url_parts()
+ return f"/course/{course}/cockpit/mentor/self-evaluation-feedback/{self.learning_unit.id}"
+
+
+class CourseCompletionFeedback(models.Model):
+ feedback = models.ForeignKey(SelfEvaluationFeedback, on_delete=models.CASCADE)
+
+ # the course completion has to be evaluated by the feedback provider
+ course_completion = models.ForeignKey(CourseCompletion, on_delete=models.CASCADE)
+
+ feedback_assessment = models.CharField(
+ max_length=255,
+ choices=[(status, status.value) for status in CourseCompletionStatus],
+ default=CourseCompletionStatus.UNKNOWN.value,
+ )
diff --git a/server/vbv_lernwelt/self_evaluation_feedback/serializers.py b/server/vbv_lernwelt/self_evaluation_feedback/serializers.py
new file mode 100644
index 00000000..fd24d363
--- /dev/null
+++ b/server/vbv_lernwelt/self_evaluation_feedback/serializers.py
@@ -0,0 +1,81 @@
+from typing import List
+
+from rest_framework import serializers
+
+from vbv_lernwelt.competence.models import PerformanceCriteria
+from vbv_lernwelt.core.serializers import UserSerializer
+from vbv_lernwelt.course.models import CourseCompletion, CourseCompletionStatus
+from vbv_lernwelt.self_evaluation_feedback.models import (
+ CourseCompletionFeedback,
+ SelfEvaluationFeedback,
+)
+
+
+class SelfEvaluationFeedbackSerializer(serializers.ModelSerializer):
+ criteria = serializers.SerializerMethodField()
+ feedback_requester_user = UserSerializer(read_only=True)
+ feedback_provider_user = UserSerializer(read_only=True)
+ learning_unit_id = serializers.PrimaryKeyRelatedField(
+ read_only=True, source="learning_unit"
+ )
+ feedback_id = serializers.PrimaryKeyRelatedField(read_only=True, source="id")
+ circle_name = serializers.SerializerMethodField()
+ title = serializers.CharField(source="learning_unit.title")
+
+ class Meta:
+ model = SelfEvaluationFeedback
+ fields = [
+ "feedback_id",
+ "title",
+ "circle_name",
+ "learning_unit_id",
+ "feedback_submitted",
+ "feedback_requester_user",
+ "feedback_provider_user",
+ "criteria",
+ ]
+
+ def get_circle_name(self, obj):
+ return obj.learning_unit.get_circle().title
+
+ def get_criteria(self, obj):
+ performance_criteria: List[
+ PerformanceCriteria
+ ] = obj.learning_unit.performancecriteria_set.all()
+
+ criteria = []
+
+ for pc in performance_criteria:
+ # requester self assessment
+ completion = CourseCompletion.objects.filter(
+ page_id=pc.id,
+ user=obj.feedback_requester_user,
+ ).first()
+
+ self_assessment = (
+ completion.completion_status
+ if completion
+ else CourseCompletionStatus.UNKNOWN.value
+ )
+
+ # provider feedback assessment
+ feedback = CourseCompletionFeedback.objects.filter(
+ course_completion=completion
+ ).first()
+
+ feedback_assessment = (
+ feedback.feedback_assessment
+ if feedback
+ else CourseCompletionStatus.UNKNOWN.value
+ )
+
+ criteria.append(
+ {
+ "course_completion_id": completion.id if completion else None,
+ "title": pc.title,
+ "self_assessment": self_assessment,
+ "feedback_assessment": feedback_assessment,
+ }
+ )
+
+ return criteria
diff --git a/server/vbv_lernwelt/self_evaluation_feedback/tests/__init__.py b/server/vbv_lernwelt/self_evaluation_feedback/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/vbv_lernwelt/self_evaluation_feedback/tests/test_api.py b/server/vbv_lernwelt/self_evaluation_feedback/tests/test_api.py
new file mode 100644
index 00000000..440dfb5e
--- /dev/null
+++ b/server/vbv_lernwelt/self_evaluation_feedback/tests/test_api.py
@@ -0,0 +1,672 @@
+from unittest.mock import patch
+
+from django.urls import reverse
+from rest_framework.test import APITestCase
+
+from vbv_lernwelt.course.creators.test_utils import (
+ add_course_session_user,
+ create_circle,
+ create_course,
+ create_course_session,
+ create_learning_unit,
+ create_performance_criteria_page,
+ create_user,
+)
+from vbv_lernwelt.course.models import CourseCompletionStatus, CourseSessionUser
+from vbv_lernwelt.course.services import mark_course_completion
+from vbv_lernwelt.learning_mentor.models import LearningMentor
+from vbv_lernwelt.learnpath.models import LearningUnitPerformanceFeedbackType
+from vbv_lernwelt.self_evaluation_feedback.models import (
+ CourseCompletionFeedback,
+ SelfEvaluationFeedback,
+)
+
+
+def create_self_evaluation_feedback(
+ learning_unit, feedback_requester_user, feedback_provider_user
+):
+ return SelfEvaluationFeedback.objects.create(
+ learning_unit=learning_unit,
+ feedback_requester_user=feedback_requester_user,
+ feedback_provider_user=feedback_provider_user,
+ )
+
+
+class SelfEvaluationFeedbackAPI(APITestCase):
+ def setUp(self) -> None:
+ self.member = create_user("member")
+ self.mentor = create_user("mentor")
+
+ self.course, self.course_page = create_course("Test Course")
+ self.course_session = create_course_session(
+ course=self.course, title="Test Bern 2022 a"
+ )
+
+ member_csu = add_course_session_user(
+ course_session=self.course_session,
+ user=self.member,
+ role=CourseSessionUser.Role.MEMBER,
+ )
+
+ self.circle, _ = create_circle(
+ title="Test Circle", course_page=self.course_page
+ )
+
+ learning_mentor = LearningMentor.objects.create(
+ mentor=self.mentor,
+ course=self.course_session.course,
+ )
+
+ learning_mentor.participants.add(member_csu)
+
+ @patch(
+ "vbv_lernwelt.notify.services.NotificationService.send_self_evaluation_feedback_request_feedback_notification"
+ )
+ def test_start_self_evaluation_feedback(self, mock_notification_service_send):
+ # GIVEN
+ learning_unit = create_learning_unit(course=self.course, circle=self.circle)
+
+ pc = create_performance_criteria_page(
+ course=self.course,
+ course_page=self.course_page,
+ circle=self.circle,
+ learning_unit=learning_unit,
+ )
+
+ mark_course_completion(
+ page=pc,
+ user=self.member,
+ course_session=self.course_session,
+ completion_status="SUCCESS",
+ )
+
+ self.client.force_login(self.member)
+
+ # WHEN
+ response = self.client.post(
+ reverse(
+ "start_self_evaluation_feedback",
+ args=[learning_unit.id],
+ ),
+ {
+ "feedback_provider_user_id": self.mentor.id,
+ },
+ )
+
+ # make sure re-starting is a no-op
+ self.client.post(
+ reverse(
+ "start_self_evaluation_feedback",
+ args=[learning_unit.id],
+ ),
+ {
+ "feedback_provider_user_id": self.mentor.id,
+ },
+ )
+
+ # shall be idempotent
+ self.client.post(
+ reverse(
+ "start_self_evaluation_feedback",
+ args=[learning_unit.id],
+ ),
+ {
+ "feedback_provider_user_id": self.mentor.id,
+ },
+ )
+
+ # THEN
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.data["success"], True)
+
+ self.assertEqual(
+ SelfEvaluationFeedback.objects.count(),
+ 1,
+ )
+
+ self_evaluation_feedback = SelfEvaluationFeedback.objects.first()
+ self.assertEqual(self_evaluation_feedback.feedback_requester_user, self.member)
+ self.assertEqual(self_evaluation_feedback.feedback_provider_user, self.mentor)
+ self.assertEqual(self_evaluation_feedback.learning_unit, learning_unit)
+
+ mock_notification_service_send.assert_called_once_with(
+ self_evaluation_feedback=self_evaluation_feedback
+ )
+
+ def test_start_self_evaluation_feedback_not_allowed_user(self):
+ # GIVEN
+ learning_unit = create_learning_unit(course=self.course, circle=self.circle)
+ not_a_mentor = create_user("not_a_mentor")
+
+ self.client.force_login(self.member)
+
+ # WHEN
+ response = self.client.post(
+ reverse("start_self_evaluation_feedback", args=[learning_unit.id]),
+ {
+ "feedback_provider_user_id": not_a_mentor.id,
+ },
+ )
+
+ # THEN
+ self.assertEqual(response.status_code, 403)
+
+ def test_get_self_evaluation_feedback_as_requester(self):
+ """Tests endpoint of feedback REQUESTER"""
+
+ # GIVEN
+ learning_unit = create_learning_unit( # noqa
+ course=self.course, circle=self.circle
+ )
+
+ performance_criteria_1 = create_performance_criteria_page(
+ course=self.course,
+ course_page=self.course_page,
+ circle=self.circle,
+ learning_unit=learning_unit,
+ )
+
+ create_performance_criteria_page(
+ course=self.course,
+ course_page=self.course_page,
+ circle=self.circle,
+ learning_unit=learning_unit,
+ )
+
+ completion = mark_course_completion(
+ page=performance_criteria_1,
+ user=self.member,
+ course_session=self.course_session,
+ completion_status=CourseCompletionStatus.SUCCESS.value,
+ )
+
+ self_evaluation_feedback = create_self_evaluation_feedback(
+ learning_unit=learning_unit,
+ feedback_requester_user=self.member,
+ feedback_provider_user=self.mentor,
+ )
+
+ CourseCompletionFeedback.objects.create(
+ feedback=self_evaluation_feedback,
+ course_completion=completion,
+ feedback_assessment=CourseCompletionStatus.FAIL.value,
+ )
+
+ self.client.force_login(self.member)
+
+ # WHEN
+ response = self.client.get(
+ reverse(
+ "get_self_evaluation_feedback_as_requester",
+ args=[learning_unit.id],
+ )
+ )
+
+ # THEN
+ self.assertEqual(response.status_code, 200)
+
+ feedback = response.data
+ self.assertEqual(feedback["learning_unit_id"], learning_unit.id)
+ self.assertFalse(feedback["feedback_submitted"])
+ self.assertEqual(feedback["circle_name"], self.circle.title) # noqa
+
+ provider_user = feedback["feedback_provider_user"]
+ self.assertEqual(provider_user["id"], str(self.mentor.id))
+ self.assertEqual(provider_user["first_name"], self.mentor.first_name)
+ self.assertEqual(provider_user["last_name"], self.mentor.last_name)
+ self.assertEqual(provider_user["avatar_url"], self.mentor.avatar_url)
+
+ requester_user = feedback["feedback_requester_user"]
+ self.assertEqual(requester_user["id"], str(self.member.id)) # noqa
+ self.assertEqual(requester_user["first_name"], self.member.first_name)
+ self.assertEqual(requester_user["last_name"], self.member.last_name)
+ self.assertEqual(requester_user["avatar_url"], self.member.avatar_url)
+
+ self.assertEqual(len(feedback["criteria"]), 2)
+
+ first_criteria = feedback["criteria"][0]
+ self.assertEqual(first_criteria["course_completion_id"], completion.id)
+ self.assertEqual(first_criteria["title"], performance_criteria_1.title)
+ self.assertEqual(
+ first_criteria["self_assessment"],
+ CourseCompletionStatus.SUCCESS.value,
+ )
+ self.assertEqual(
+ first_criteria["feedback_assessment"], CourseCompletionStatus.FAIL.value
+ )
+
+ second_criteria = feedback["criteria"][1]
+ self.assertEqual(second_criteria["course_completion_id"], None)
+ self.assertEqual(second_criteria["title"], performance_criteria_1.title)
+ self.assertEqual(
+ second_criteria["self_assessment"], CourseCompletionStatus.UNKNOWN.value
+ )
+ self.assertEqual(
+ second_criteria["feedback_assessment"],
+ CourseCompletionStatus.UNKNOWN.value,
+ )
+
+ def test_feedbacks_with_mixed_completion_statuses(self):
+ """Case: CourseCompletion AND feedbacks with mixed completion statuses"""
+
+ # GIVEN
+ learning_unit = create_learning_unit(
+ course=self.course,
+ circle=self.circle,
+ feedback_user=LearningUnitPerformanceFeedbackType.MENTOR_FEEDBACK,
+ )
+
+ feedback = create_self_evaluation_feedback(
+ learning_unit=learning_unit,
+ feedback_requester_user=self.member,
+ feedback_provider_user=self.mentor,
+ )
+
+ feedback.feedback_submitted = True
+ feedback.save()
+
+ for status in [
+ CourseCompletionStatus.SUCCESS,
+ CourseCompletionStatus.FAIL,
+ CourseCompletionStatus.UNKNOWN,
+ ]:
+ criteria_page = create_performance_criteria_page(
+ course=self.course,
+ course_page=self.course_page,
+ circle=self.circle,
+ learning_unit=learning_unit,
+ )
+
+ # self assessment
+ completion = mark_course_completion(
+ page=criteria_page,
+ user=self.member,
+ course_session=self.course_session,
+ completion_status=status.value,
+ )
+
+ # feedback assessment
+ CourseCompletionFeedback.objects.create(
+ feedback=feedback,
+ course_completion=completion,
+ feedback_assessment=status.value,
+ )
+
+ self.client.force_login(self.member)
+
+ # WHEN
+ response = self.client.get(
+ reverse(
+ "get_self_evaluation_feedbacks_as_requester",
+ args=[self.course_session.id],
+ )
+ )
+
+ # THEN
+ self.assertEqual(response.status_code, 200)
+
+ result = response.data["results"][0]
+
+ self_assessment = result["self_assessment"]
+ self.assertEqual(self_assessment["counts"]["pass"], 1)
+ self.assertEqual(self_assessment["counts"]["fail"], 1)
+ self.assertEqual(self_assessment["counts"]["unknown"], 1)
+
+ feedback_assessment = result["feedback_assessment"]
+ self.assertEqual(feedback_assessment["counts"]["pass"], 1)
+ self.assertEqual(feedback_assessment["counts"]["fail"], 1)
+ self.assertEqual(feedback_assessment["counts"]["unknown"], 1)
+
+ self.assertTrue(feedback_assessment["submitted_by_provider"])
+ self.assertEqual(
+ feedback_assessment["provider_user"]["id"], str(self.mentor.id)
+ )
+
+ aggregate = response.data["aggregates"]
+ self.assertEqual(aggregate["self_assessment"]["pass"], 1)
+ self.assertEqual(aggregate["self_assessment"]["fail"], 1)
+ self.assertEqual(aggregate["self_assessment"]["unknown"], 1)
+ self.assertEqual(aggregate["feedback_assessment"]["pass"], 1)
+ self.assertEqual(aggregate["feedback_assessment"]["fail"], 1)
+ self.assertEqual(aggregate["feedback_assessment"]["unknown"], 1)
+
+ def test_no_feedbacks_but_with_completion_status(self):
+ """Case: CourseCompletion but NO feedback"""
+
+ # GIVEN
+ learning_unit_with_success_feedback = create_learning_unit(
+ course=self.course,
+ circle=self.circle,
+ feedback_user=LearningUnitPerformanceFeedbackType.MENTOR_FEEDBACK,
+ )
+
+ performance_criteria_page = create_performance_criteria_page(
+ course=self.course,
+ course_page=self.course_page,
+ circle=self.circle,
+ learning_unit=learning_unit_with_success_feedback,
+ )
+
+ # IMPORTANT: CourseCompletion but NO feedback!
+
+ mark_course_completion(
+ page=performance_criteria_page,
+ user=self.member,
+ course_session=self.course_session,
+ completion_status=CourseCompletionStatus.SUCCESS.value,
+ )
+
+ self.client.force_login(self.member)
+
+ # WHEN
+ response = self.client.get(
+ reverse(
+ "get_self_evaluation_feedbacks_as_requester",
+ args=[self.course_session.id],
+ )
+ )
+
+ # THEN
+ self.assertEqual(response.status_code, 200)
+
+ result = response.data["results"][0]
+ counts = result["self_assessment"]["counts"]
+ self.assertEqual(counts["pass"], 1)
+ self.assertEqual(counts["fail"], 0)
+ self.assertEqual(counts["unknown"], 0)
+
+ def test_feedbacks_not_started(self):
+ """Case: Learning unit with no completion status and no feedback"""
+
+ # GIVEN
+ learning_unit = create_learning_unit( # noqa
+ course=self.course,
+ circle=self.circle,
+ feedback_user=LearningUnitPerformanceFeedbackType.MENTOR_FEEDBACK,
+ )
+
+ create_performance_criteria_page(
+ course=self.course,
+ course_page=self.course_page,
+ circle=self.circle,
+ learning_unit=learning_unit,
+ )
+
+ self.client.force_login(self.member)
+
+ # WHEN
+ response = self.client.get(
+ reverse(
+ "get_self_evaluation_feedbacks_as_requester",
+ args=[self.course_session.id],
+ )
+ )
+
+ # THEN
+ self.assertEqual(response.status_code, 200)
+
+ result = response.data["results"][0]
+ self.assertEqual(result["self_assessment"]["counts"]["pass"], 0)
+ self.assertEqual(result["self_assessment"]["counts"]["fail"], 0)
+ self.assertEqual(result["self_assessment"]["counts"]["unknown"], 1)
+
+ def test_feedbacks_metadata(self):
+ # GIVEN
+ learning_unit = create_learning_unit( # noqa
+ course=self.course,
+ circle=self.circle,
+ feedback_user=LearningUnitPerformanceFeedbackType.MENTOR_FEEDBACK,
+ )
+
+ create_performance_criteria_page(
+ course=self.course,
+ course_page=self.course_page,
+ circle=self.circle,
+ learning_unit=learning_unit,
+ )
+
+ self.client.force_login(self.member)
+
+ # WHEN
+ response = self.client.get(
+ reverse(
+ "get_self_evaluation_feedbacks_as_requester",
+ args=[self.course_session.id],
+ )
+ )
+
+ # THEN
+ self.assertEqual(response.status_code, 200)
+
+ result = response.data["results"][0]
+ self.assertEqual(result["title"], learning_unit.title)
+ self.assertEqual(result["id"], learning_unit.id)
+ self.assertEqual(result["circle_id"], self.circle.id)
+ self.assertEqual(result["circle_title"], self.circle.title)
+ self.assertEqual(result["detail_url"], learning_unit.get_evaluate_url())
+
+ circles = response.data["circles"]
+ self.assertEqual(len(circles), 1)
+ self.assertEqual(circles[0]["id"], self.circle.id)
+ self.assertEqual(circles[0]["title"], self.circle.title)
+
+ def test_get_self_evaluation_feedback_as_provider(self):
+ """Tests endpoint of feedback PROVIDER"""
+
+ # GIVEN
+ learning_unit = create_learning_unit( # noqa
+ course=self.course, circle=self.circle
+ )
+
+ performance_criteria_1 = create_performance_criteria_page(
+ course=self.course,
+ course_page=self.course_page,
+ circle=self.circle,
+ learning_unit=learning_unit,
+ )
+
+ create_performance_criteria_page(
+ course=self.course,
+ course_page=self.course_page,
+ circle=self.circle,
+ learning_unit=learning_unit,
+ )
+
+ completion = mark_course_completion(
+ page=performance_criteria_1,
+ user=self.member,
+ course_session=self.course_session,
+ completion_status=CourseCompletionStatus.SUCCESS.value,
+ )
+
+ self_evaluation_feedback = create_self_evaluation_feedback(
+ learning_unit=learning_unit,
+ feedback_requester_user=self.member,
+ feedback_provider_user=self.mentor,
+ )
+
+ CourseCompletionFeedback.objects.create(
+ feedback=self_evaluation_feedback,
+ course_completion=completion,
+ feedback_assessment=CourseCompletionStatus.FAIL.value,
+ )
+
+ self.client.force_login(self.mentor)
+
+ # WHEN
+ response = self.client.get(
+ reverse(
+ "get_self_evaluation_feedback_as_provider",
+ args=[self_evaluation_feedback.learning_unit.id],
+ )
+ )
+
+ # THEN
+ self.assertEqual(response.status_code, 200)
+
+ feedback = response.data
+ self.assertEqual(feedback["learning_unit_id"], learning_unit.id)
+ self.assertEqual(feedback["title"], learning_unit.title)
+ self.assertEqual(feedback["feedback_submitted"], False)
+ self.assertEqual(feedback["circle_name"], self.circle.title) # noqa
+
+ provider_user = feedback["feedback_provider_user"]
+ self.assertEqual(provider_user["id"], str(self.mentor.id)) # noqa
+ self.assertEqual(provider_user["first_name"], self.mentor.first_name)
+ self.assertEqual(provider_user["last_name"], self.mentor.last_name)
+ self.assertEqual(provider_user["avatar_url"], self.mentor.avatar_url)
+
+ requester_user = feedback["feedback_requester_user"]
+ self.assertEqual(requester_user["id"], str(self.member.id)) # noqa
+ self.assertEqual(requester_user["first_name"], self.member.first_name)
+ self.assertEqual(requester_user["last_name"], self.member.last_name)
+ self.assertEqual(requester_user["avatar_url"], self.member.avatar_url)
+
+ self.assertEqual(len(feedback["criteria"]), 2)
+
+ first_criteria = feedback["criteria"][0]
+ self.assertEqual(first_criteria["course_completion_id"], completion.id)
+ self.assertEqual(first_criteria["title"], performance_criteria_1.title)
+ self.assertEqual(
+ first_criteria["self_assessment"],
+ CourseCompletionStatus.SUCCESS.value,
+ )
+ self.assertEqual(
+ first_criteria["feedback_assessment"], CourseCompletionStatus.FAIL.value
+ )
+
+ second_criteria = feedback["criteria"][1]
+ self.assertEqual(second_criteria["course_completion_id"], None)
+ self.assertEqual(second_criteria["title"], performance_criteria_1.title)
+ self.assertEqual(
+ second_criteria["self_assessment"], CourseCompletionStatus.UNKNOWN.value
+ )
+ self.assertEqual(
+ second_criteria["feedback_assessment"],
+ CourseCompletionStatus.UNKNOWN.value,
+ )
+
+ def test_self_evaluation_feedback_assessment(self):
+ # GIVEN
+ learning_unit = create_learning_unit(course=self.course, circle=self.circle)
+
+ performance_criteria_1 = create_performance_criteria_page(
+ course=self.course,
+ course_page=self.course_page,
+ circle=self.circle,
+ learning_unit=learning_unit,
+ )
+
+ completion = mark_course_completion(
+ page=performance_criteria_1,
+ user=self.member,
+ course_session=self.course_session,
+ completion_status=CourseCompletionStatus.SUCCESS.value,
+ )
+
+ self_evaluation_feedback = create_self_evaluation_feedback(
+ learning_unit=learning_unit,
+ feedback_requester_user=self.member,
+ feedback_provider_user=self.mentor,
+ )
+
+ self.client.force_login(self.mentor)
+
+ # WHEN
+ response = self.client.put(
+ reverse(
+ "add_self_evaluation_feedback_assessment",
+ args=[self_evaluation_feedback.id],
+ ),
+ {
+ "course_completion_id": completion.id,
+ "feedback_assessment": CourseCompletionStatus.FAIL.value,
+ },
+ )
+
+ # THEN
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.data["success"], True)
+
+ feedback = CourseCompletionFeedback.objects.get(
+ feedback=self_evaluation_feedback,
+ course_completion=completion,
+ )
+
+ self.assertEqual(
+ feedback.feedback_assessment, CourseCompletionStatus.FAIL.value
+ )
+
+ @patch(
+ "vbv_lernwelt.notify.services.NotificationService.send_self_evaluation_feedback_received_notification"
+ )
+ def test_release_self_evaluation_feedback(self, mock_notification_service_send):
+ # GIVEN
+ learning_unit = create_learning_unit(course=self.course, circle=self.circle)
+ self_evaluation_feedback = create_self_evaluation_feedback(
+ learning_unit=learning_unit,
+ feedback_requester_user=self.member,
+ feedback_provider_user=self.mentor,
+ )
+
+ self.assertEqual(self_evaluation_feedback.feedback_submitted, False)
+ self.client.force_login(self.mentor)
+
+ # WHEN
+ self.client.put(
+ reverse(
+ "release_self_evaluation_feedback", args=[self_evaluation_feedback.id]
+ ),
+ )
+
+ # shall be idempotent
+ response = self.client.put(
+ reverse(
+ "release_self_evaluation_feedback", args=[self_evaluation_feedback.id]
+ ),
+ )
+
+ # THEN
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.data["success"], True)
+ self.assertEqual(
+ SelfEvaluationFeedback.objects.get(
+ id=self_evaluation_feedback.id
+ ).feedback_submitted,
+ True,
+ )
+
+ mock_notification_service_send.assert_called_once_with(
+ self_evaluation_feedback=self_evaluation_feedback
+ )
+
+ def test_get_self_evaluation_feedback_frontend_urls(self):
+ """Makes sure that the frontend urls are correct (used in notifications)"""
+ # GIVEN
+ learning_unit = create_learning_unit(course=self.course, circle=self.circle)
+
+ cut = create_self_evaluation_feedback(
+ learning_unit=learning_unit,
+ feedback_requester_user=self.member,
+ feedback_provider_user=self.mentor,
+ )
+
+ # WHEN
+ requester_url = cut.feedback_requester_results_url
+ provider_url = cut.feedback_provider_evaluation_url
+
+ # THEN
+ _course, _circle, _learning_unit = learning_unit.get_frontend_url_parts()
+
+ # 0 -> no completions so step=0 is correct
+ _step = len(learning_unit.performancecriteria_set.all())
+
+ self.assertEqual(
+ requester_url,
+ f"/course/{_course}/learn/{_circle}/evaluate/{_learning_unit}?step={_step}",
+ )
+
+ self.assertEqual(
+ provider_url,
+ f"/course/{_course}/cockpit/mentor/self-evaluation-feedback/{learning_unit.id}",
+ )
diff --git a/server/vbv_lernwelt/self_evaluation_feedback/urls.py b/server/vbv_lernwelt/self_evaluation_feedback/urls.py
new file mode 100644
index 00000000..856a802f
--- /dev/null
+++ b/server/vbv_lernwelt/self_evaluation_feedback/urls.py
@@ -0,0 +1,45 @@
+from django.urls import path
+
+from vbv_lernwelt.self_evaluation_feedback.views import (
+ add_provider_self_evaluation_feedback,
+ get_self_evaluation_feedback_as_provider,
+ get_self_evaluation_feedback_as_requester,
+ get_self_evaluation_feedbacks_as_requester,
+ release_provider_self_evaluation_feedback,
+ start_self_evaluation_feedback,
+)
+
+urlpatterns = [
+ # /requester/* URLs -> For the user who requests feedback
+ path(
+ "requester//feedbacks/summaries",
+ get_self_evaluation_feedbacks_as_requester,
+ name="get_self_evaluation_feedbacks_as_requester",
+ ),
+ path(
+ "requester//feedback/start",
+ start_self_evaluation_feedback,
+ name="start_self_evaluation_feedback",
+ ),
+ path(
+ "requester//feedback",
+ get_self_evaluation_feedback_as_requester,
+ name="get_self_evaluation_feedback_as_requester",
+ ),
+ # /provider/* URLs -> For the user who is providing feedback
+ path(
+ "provider//feedback",
+ get_self_evaluation_feedback_as_provider,
+ name="get_self_evaluation_feedback_as_provider",
+ ),
+ path(
+ "provider/feedback//release",
+ release_provider_self_evaluation_feedback,
+ name="release_self_evaluation_feedback",
+ ),
+ path(
+ "provider/feedback//add-assessment",
+ add_provider_self_evaluation_feedback,
+ name="add_self_evaluation_feedback_assessment",
+ ),
+]
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..5a0b2672
--- /dev/null
+++ b/server/vbv_lernwelt/self_evaluation_feedback/utils.py
@@ -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),
+ )
diff --git a/server/vbv_lernwelt/self_evaluation_feedback/views.py b/server/vbv_lernwelt/self_evaluation_feedback/views.py
new file mode 100644
index 00000000..2335dfe6
--- /dev/null
+++ b/server/vbv_lernwelt/self_evaluation_feedback/views.py
@@ -0,0 +1,258 @@
+import structlog
+from django.shortcuts import get_object_or_404
+from rest_framework.decorators import api_view, permission_classes
+from rest_framework.exceptions import PermissionDenied
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+
+from vbv_lernwelt.core.models import User
+from vbv_lernwelt.core.serializers import UserSerializer
+from vbv_lernwelt.course.models import CourseCompletion, CourseSession
+from vbv_lernwelt.learning_mentor.models import LearningMentor
+from vbv_lernwelt.learnpath.models import (
+ Circle,
+ LearningUnit,
+ LearningUnitPerformanceFeedbackType,
+)
+from vbv_lernwelt.notify.services import NotificationService
+from vbv_lernwelt.self_evaluation_feedback.models import (
+ CourseCompletionFeedback,
+ SelfEvaluationFeedback,
+)
+from vbv_lernwelt.self_evaluation_feedback.serializers import (
+ SelfEvaluationFeedbackSerializer,
+)
+from vbv_lernwelt.self_evaluation_feedback.utils import (
+ AssessmentCounts,
+ get_self_assessment_counts,
+ get_self_evaluation_feedback_counts,
+)
+
+logger = structlog.get_logger(__name__)
+
+
+@api_view(["POST"])
+@permission_classes([IsAuthenticated])
+def start_self_evaluation_feedback(request, learning_unit_id):
+ feedback_provider_user_id = request.data.get("feedback_provider_user_id")
+
+ learning_unit = get_object_or_404(LearningUnit, id=learning_unit_id)
+ feedback_provider_user = get_object_or_404(User, id=feedback_provider_user_id)
+
+ if not LearningMentor.objects.filter(
+ course=learning_unit.get_course(),
+ mentor=feedback_provider_user,
+ participants__user=request.user,
+ ).exists():
+ raise PermissionDenied()
+
+ # calling start multiple times shall be a no-op
+ feedback, created = SelfEvaluationFeedback.objects.get_or_create(
+ feedback_requester_user=request.user,
+ feedback_provider_user=feedback_provider_user,
+ learning_unit=learning_unit,
+ )
+
+ if created:
+ NotificationService.send_self_evaluation_feedback_request_feedback_notification(
+ self_evaluation_feedback=feedback
+ )
+
+ return Response({"success": True})
+
+
+@api_view(["PUT"])
+@permission_classes([IsAuthenticated])
+def release_provider_self_evaluation_feedback(request, feedback_id):
+ feedback = get_object_or_404(
+ SelfEvaluationFeedback, id=feedback_id, feedback_provider_user=request.user
+ )
+
+ if feedback.feedback_submitted:
+ return Response({"success": True})
+
+ feedback.feedback_submitted = True
+ feedback.save()
+
+ NotificationService.send_self_evaluation_feedback_received_notification(
+ self_evaluation_feedback=feedback
+ )
+
+ return Response({"success": True})
+
+
+@api_view(["GET"])
+@permission_classes([IsAuthenticated])
+def get_self_evaluation_feedback_as_provider(request, learning_unit_id):
+ feedback = get_object_or_404(
+ SelfEvaluationFeedback,
+ learning_unit_id=learning_unit_id,
+ feedback_provider_user=request.user,
+ )
+
+ return Response(SelfEvaluationFeedbackSerializer(feedback).data)
+
+
+@api_view(["GET"])
+@permission_classes([IsAuthenticated])
+def get_self_evaluation_feedbacks_as_requester(request, course_session_id: int):
+ course_session = get_object_or_404(CourseSession, id=course_session_id)
+
+ results = []
+ circle_ids = set()
+
+ all_self_assessment_counts = []
+ all_feedback_assessment_counts = []
+
+ for learning_unit in LearningUnit.objects.filter(
+ course_category__course=course_session.course,
+ ):
+ # this is not a problem in real life, but in the test environment
+ # we have a lot of learning units without self assessment criteria
+ # -> just skip those learning units
+ if len(learning_unit.performancecriteria_set.all()) == 0:
+ continue
+
+ circle = learning_unit.get_parent().specific
+ circle_ids.add(circle.id)
+
+ feedback = SelfEvaluationFeedback.objects.filter(
+ learning_unit=learning_unit,
+ feedback_requester_user=request.user,
+ ).first()
+
+ if not feedback:
+ # no feedback given yet
+ feedback_assessment = None
+ else:
+ # feedback given
+ feedback_counts = get_self_evaluation_feedback_counts(feedback)
+ all_feedback_assessment_counts.append(feedback_counts)
+
+ feedback_assessment = {
+ "submitted_by_provider": feedback.feedback_submitted,
+ "provider_user": UserSerializer(feedback.feedback_provider_user).data,
+ "counts": {
+ "pass": feedback_counts.pass_count,
+ "fail": feedback_counts.fail_count,
+ "unknown": feedback_counts.unknown_count,
+ },
+ }
+
+ self_assessment_counts = get_self_assessment_counts(learning_unit, request.user)
+ all_self_assessment_counts.append(self_assessment_counts)
+
+ results.append(
+ {
+ "id": learning_unit.id,
+ "title": learning_unit.title,
+ "detail_url": learning_unit.get_evaluate_url(),
+ "circle_id": circle.id,
+ "circle_title": circle.title,
+ "feedback_assessment": feedback_assessment,
+ "self_assessment": {
+ "counts": {
+ "pass": self_assessment_counts.pass_count,
+ "fail": self_assessment_counts.fail_count,
+ "unknown": self_assessment_counts.unknown_count,
+ }
+ },
+ }
+ )
+
+ self_assessment_counts_aggregate = AssessmentCounts(
+ pass_count=sum(x.pass_count for x in all_self_assessment_counts),
+ fail_count=sum(x.fail_count for x in all_self_assessment_counts),
+ unknown_count=sum(x.unknown_count for x in all_self_assessment_counts),
+ )
+ received_feedback_counts_aggregate = AssessmentCounts(
+ pass_count=sum(x.pass_count for x in all_feedback_assessment_counts),
+ fail_count=sum(x.fail_count for x in all_feedback_assessment_counts),
+ unknown_count=sum(x.unknown_count for x in all_feedback_assessment_counts),
+ )
+
+ # pad the feedback counts with unknowns for the
+ # learning units where we have no feedback yet
+ feedback_assessment_counts_aggregate = AssessmentCounts(
+ pass_count=received_feedback_counts_aggregate.pass_count,
+ fail_count=received_feedback_counts_aggregate.fail_count,
+ unknown_count=self_assessment_counts_aggregate.total_count
+ - received_feedback_counts_aggregate.total_count
+ + received_feedback_counts_aggregate.unknown_count,
+ )
+
+ # check if there are any learning units with mentor feedback
+ feedback_assessment_visible = (
+ LearningUnit.objects.filter(
+ feedback_user=LearningUnitPerformanceFeedbackType.MENTOR_FEEDBACK.value,
+ course_category__course=course_session.course,
+ ).count()
+ > 0
+ )
+
+ return Response(
+ {
+ "results": results,
+ "circles": list(
+ Circle.objects.filter(id__in=circle_ids).values("id", "title")
+ ),
+ "aggregates": {
+ "feedback_assessment_visible": feedback_assessment_visible,
+ "feedback_assessment": {
+ "pass": feedback_assessment_counts_aggregate.pass_count,
+ "fail": feedback_assessment_counts_aggregate.fail_count,
+ "unknown": feedback_assessment_counts_aggregate.unknown_count,
+ },
+ "self_assessment": {
+ "pass": self_assessment_counts_aggregate.pass_count,
+ "fail": self_assessment_counts_aggregate.fail_count,
+ "unknown": self_assessment_counts_aggregate.unknown_count,
+ },
+ },
+ }
+ )
+
+
+@api_view(["GET"])
+@permission_classes([IsAuthenticated])
+def get_self_evaluation_feedback_as_requester(request, learning_unit_id):
+ learning_unit = get_object_or_404(LearningUnit, id=learning_unit_id)
+
+ feedback = get_object_or_404(
+ SelfEvaluationFeedback,
+ learning_unit=learning_unit,
+ feedback_requester_user=request.user,
+ )
+
+ return Response(SelfEvaluationFeedbackSerializer(feedback).data)
+
+
+@api_view(["PUT"])
+@permission_classes([IsAuthenticated])
+def add_provider_self_evaluation_feedback(request, feedback_id):
+ feedback_assessment = request.data.get("feedback_assessment")
+
+ feedback = get_object_or_404(
+ SelfEvaluationFeedback, id=feedback_id, feedback_provider_user=request.user
+ )
+
+ course_completion = get_object_or_404(
+ CourseCompletion,
+ id=request.data.get("course_completion_id"),
+ user=feedback.feedback_requester_user,
+ )
+
+ (
+ course_completion_feedback,
+ created,
+ ) = CourseCompletionFeedback.objects.get_or_create(
+ feedback=feedback,
+ course_completion=course_completion,
+ defaults={"feedback_assessment": feedback_assessment},
+ )
+
+ if not created:
+ course_completion_feedback.feedback_assessment = feedback_assessment
+ course_completion_feedback.save()
+
+ return Response({"success": True})
diff --git a/server/vbv_lernwelt/templates/admin/index.html b/server/vbv_lernwelt/templates/admin/index.html
index 32d3e5f2..55e83b58 100644
--- a/server/vbv_lernwelt/templates/admin/index.html
+++ b/server/vbv_lernwelt/templates/admin/index.html
@@ -43,10 +43,24 @@
Teilnehmer
und Trainer exportieren
+
+
+
+