feat: mentor feedback

This commit is contained in:
Livio Bieri 2024-01-30 22:58:53 +01:00
parent abb371d4a0
commit f228f9ee47
12 changed files with 271 additions and 67 deletions

View File

@ -0,0 +1,72 @@
<script setup lang="ts">
import type { Criterion } from "@/services/selfEvaluationFeedback";
import type { User } from "@/types";
import { computed } from "vue";
const props = defineProps<{
criteria: Criterion;
requester: User;
}>();
const emit = defineEmits(["evaluation"]);
const fullname = computed(
() => `${props.requester.first_name} ${props.requester.last_name}`
);
const description = computed(() => `«${props.criteria.title}»`);
const currentEvaluation = computed(() => props.criteria.feedback_assessment);
const onPassed = () => {
emit("evaluation", props.criteria, "SUCCESS");
};
const onFailed = () => {
emit("evaluation", props.criteria, "FAIL");
};
</script>
<template>
<div class="mt-16 space-y-4 bg-gray-200 p-7">
<span>{{ $t("a.Beurteilungskriterium") }}:</span>
<div class="text-bold text-xl">{{ description }}</div>
</div>
<div class="mt-16 flex flex-row items-center pb-4">
<span class="text-2xl font-bold">
{{ $t("a.Kann FULLNAME das?", { FULLNAME: fullname }) }}
</span>
<img class="ml-4 h-12 w-12 rounded" :src="requester.avatar_url" :alt="fullname" />
</div>
<div class="flex space-x-10">
<button
class="inline-flex flex-1 items-center border-2 p-4 text-left"
:class="currentEvaluation === 'SUCCESS' ? 'border-green-500' : 'border-gray-300'"
@click="onPassed"
>
<it-icon-smiley-happy class="mr-4 h-16 w-16"></it-icon-smiley-happy>
<span class="text-lg font-bold">
{{
$t("a.Ja, NAME kann das.", {
NAME: requester.first_name,
})
}}
</span>
</button>
<button
class="inline-flex flex-1 items-center border-2 p-4 text-left"
:class="currentEvaluation === 'FAIL' ? 'border-orange-500' : 'border-gray-300'"
@click="onFailed"
>
<it-icon-smiley-thinking class="mr-4 h-16 w-16"></it-icon-smiley-thinking>
<span class="text-lg font-bold">
{{
$t("a.Nein, NAME muss das nochmals anschauen.", {
NAME: requester.first_name,
})
}}
</span>
</button>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,20 @@
<script setup lang="ts">
import type { FeedbackRequest } from "@/services/selfEvaluationFeedback";
defineProps<{
feedback: FeedbackRequest;
}>();
const emit = defineEmits(["release"]);
</script>
<template>
<button
v-if="!feedback.feedback_submitted"
class="inline-flex flex-1 items-center border-2 p-4 text-left"
@click="emit('release')"
/>
<span v-else>Freigeben</span>
</template>
<style scoped></style>

View File

@ -31,10 +31,20 @@ defineProps<{
<it-icon-smiley-happy class="h-6 w-6" /> <it-icon-smiley-happy class="h-6 w-6" />
<span>{{ $t("selfEvaluation.yes") }}</span> <span>{{ $t("selfEvaluation.yes") }}</span>
</div> </div>
<div v-else class="flex flex-row items-center space-x-2"> <div
v-else-if="completion.completion_status == 'FAIL'"
class="flex flex-row items-center space-x-2"
>
<it-icon-smiley-thinking class="h-6 w-6" /> <it-icon-smiley-thinking class="h-6 w-6" />
<span>{{ $t("selfEvaluation.no") }}</span> <span>{{ $t("selfEvaluation.no") }}</span>
</div> </div>
<div
v-else-if="completion.completion_status == 'UNKNOWN'"
class="flex flex-row items-center space-x-2"
>
<it-icon-smiley-neutral class="h-6 w-6" />
<span>{{ $t("a.Nicht bewertet") }}</span>
</div>
</div> </div>
</template> </template>

View File

@ -2,9 +2,14 @@
import LearningContentMultiLayout from "@/pages/learningPath/learningContentPage/layouts/LearningContentMultiLayout.vue"; import LearningContentMultiLayout from "@/pages/learningPath/learningContentPage/layouts/LearningContentMultiLayout.vue";
import LearningContentContainer from "@/pages/learningPath/learningContentPage/LearningContentContainer.vue"; import LearningContentContainer from "@/pages/learningPath/learningContentPage/LearningContentContainer.vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { computed, onMounted, ref, toValue } from "vue"; import { computed, ref } from "vue";
import { useCSRFFetch } from "@/fetchHelpers"; import {
import type { FeedbackRequest } from "@/services/selfEvaluationFeedback"; type Criterion,
useSelfEvaluationFeedback,
} from "@/services/selfEvaluationFeedback";
import { useRouteQuery } from "@vueuse/router";
import FeedbackProviderRankCriteria from "@/components/selfEvaluationFeedback/FeedbackProviderRankCriteria.vue";
import FeedbackProviderReleaseOverview from "@/components/selfEvaluationFeedback/FeedbackProviderReleaseOverview.vue";
const router = useRouter(); const router = useRouter();
const props = defineProps<{ const props = defineProps<{
@ -12,47 +17,62 @@ const props = defineProps<{
courseSlug: string; courseSlug: string;
}>(); }>();
const url = computed( const currentStepRouteParam = useRouteQuery("step", "0", {
() => transform: Number,
`/api/self-evaluation-feedback/provider/${toValue(props.learningUnitId)}/feedback` mode: "push",
);
const feedback = ref<FeedbackRequest | null>();
const isFeedbackLoading = ref(false);
onMounted(async () => {
feedback.value = null;
isFeedbackLoading.value = true;
const { data, error } = await useCSRFFetch(url.value).json();
isFeedbackLoading.value = false;
if (error.value) {
console.error(error.value);
return;
} else {
feedback.value = data.value;
}
}); });
const stepsCount = computed(() => 10); const selfEvaluationFeedback = useSelfEvaluationFeedback(
const currentStep = 1; props.learningUnitId,
"provider"
);
const title = "TITLE " + props.learningUnitId; const feedback = computed(() => selfEvaluationFeedback?.feedback.value);
const showNextButton = true; const title = computed(() => {
const showExitButton = true; if (feedback.value) {
const showPreviousButton = true; return feedback.value.title;
const base_url = "BASE_URL"; }
return "";
});
const endBadgeText = "END_BADGE_TEXT"; const currentStep = ref(currentStepRouteParam);
const stepsCount = computed(() => {
if (feedback.value) {
return feedback.value.criteria.length + 1;
}
return 0;
});
const currentCriteria = computed(() => {
if (feedback.value && currentStep.value < stepsCount.value - 1) {
return feedback.value.criteria[currentStep.value];
}
return null;
});
const showNextButton = computed(() => {
if (feedback.value) {
return currentStep.value < stepsCount.value - 1;
}
return false;
});
const handleBack = () => { const handleBack = () => {
console.log("handleBack"); if (currentStep.value > 0) {
currentStep.value--;
}
}; };
const handleContinue = () => { const handleContinue = () => {
console.log("handleContinue"); if (currentStep.value < stepsCount.value) {
currentStep.value++;
}
}; };
const clickExit = () => { const clickExit = () => {
console.log("clickExit");
router.push({ router.push({
name: "mentorCockpitSelfEvaluationFeedbackAssignments", name: "mentorCockpitSelfEvaluationFeedbackAssignments",
params: { params: {
@ -60,35 +80,60 @@ const clickExit = () => {
}, },
}); });
}; };
const handleFeedbackEvaluation = async (
criteria: Criterion,
evaluation: "SUCCESS" | "FAIL"
) => {
if (!feedback.value) {
return;
}
await selfEvaluationFeedback.addFeedbackAssessment(
criteria.course_completion_id,
evaluation
);
};
const handleFeedbackRelease = async () => {
if (!feedback.value) {
return;
}
await selfEvaluationFeedback.releaseFeedback();
};
</script> </script>
<template> <template>
<LearningContentContainer v-if="feedback" @exit="clickExit"> <LearningContentContainer v-if="feedback" @exit="clickExit">
<LearningContentMultiLayout <LearningContentMultiLayout
icon="it-icon-lc-learning-module"
close-button-variant="close"
:current-step="currentStep" :current-step="currentStep"
:sub-title="$t('a.Selbsteinschätzung')" :sub-title="$t('a.Selbsteinschätzung')"
:title="title" :title="title"
icon="it-icon-lc-learning-module"
:steps-count="stepsCount" :steps-count="stepsCount"
:show-next-button="showNextButton" :show-next-button="showNextButton"
:show-exit-button="showExitButton" :show-exit-button="!showNextButton"
:show-start-button="false" :show-start-button="false"
:show-previous-button="showPreviousButton" :show-previous-button="currentStep > 0"
:base-url="base_url" :end-badge-text="$t('general.submission')"
:end-badge-text="endBadgeText" @exit="clickExit()"
@exit="clickExit"
@previous="handleBack()" @previous="handleBack()"
@next="handleContinue()" @next="handleContinue()"
> >
<div v-if="currentStep" class="h-full"> <div v-if="feedback" class="h-full">
<div class="mt-8"> <!-- Performance Criteria Evaluation -->
<h3 class="heading-3">{{ "Some title " }}}</h3> <FeedbackProviderRankCriteria
<div v-if="currentCriteria"
class="mt-4 flex flex-col justify-between gap-8 lg:mt-8 lg:flex-row lg:gap-12" :requester="feedback.feedback_requester_user"
> :criteria="currentCriteria"
<div>TEST {{ feedback }}</div> @evaluation="handleFeedbackEvaluation"
</div> />
</div> <!-- Submission -->
<FeedbackProviderReleaseOverview
v-else
:feedback="feedback"
@release="handleFeedbackRelease"
/>
</div> </div>
</LearningContentMultiLayout> </LearningContentMultiLayout>
</LearningContentContainer> </LearningContentContainer>

View File

@ -51,6 +51,13 @@ const showExitButton = computed(
function handleContinue() { function handleContinue() {
log.debug("handleContinue"); log.debug("handleContinue");
// 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) { if (questionIndex.value + 1 < numPages.value) {
log.debug("increment questionIndex", questionIndex.value); log.debug("increment questionIndex", questionIndex.value);
questionIndex.value += 1; questionIndex.value += 1;

View File

@ -15,7 +15,10 @@ const props = defineProps<{
criteria: LearningUnitPerformanceCriteria[]; criteria: LearningUnitPerformanceCriteria[];
}>(); }>();
const selfEvaluationFeedback = useSelfEvaluationFeedback(props.learningUnit.id); const selfEvaluationFeedback = useSelfEvaluationFeedback(
props.learningUnit.id,
"requester"
);
const storedFeedback = computed(() => selfEvaluationFeedback.feedback.value); const storedFeedback = computed(() => selfEvaluationFeedback.feedback.value);
const isStoredFeedbackLoading = computed(() => selfEvaluationFeedback.loading.value); const isStoredFeedbackLoading = computed(() => selfEvaluationFeedback.loading.value);

View File

@ -1,11 +1,13 @@
import { useCSRFFetch } from "@/fetchHelpers"; import { useCSRFFetch } from "@/fetchHelpers";
import type { User } from "@/types"; import type { User } from "@/types";
import type { Ref } from "vue"; import type { Ref } from "vue";
import { computed, onMounted, ref, toValue } from "vue"; import { computed, onMounted, ref } from "vue";
export interface FeedbackRequest { export interface FeedbackRequest {
feedback_id: string;
learning_unit_id: number; learning_unit_id: number;
circle_name: string; circle_name: string;
title: string;
// submitted => provider submitted (released) his/her feedback // submitted => provider submitted (released) his/her feedback
feedback_submitted: boolean; feedback_submitted: boolean;
feedback_requester_user: User; feedback_requester_user: User;
@ -13,26 +15,31 @@ export interface FeedbackRequest {
criteria: Criterion[]; criteria: Criterion[];
} }
interface Criterion { export interface Criterion {
course_completion_id: string; course_completion_id: string;
title: string; title: string;
self_assessment: "FAIL" | "SUCCESS" | "UNKNOWN"; self_assessment: "FAIL" | "SUCCESS" | "UNKNOWN";
feedback_assessment: "FAIL" | "SUCCESS" | "UNKNOWN"; feedback_assessment: "FAIL" | "SUCCESS" | "UNKNOWN";
} }
export function useSelfEvaluationFeedback(learningUnitId: Ref<string> | 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
*/
export function useSelfEvaluationFeedback(
learningUnitId: Ref<string> | string,
feedbackRole: "requester" | "provider"
) {
const feedback = ref<FeedbackRequest>(); const feedback = ref<FeedbackRequest>();
const loading = ref(false); const loading = ref(false);
const error = ref(); const error = ref();
const url = computed( const url = computed(
() => `/api/self-evaluation-feedback/requester/${toValue(learningUnitId)}/feedback` () => `/api/self-evaluation-feedback/${feedbackRole}/${learningUnitId}/feedback`
); );
const fetchSelfEvaluationFeedback = async () => { const fetch = async () => {
feedback.value = undefined;
error.value = undefined; error.value = undefined;
loading.value = true; loading.value = true;
const { data, statusCode, error: _error } = await useCSRFFetch(url.value).json(); const { data, statusCode, error: _error } = await useCSRFFetch(url.value).json();
loading.value = false; loading.value = false;
@ -50,11 +57,50 @@ export function useSelfEvaluationFeedback(learningUnitId: Ref<string> | string)
} }
}; };
onMounted(fetchSelfEvaluationFeedback); 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,
});
// KISS: the backend is our "store" so just
// re-fetch e whole feedback from the backend
await fetch();
};
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({});
// KISS: the backend is our "store" so just
// re-fetch e whole feedback from the backend
await fetch();
};
onMounted(fetch);
return { return {
feedback, feedback,
error, error,
loading, loading,
// feedback provider actions
addFeedbackAssessment,
releaseFeedback,
}; };
} }

View File

@ -37,6 +37,7 @@ def create_blank_completions_non_requesters(
status=MentorCompletionStatus.UNKNOWN, status=MentorCompletionStatus.UNKNOWN,
user_id=non_requester_user.id, user_id=non_requester_user.id,
last_name=non_requester_user.last_name, last_name=non_requester_user.last_name,
url="",
) )
) )

View File

@ -18,9 +18,7 @@ class SelfEvaluationFeedbackSerializer(serializers.ModelSerializer):
learning_unit_id = serializers.PrimaryKeyRelatedField( learning_unit_id = serializers.PrimaryKeyRelatedField(
read_only=True, source="learning_unit" read_only=True, source="learning_unit"
) )
feedback_id = serializers.PrimaryKeyRelatedField( feedback_id = serializers.PrimaryKeyRelatedField(read_only=True, source="id")
read_only=True, source="course_completion_feedback"
)
circle_name = serializers.SerializerMethodField() circle_name = serializers.SerializerMethodField()
title = serializers.CharField(source="learning_unit.title") title = serializers.CharField(source="learning_unit.title")

View File

@ -370,7 +370,7 @@ class SelfEvaluationFeedbackAPI(APITestCase):
feedback.feedback_assessment, CourseCompletionStatus.FAIL.value feedback.feedback_assessment, CourseCompletionStatus.FAIL.value
) )
def test_submit_self_evaluation_feedback(self): def test_release_self_evaluation_feedback(self):
# GIVEN # GIVEN
learning_unit = create_learning_unit(course=self.course, circle=self.circle) learning_unit = create_learning_unit(course=self.course, circle=self.circle)
self_evaluation_feedback = create_self_evaluation_feedback( self_evaluation_feedback = create_self_evaluation_feedback(
@ -385,7 +385,7 @@ class SelfEvaluationFeedbackAPI(APITestCase):
# WHEN # WHEN
response = self.client.put( response = self.client.put(
reverse( reverse(
"submit_self_evaluation_feedback", args=[self_evaluation_feedback.id] "release_self_evaluation_feedback", args=[self_evaluation_feedback.id]
), ),
) )

View File

@ -4,8 +4,8 @@ from vbv_lernwelt.self_evaluation_feedback.views import (
add_provider_self_evaluation_feedback, add_provider_self_evaluation_feedback,
get_self_evaluation_feedback_as_provider, get_self_evaluation_feedback_as_provider,
get_self_evaluation_feedback_as_requester, get_self_evaluation_feedback_as_requester,
release_provider_self_evaluation_feedback,
start_self_evaluation_feedback, start_self_evaluation_feedback,
submit_provider_self_evaluation_feedback,
) )
urlpatterns = [ urlpatterns = [
@ -25,9 +25,9 @@ urlpatterns = [
name="get_self_evaluation_feedback_as_provider", name="get_self_evaluation_feedback_as_provider",
), ),
path( path(
"provider/feedback/<int:feedback_id>/submit", "provider/feedback/<int:feedback_id>/release",
submit_provider_self_evaluation_feedback, release_provider_self_evaluation_feedback,
name="submit_self_evaluation_feedback", name="release_self_evaluation_feedback",
), ),
path( path(
"provider/feedback/<int:feedback_id>/add-assessment", "provider/feedback/<int:feedback_id>/add-assessment",

View File

@ -48,7 +48,7 @@ def start_self_evaluation_feedback(request, learning_unit_id):
@api_view(["PUT"]) @api_view(["PUT"])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def submit_provider_self_evaluation_feedback(request, feedback_id): def release_provider_self_evaluation_feedback(request, feedback_id):
feedback = get_object_or_404( feedback = get_object_or_404(
SelfEvaluationFeedback, id=feedback_id, feedback_provider_user=request.user SelfEvaluationFeedback, id=feedback_id, feedback_provider_user=request.user
) )
@ -56,6 +56,8 @@ def submit_provider_self_evaluation_feedback(request, feedback_id):
feedback.feedback_submitted = True feedback.feedback_submitted = True
feedback.save() feedback.save()
# TODO: Create notification for feedback_requester_user
return Response({"success": True}) return Response({"success": True})