Merged in feature/VBV-321-KN-frontend-trainer (pull request #72)

Feature/VBV-321 KN frontend trainer

Approved-by: Elia Bieri
This commit is contained in:
Daniel Egger 2023-05-09 12:36:47 +00:00
commit ef37ac0db9
63 changed files with 2937 additions and 707 deletions

View File

@ -111,12 +111,10 @@ pipelines:
script:
- echo "Release ready!"
- step:
<<: *deploy
name: deploy prod
deployment: prod
trigger: manual
script:
- source ./env/bitbucket/prepare_for_deployment.sh
- ./caprover_deploy.sh myvbv
custom:
deploy-stage:
- step:

View File

@ -14,6 +14,7 @@
"@sentry/vue": "^7.20.0",
"@urql/vue": "^1.0.2",
"@vueuse/core": "^9.13.0",
"@vueuse/router": "^10.1.2",
"cypress": "^12.9.0",
"d3": "^7.6.1",
"dayjs": "^1.11.7",
@ -25,7 +26,7 @@
"vue": "^3.2.38",
"vue-i18n": "^9.2.2",
"vue-i18n-extract": "^2.0.7",
"vue-router": "^4.1.5"
"vue-router": "^4.1.6"
},
"devDependencies": {
"@graphql-codegen/cli": "^2.13.12",
@ -7489,6 +7490,32 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/router": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/@vueuse/router/-/router-10.1.2.tgz",
"integrity": "sha512-99KhTBZliU5gRPHPhi7UO97vArgWIYLomLeCPYJQvbg1gYYa3BVX/uFDcdOaVYhdz5rWDeY/DaeW5CeNYR7zzQ==",
"dependencies": {
"@vueuse/shared": "10.1.2",
"vue-demi": ">=0.14.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue-router": ">=4.0.0-rc.1"
}
},
"node_modules/@vueuse/router/node_modules/@vueuse/shared": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.1.2.tgz",
"integrity": "sha512-1uoUTPBlgyscK9v6ScGeVYDDzlPSFXBlxuK7SfrDGyUTBiznb3mNceqhwvZHjtDRELZEN79V5uWPTF1VDV8svA==",
"dependencies": {
"vue-demi": ">=0.14.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "9.13.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-9.13.0.tgz",
@ -18979,9 +19006,9 @@
}
},
"node_modules/vue-demi": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz",
"integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==",
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.0.tgz",
"integrity": "sha512-gt58r2ogsNQeVoQ3EhoUAvUsH9xviydl0dWJj7dabBC/2L4uBId7ujtCwDRD0JhkGsV1i0CtfLAeyYKBht9oWg==",
"hasInstallScript": true,
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
@ -25294,6 +25321,25 @@
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-9.13.0.tgz",
"integrity": "sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ=="
},
"@vueuse/router": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/@vueuse/router/-/router-10.1.2.tgz",
"integrity": "sha512-99KhTBZliU5gRPHPhi7UO97vArgWIYLomLeCPYJQvbg1gYYa3BVX/uFDcdOaVYhdz5rWDeY/DaeW5CeNYR7zzQ==",
"requires": {
"@vueuse/shared": "10.1.2",
"vue-demi": ">=0.14.0"
},
"dependencies": {
"@vueuse/shared": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.1.2.tgz",
"integrity": "sha512-1uoUTPBlgyscK9v6ScGeVYDDzlPSFXBlxuK7SfrDGyUTBiznb3mNceqhwvZHjtDRELZEN79V5uWPTF1VDV8svA==",
"requires": {
"vue-demi": ">=0.14.0"
}
}
}
},
"@vueuse/shared": {
"version": "9.13.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-9.13.0.tgz",
@ -33879,9 +33925,9 @@
}
},
"vue-demi": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz",
"integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A=="
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.0.tgz",
"integrity": "sha512-gt58r2ogsNQeVoQ3EhoUAvUsH9xviydl0dWJj7dabBC/2L4uBId7ujtCwDRD0JhkGsV1i0CtfLAeyYKBht9oWg=="
},
"vue-docgen-api": {
"version": "4.64.1",

View File

@ -25,6 +25,7 @@
"@sentry/vue": "^7.20.0",
"@urql/vue": "^1.0.2",
"@vueuse/core": "^9.13.0",
"@vueuse/router": "^10.1.2",
"cypress": "^12.9.0",
"d3": "^7.6.1",
"dayjs": "^1.11.7",
@ -36,7 +37,7 @@
"vue": "^3.2.38",
"vue-i18n": "^9.2.2",
"vue-i18n-extract": "^2.0.7",
"vue-router": "^4.1.5"
"vue-router": "^4.1.6"
},
"devDependencies": {
"@graphql-codegen/cli": "^2.13.12",

View File

@ -1,8 +1,9 @@
<template>
<div class="mb-4 bg-white px-6 py-5">
<h2 class="heading-3 mb-4 bg-feedback bg-60 bg-no-repeat pl-[68px] leading-[60px]">
{{ $t("general.feedback", 2) }}
</h2>
<h3 class="heading-3 mb-4 flex items-center gap-2">
<it-icon-feedback-large class="h-16 w-16"></it-icon-feedback-large>
<div>{{ $t("general.feedback", 2) }}</div>
</h3>
<ol v-if="feedbackSummary.length > 0">
<ItRow v-for="feedbacks in feedbackSummary" :key="feedbacks.circle_id">
<template #firstRow>

View File

@ -55,12 +55,12 @@ const input = (e: Event) => {
@input="input"
/>
<div class="ml-4 flex-col">
<div v-if="checkboxItem.label">
{{ checkboxItem.label }}
</div>
<div v-if="checkboxItem.subtitle" class="text-gray-900">
{{ checkboxItem.subtitle }}
</div>
<div v-if="checkboxItem.label" v-html="checkboxItem.label"></div>
<div
v-if="checkboxItem.subtitle"
class="text-gray-900"
v-html="checkboxItem.subtitle"
></div>
</div>
</label>
</div>

View File

@ -8,11 +8,15 @@ export interface Props {
currentStep: number;
startBadgeText?: string;
endBadgeText?: string;
baseUrl?: string;
queryParam?: string;
}
const props = withDefaults(defineProps<Props>(), {
startBadgeText: undefined,
endBadgeText: undefined,
baseUrl: undefined,
queryParam: "page",
});
const hasStartBadge = computed(() => typeof props.startBadgeText !== "undefined");
@ -46,6 +50,13 @@ const endBadgeClasses = computed(() => {
}
return "border";
});
function calcStepIndex(step: number) {
if (props.startBadgeText) {
return step + 1;
}
return step;
}
</script>
<template>
@ -54,8 +65,12 @@ const endBadgeClasses = computed(() => {
v-if="props.startBadgeText"
class="inline-flex h-7 items-center justify-center whitespace-nowrap rounded-3xl px-3 text-sm"
:class="startBadgeClasses"
data-cy="nav-progress-step-start"
>
{{ props.startBadgeText }}
<router-link v-if="props.baseUrl" :to="`${props.baseUrl}?${props.queryParam}=0`">
{{ props.startBadgeText }}
</router-link>
<span v-else>{{ props.startBadgeText }}</span>
</div>
<div v-for="(_, step) in numNumberSteps" :key="step" class="flex flex-row">
<hr
@ -65,8 +80,15 @@ const endBadgeClasses = computed(() => {
<div
class="inline-flex h-7 w-7 items-center justify-center rounded-full px-3 py-1 text-sm"
:class="getPillClasses(step)"
:data-cy="`nav-progress-step-${calcStepIndex(step)}`"
>
{{ step + 1 }}
<router-link
v-if="props.baseUrl"
:to="`${props.baseUrl}?${props.queryParam}=${calcStepIndex(step)}`"
>
{{ step + 1 }}
</router-link>
<span v-else>{{ step + 1 }}</span>
</div>
</div>
<hr v-if="hasEndBadge" class="w-8 self-center border border-gray-400" />
@ -74,8 +96,15 @@ const endBadgeClasses = computed(() => {
v-if="endBadgeText"
class="inline-flex h-7 items-center justify-center whitespace-nowrap rounded-3xl px-3 text-sm"
:class="endBadgeClasses"
data-cy="nav-progress-end"
>
{{ props.endBadgeText }}
<router-link
v-if="props.baseUrl"
:to="`${props.baseUrl}?${props.queryParam}=${steps - 1}`"
>
{{ props.endBadgeText }}
</router-link>
<span v-else>{{ props.endBadgeText }}</span>
</div>
</div>
</template>

View File

@ -1,12 +1,11 @@
<script setup lang="ts">
import { computed } from "vue";
export type StatusCountKey = "fail" | "success" | "unknown";
export type StatusCount = Record<StatusCountKey, number>;
const props = defineProps<{
statusCount?: {
fail: number;
success: number;
unknown: number;
};
statusCount?: StatusCount;
}>();
const total = computed(() => {

View File

@ -1,27 +1,30 @@
<template>
<div>
<h2 v-if="label" class="heading-1 mb-8 block">{{ label }}</h2>
<div v-if="label" class="mb-2 block">{{ label }}</div>
<textarea
:value="modelValue"
class="h-40 w-full border-gray-500"
:data-cy="`it-textarea-${cyKey}`"
:disabled="disabled"
:placeholder="placeholder"
@input="onInput"
/>
</div>
</template>
<script setup lang="ts">
interface Props {
export interface Props {
modelValue: string;
label: string | undefined;
label?: string;
placeholder?: string;
cyKey?: string;
disabled?: boolean;
}
withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
label: undefined,
cyKey: "",
placeholder: "",
});
const emit = defineEmits(["update:modelValue"]);

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import { useCockpitStore } from "@/stores/cockpit";
import { useCompetenceStore } from "@/stores/competence";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useLearningPathStore } from "@/stores/learningPath";
import { useUserStore } from "@/stores/user";
import * as log from "loglevel";
@ -15,21 +16,25 @@ const props = defineProps<{
const cockpitStore = useCockpitStore();
const competenceStore = useCompetenceStore();
const learningPathStore = useLearningPathStore();
const courseSessionsStore = useCourseSessionsStore();
onMounted(async () => {
log.debug("CockpitParentPage mounted", props.courseSlug);
try {
await cockpitStore.loadCourseSessionUsers(props.courseSlug);
cockpitStore.courseSessionUsers?.forEach((csu) => {
competenceStore.loadCompetenceProfilePage(
props.courseSlug + "-competence",
csu.user_id
);
const currentCourseSession = courseSessionsStore.currentCourseSession;
if (currentCourseSession?.id) {
await cockpitStore.loadCourseSessionUsers(currentCourseSession.id);
cockpitStore.courseSessionUsers?.forEach((csu) => {
competenceStore.loadCompetenceProfilePage(
props.courseSlug + "-competence",
csu.user_id
);
learningPathStore.loadLearningPath(props.courseSlug + "-lp", csu.user_id);
});
learningPathStore.loadLearningPath(props.courseSlug + "-lp", useUserStore().id);
learningPathStore.loadLearningPath(props.courseSlug + "-lp", csu.user_id);
});
learningPathStore.loadLearningPath(props.courseSlug + "-lp", useUserStore().id);
}
} catch (error) {
log.error(error);
}

View File

@ -79,7 +79,7 @@ const props = defineProps<{
log.debug("FeedbackPage created", props.circleId);
const courseSessionStore = useCourseSessionsStore();
const courseSessionsStore = useCourseSessionsStore();
const { t } = useI18n();
const orderedQuestions = [
@ -149,7 +149,7 @@ const feedbackData = reactive<FeedbackData>({ amount: 0, questions: {} });
onMounted(async () => {
log.debug("FeedbackPage mounted");
const data = await itGet(
`/api/core/feedback/${courseSessionStore.currentCourseSession?.course.id}/${props.circleId}`
`/api/core/feedback/${courseSessionsStore.currentCourseSession?.course.id}/${props.circleId}`
);
Object.assign(feedbackData, data);
});

View File

@ -0,0 +1,133 @@
<script setup lang="ts">
import EvaluationContainer from "@/pages/cockpit/assignmentEvaluationPage/EvaluationContainer.vue";
import AssignmentSubmissionResponses from "@/pages/learningPath/learningContentPage/assignment/AssignmentSubmissionResponses.vue";
import { useAssignmentStore } from "@/stores/assignmentStore";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import type {
Assignment,
CourseSessionAssignmentDetails,
CourseSessionUser,
} from "@/types";
import log from "loglevel";
import { computed, onMounted, reactive } from "vue";
import { useRouter } from "vue-router";
const props = defineProps<{
courseSlug: string;
assignmentId: string;
userId: string;
}>();
log.debug("AssignmentEvaluationPage created", props.assignmentId, props.userId);
interface StateInterface {
assignment: Assignment | undefined;
courseSessionAssignmentDetails: CourseSessionAssignmentDetails | undefined;
assignmentUser: CourseSessionUser | undefined;
}
const state: StateInterface = reactive({
assignment: undefined,
courseSessionAssignmentDetails: undefined,
assignmentUser: undefined,
});
const assignmentStore = useAssignmentStore();
const courseSessionsStore = useCourseSessionsStore();
const router = useRouter();
onMounted(async () => {
log.debug("AssignmentView mounted", props.assignmentId, props.userId);
if (courseSessionsStore.currentCourseSession) {
state.assignmentUser = courseSessionsStore.currentCourseSession.users.find(
(user) => user.user_id === Number(props.userId)
);
}
try {
state.assignment = await assignmentStore.loadAssignment(Number(props.assignmentId));
await assignmentStore.loadAssignmentCompletion(
Number(props.assignmentId),
courseSessionsStore.currentCourseSession!.id,
props.userId
);
} catch (error) {
log.error(error);
}
});
function close() {
router.push({
path: `/course/${props.courseSlug}/cockpit/assignment`,
});
}
const assignmentCompletion = computed(() => assignmentStore.assignmentCompletion);
</script>
<template>
<div class="absolute bottom-0 top-0 z-10 w-full bg-white">
<div
v-if="state.assignment && state.assignmentUser && assignmentCompletion"
class="relative"
>
<header
class="relative flex h-12 w-full items-center justify-between border-b border-b-gray-400 bg-white px-4 lg:h-16 lg:px-8"
>
<div class="flex items-center text-gray-900">
<it-icon-assignment class="h-6 w-6"></it-icon-assignment>
<div class="ml-2">Geleitete Fallarbeit: {{ state.assignment.title }}</div>
</div>
<button
type="button"
class="absolute right-2 top-2 h-8 w-8 cursor-pointer lg:right-4 lg:top-4"
data-cy="close-learning-content"
@click="close()"
>
<it-icon-close></it-icon-close>
</button>
</header>
<div class="h-content flex">
<div class="h-full w-1/2 overflow-y-auto bg-white">
<!-- Left part content goes here -->
<div class="p-10">
<h3>Ergebnisse</h3>
<div class="my-6 flex items-center">
<img
:src="state.assignmentUser?.avatar_url"
class="mr-4 h-11 w-11 rounded-full"
/>
<div class="font-bold">
{{ state.assignmentUser?.first_name }}
{{ state.assignmentUser?.last_name }}
</div>
</div>
<AssignmentSubmissionResponses
:assignment="state.assignment"
:assignment-completion-data="assignmentCompletion.completion_data"
:allow-edit="false"
></AssignmentSubmissionResponses>
</div>
</div>
<div class="w-1/2 overflow-y-auto bg-gray-200">
<EvaluationContainer
:assignment-completion="assignmentCompletion"
:assignment-user="state.assignmentUser"
:assignment="state.assignment"
@close="close()"
></EvaluationContainer>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
$nav-height: 64px;
.h-content {
height: calc(100vh - $nav-height);
}
</style>

View File

@ -0,0 +1,177 @@
<script setup lang="ts">
import EvaluationIntro from "@/pages/cockpit/assignmentEvaluationPage/EvaluationIntro.vue";
import EvaluationSummary from "@/pages/cockpit/assignmentEvaluationPage/EvaluationSummary.vue";
import EvaluationTask from "@/pages/cockpit/assignmentEvaluationPage/EvaluationTask.vue";
import { useAssignmentStore } from "@/stores/assignmentStore";
import type {
Assignment,
AssignmentCompletion,
AssignmentEvaluationTask,
CourseSessionUser,
} from "@/types";
import dayjs from "dayjs";
import { findIndex } from "lodash";
import * as log from "loglevel";
import { computed, onMounted, reactive } from "vue";
const props = defineProps<{
assignmentUser: CourseSessionUser;
assignmentCompletion: AssignmentCompletion;
assignment: Assignment;
}>();
const emit = defineEmits(["close"]);
log.debug("UserEvaluation setup");
interface StateInterface {
// 0 = introduction, 1 - n = tasks, n+1 = submission
pageIndex: number;
}
const state: StateInterface = reactive({
pageIndex: 0,
});
const assignmentStore = useAssignmentStore();
const numTasks = computed(() => props.assignment.evaluation_tasks?.length ?? 0);
const evaluationSubmitted = computed(
() => props.assignmentCompletion.completion_status === "evaluation_submitted"
);
onMounted(() => {
if (evaluationSubmitted.value) {
state.pageIndex = props.assignment.evaluation_tasks?.length + 1 ?? 0;
} else {
state.pageIndex = 0;
}
});
function previousPage() {
log.debug("previousTask");
state.pageIndex = Math.max(0, state.pageIndex - 1);
}
function nextPage() {
log.debug("nextTask");
state.pageIndex = Math.min(numTasks.value + 1, state.pageIndex + 1);
}
function editTask(task: AssignmentEvaluationTask) {
log.debug("editTask", task);
const taskIndex =
findIndex(props.assignment.evaluation_tasks, {
id: task.id,
}) ?? 0;
state.pageIndex = taskIndex + 1;
}
const assignmentDetail = computed(() =>
assignmentStore.findAssignmentDetail(props.assignment.id)
);
const dueDate = computed(() =>
dayjs(assignmentDetail.value?.evaluationDeadlineDateTimeUtc)
);
const inEvaluationTask = computed(
() => state.pageIndex >= 1 && state.pageIndex <= numTasks.value
);
const taskIndex = computed(() => state.pageIndex - 1);
const task = computed(() => props.assignment.evaluation_tasks[taskIndex.value]);
const taskExpertDataText = computed(() => {
let result = "";
if (inEvaluationTask.value) {
result =
assignmentStore.assignmentCompletion?.completion_data?.[task.value.id]
?.expert_data?.text ?? "";
}
return result;
});
function nextButtonEnabled() {
if (inEvaluationTask.value) {
return taskExpertDataText.value ?? false;
}
return true;
}
function finishButtonEnabled() {
return props.assignmentCompletion.completion_status === "evaluation_submitted";
}
</script>
<template>
<div class="flex min-h-full flex-col">
<div class="flex-1 overflow-y-auto">
<section class="p-10">
<EvaluationIntro
v-if="state.pageIndex === 0"
:assignment-user="props.assignmentUser"
:assignment="props.assignment"
:assignment-completion="props.assignmentCompletion"
:due-date="dueDate"
@start-evaluation="nextPage"
></EvaluationIntro>
<EvaluationTask
v-else-if="inEvaluationTask"
:assignment-user="props.assignmentUser"
:assignment="props.assignment"
:task-index="state.pageIndex - 1"
:allow-edit="
props.assignmentCompletion.completion_status !== 'evaluation_submitted'
"
/>
<EvaluationSummary
v-else
:assignment-user="props.assignmentUser"
:assignment="props.assignment"
:assignment-completion="props.assignmentCompletion"
:due-date="dueDate"
@edit-task="editTask"
></EvaluationSummary>
</section>
</div>
<nav v-if="state.pageIndex > 0" class="sticky bottom-0 border-t bg-gray-200 p-6">
<div class="relative flex flex-row place-content-end">
<button
v-if="true"
class="btn-secondary mr-2 flex items-center"
data-cy="previous-step"
@click="previousPage()"
>
<it-icon-arrow-left class="mr-2 h-6 w-6"></it-icon-arrow-left>
{{ $t("general.backCapitalized") }}
</button>
<button
v-if="state.pageIndex <= numTasks"
:disabled="!nextButtonEnabled()"
class="btn-secondary z-10 flex items-center"
data-cy="next-step"
@click="nextPage()"
>
{{ $t("general.next") }}
<it-icon-arrow-right class="ml-2 h-6 w-6"></it-icon-arrow-right>
</button>
<button
v-if="state.pageIndex > numTasks"
:disabled="!finishButtonEnabled()"
class="btn-secondary z-10"
data-cy="next-step"
@click="$emit('close')"
>
<span class="flex items-center">
Bewertung abschliessen
<it-icon-check class="ml-2 h-6 w-6"></it-icon-check>
</span>
</button>
</div>
</nav>
</div>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,87 @@
<script setup lang="ts">
import { useAssignmentStore } from "@/stores/assignmentStore";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import type { Assignment, AssignmentCompletion, CourseSessionUser } from "@/types";
import dayjs, { Dayjs } from "dayjs";
import * as log from "loglevel";
const props = defineProps<{
assignmentUser: CourseSessionUser;
assignment: Assignment;
assignmentCompletion: AssignmentCompletion;
dueDate?: Dayjs;
}>();
const emit = defineEmits(["startEvaluation"]);
log.debug("EvaluationIntro setup");
const courseSessionsStore = useCourseSessionsStore();
const assignmentStore = useAssignmentStore();
async function startEvaluation() {
log.debug("startEvaluation");
if (props.assignmentCompletion.completion_status !== "evaluation_submitted") {
await assignmentStore.evaluateAssignmentCompletion({
assignment_user_id: Number(props.assignmentUser.user_id),
assignment_id: props.assignment.id,
course_session_id: courseSessionsStore.currentCourseSession!.id,
completion_data: {},
completion_status: "evaluation_in_progress",
});
emit("startEvaluation");
} else {
emit("startEvaluation");
}
}
</script>
<template>
<div>
<div class="mb-4">
{{ props.assignmentUser.first_name }} {{ props.assignmentUser.last_name }} hat die
Ergebnisse am
{{ dayjs(props.assignmentCompletion.submitted_at).format("DD.MM.YYYY") }} um
{{ dayjs(props.assignmentCompletion.submitted_at).format("HH.mm") }} Uhr
abgegeben.
</div>
<h3>Bewertung</h3>
<p v-if="props.dueDate" class="my-4">
Du musst die Bewertung bis am {{ props.dueDate.format("DD.MM.YYYY") }} um
{{ props.dueDate.format("HH.mm") }} Uhr abschliessen und freigeben.
</p>
<p class="my-4">
Die Gesamtpunktzahl und die daraus resultierende Note wird auf Grund des
hinterlegeten Beurteilungsinstrument berechnet.
</p>
<p class="my-4">
<a :href="props.assignment.evaluation_document_url" class="link" target="_blank">
Beurteilungsinstrument anzeigen
</a>
</p>
<div>
<button class="btn-primary" @click="startEvaluation()">
<span
v-if="
props.assignmentCompletion.completion_status === 'evaluation_in_progress'
"
>
Bewertung fortsetzen
</span>
<span
v-if="props.assignmentCompletion.completion_status === 'evaluation_submitted'"
>
Bewertung ansehen
</span>
<span v-else>Bewertung starten</span>
</button>
</div>
</div>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,194 @@
<script setup lang="ts">
import ItSuccessAlert from "@/components/ui/ItSuccessAlert.vue";
import {
maxAssignmentPoints,
pointsToGrade,
userAssignmentPoints,
} from "@/services/assignmentService";
import { useAssignmentStore } from "@/stores/assignmentStore";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import type {
Assignment,
AssignmentCompletion,
AssignmentEvaluationTask,
CourseSessionUser,
} from "@/types";
import dayjs, { Dayjs } from "dayjs";
import * as log from "loglevel";
import { computed, reactive } from "vue";
const props = defineProps<{
assignmentUser: CourseSessionUser;
assignment: Assignment;
assignmentCompletion: AssignmentCompletion;
showEvaluationUser?: boolean;
dueDate?: Dayjs;
}>();
const emit = defineEmits(["editTask"]);
const state = reactive({
showSuccessInfo: false,
});
log.debug("EvaluationSummary setup");
const courseSessionsStore = useCourseSessionsStore();
const assignmentStore = useAssignmentStore();
async function submitEvaluation() {
log.debug("submitEvaluation");
await assignmentStore.evaluateAssignmentCompletion({
assignment_user_id: Number(props.assignmentUser.user_id),
assignment_id: props.assignment.id,
course_session_id: courseSessionsStore.currentCourseSession!.id,
completion_data: {},
completion_status: "evaluation_submitted",
evaluation_grade: grade.value ?? undefined,
evaluation_points: userPoints.value,
});
state.showSuccessInfo = true;
}
function subTaskByPoints(task: AssignmentEvaluationTask, points = 0) {
return task.value.sub_tasks.find((subTask) => subTask.points === points);
}
function evaluationForTask(task: AssignmentEvaluationTask) {
const expertData = props.assignmentCompletion.completion_data[task.id]?.expert_data;
if (!expertData) {
return {
points: 0,
text: "",
};
}
return expertData;
}
const maxPoints = computed(() => maxAssignmentPoints(props.assignment));
const userPoints = computed(() =>
userAssignmentPoints(props.assignment, props.assignmentCompletion)
);
const grade = computed(() => {
if (props.assignmentCompletion.completion_status === "evaluation_submitted") {
return props.assignmentCompletion.evaluation_grade;
}
return pointsToGrade(userPoints.value, maxPoints.value);
});
const evaluationUser = computed(() => {
if (props.assignmentCompletion.evaluation_user) {
return (courseSessionsStore.currentCourseSession?.users ?? []).find(
(user) => user.user_id === Number(props.assignmentCompletion.evaluation_user)
) as CourseSessionUser;
}
return undefined;
});
</script>
<template>
<div>
<h3 v-if="evaluationUser && props.showEvaluationUser" class="mb-6">
Bewertung von {{ evaluationUser.first_name }} {{ evaluationUser.last_name }}
</h3>
<h3 v-else class="mb-6">Bewertung Freigabe</h3>
<section class="mb-6 border p-6">
<div class="text-lg font-bold">Note: {{ grade }}</div>
<div class="text-gray-900">
Gesamtpunktezahl {{ userPoints }} / {{ maxPoints }}
</div>
<p class="my-4">
Die Gesamtpunktzahl und die daraus resultierende Note wird auf Grund des
hinterlegeten Beurteilungsinstrument berechnet.
</p>
<p class="my-4">
<a
:href="props.assignment.evaluation_document_url"
class="link"
target="_blank"
>
Beurteilungsinstrument anzeigen
</a>
</p>
<div
v-if="props.assignmentCompletion.completion_status === 'evaluation_submitted'"
>
Freigabetermin:
{{
dayjs(props.assignmentCompletion.evaluation_submitted_at).format("DD.MM.YYYY")
}}
um
{{ dayjs(props.assignmentCompletion.evaluation_submitted_at).format("HH.mm") }}
Uhr
</div>
<div v-else>
<button class="btn-primary" @click="submitEvaluation()">
Bewertung freigeben
</button>
</div>
<div v-if="state.showSuccessInfo" class="mt-4">
<ItSuccessAlert
:text="`Deine Bewertung für ${props.assignmentUser.first_name} ${props.assignmentUser.last_name} wurde freigegeben.`"
></ItSuccessAlert>
</div>
</section>
<section>
<div v-for="(task, index) in props.assignment.evaluation_tasks" :key="task.id">
<article class="border-t py-4">
<div class="flex flex-row justify-between">
<div class="mb-4">
Bewertungskriterium {{ index + 1 }}: {{ task.value.title }}
</div>
<div
v-if="
props.assignmentCompletion.completion_status !== 'evaluation_submitted'
"
>
<button
class="link pl-2text-sm whitespace-nowrap"
@click="emit('editTask', task)"
>
{{ $t("assignment.edit") }}
</button>
</div>
</div>
<div
class="default-wagtail-rich-text mb-2 font-bold"
v-html="task.value.description"
></div>
<section class="mb-4">
<div
v-html="subTaskByPoints(task, evaluationForTask(task).points)?.title"
></div>
<p
class="default-wagtail-rich-text"
v-html="
subTaskByPoints(task, evaluationForTask(task).points)?.description
"
></p>
<div class="text-sm text-gray-800">
{{ evaluationForTask(task).points }} Punkte
</div>
</section>
<div>
<span class="font-bold">Begründung:</span>
{{ evaluationForTask(task).text }}
</div>
</article>
</div>
</section>
</div>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,126 @@
<script setup lang="ts">
import ItTextarea from "@/components/ui/ItTextarea.vue";
import { useAssignmentStore } from "@/stores/assignmentStore";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import type {
Assignment,
AssignmentCompletionData,
CourseSessionUser,
ExpertData,
} from "@/types";
import { useDebounceFn } from "@vueuse/core";
import * as log from "loglevel";
import { computed } from "vue";
const props = defineProps<{
assignmentUser: CourseSessionUser;
assignment: Assignment;
taskIndex: number;
allowEdit: boolean;
}>();
log.debug("EvaluationTask setup", props.taskIndex);
const courseSessionsStore = useCourseSessionsStore();
const assignmentStore = useAssignmentStore();
const task = computed(() => props.assignment.evaluation_tasks[props.taskIndex]);
const expertData = computed(() => {
const data = (assignmentStore.assignmentCompletion?.completion_data?.[task.value.id]
?.expert_data ?? {
points: 0,
text: "",
}) as ExpertData;
return data;
});
function changePoints(points: number) {
log.debug("changePoints", points);
evaluateAssignmentCompletion({
[task.value.id]: {
expert_data: { points, text: expertData.value.text },
},
});
}
function onUpdateText(value: string) {
// log.debug("onUpdateText", value);
evaluateAssignmentCompletionDebounced({
[task.value.id]: {
expert_data: {
text: value,
points: expertData.value.points,
},
},
});
}
async function evaluateAssignmentCompletion(completionData: AssignmentCompletionData) {
log.debug("evaluateAssignmentCompletion", completionData);
return assignmentStore.evaluateAssignmentCompletion({
assignment_user_id: Number(props.assignmentUser.user_id),
assignment_id: props.assignment.id,
course_session_id: courseSessionsStore.currentCourseSession!.id,
completion_data: completionData,
completion_status: "evaluation_in_progress",
});
}
const evaluateAssignmentCompletionDebounced = useDebounceFn(
evaluateAssignmentCompletion,
300
);
</script>
<template>
<div>
<div class="text-bold mb-4 text-sm">
Beurteilungskriterium {{ taskIndex + 1 }} /
{{ props.assignment.evaluation_tasks.length }}
{{ task.value.title }}
</div>
<h3 class="default-wagtail-rich-text mb-8" v-html="task.value.description"></h3>
<fieldset>
<div>
<div
v-for="(subTask, index) in task.value.sub_tasks"
:key="index"
class="mb-4 flex items-center last:mb-0"
>
<input
:id="String(index)"
name="coursesessions"
type="radio"
:value="subTask.points"
:checked="expertData.points === subTask.points"
:disabled="!props.allowEdit"
class="focus:ring-indigo-900 h-4 w-4 border-gray-300 text-blue-900"
@change="changePoints(subTask.points)"
/>
<label :for="String(index)" class="ml-4 block">
<div>{{ subTask.title }}</div>
<div
v-if="subTask.description"
class="default-wagtail-rich-text"
v-html="subTask.description"
></div>
<div class="text-sm text-gray-800">{{ subTask.points }} Punkte</div>
</label>
</div>
</div>
</fieldset>
<ItTextarea
class="mt-8"
:model-value="expertData.text ?? ''"
label="Begründung"
:disabled="!props.allowEdit"
placeholder="Hier muss zwingend eine Begründung erfasst werden."
@update:model-value="onUpdateText($event)"
></ItTextarea>
</div>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,143 @@
<script setup lang="ts">
import ItPersonRow from "@/components/ui/ItPersonRow.vue";
import type { StatusCount, StatusCountKey } from "@/components/ui/ItProgress.vue";
import AssignmentSubmissionProgress from "@/pages/cockpit/assignmentsPage/AssignmentSubmissionProgress.vue";
import type { AssignmentLearningContent } from "@/services/assignmentService";
import { loadAssignmentCompletionStatusData } from "@/services/assignmentService";
import { useAssignmentStore } from "@/stores/assignmentStore";
import { useCockpitStore } from "@/stores/cockpit";
import type { AssignmentCompletionStatus, CourseSession } from "@/types";
import dayjs from "dayjs";
import log from "loglevel";
import { computed, onMounted, reactive } from "vue";
const props = defineProps<{
courseSession: CourseSession;
assignment: AssignmentLearningContent;
}>();
log.debug("AssignmentDetails created", props.assignment.assignmentId);
const cockpitStore = useCockpitStore();
const assignmentStore = useAssignmentStore();
const state = reactive({
statusByUser: [] as {
userStatus: AssignmentCompletionStatus;
progressStatus: StatusCountKey;
userId: number;
grade: number | null;
}[],
progressStatusCount: {} as StatusCount,
});
onMounted(async () => {
state.statusByUser = await loadAssignmentCompletionStatusData(
props.assignment.assignmentId,
props.courseSession.id
);
});
function submissionStatusForUser(userId: number) {
return state.statusByUser.find((s) => s.userId === userId);
}
const assignmentDetail = computed(() =>
assignmentStore.findAssignmentDetail(props.assignment.assignmentId)
);
</script>
<template>
<div v-if="state.statusByUser.length">
<div class="text-large font-bold">
{{ assignment.title }}
</div>
<div v-if="assignmentDetail">
<span>
Abgabetermin:
{{ dayjs(assignmentDetail.submissionDeadlineDateTimeUtc).format("DD.MM.YYYY") }}
</span>
-
<span>
Freigabetermin:
{{ dayjs(assignmentDetail.evaluationDeadlineDateTimeUtc).format("DD.MM.YYYY") }}
</span>
</div>
<div>
<router-link :to="props.assignment.frontend_url" class="link">
Im Circle anzeigen
</router-link>
</div>
<div class="mt-4">
<AssignmentSubmissionProgress
:course-session="courseSession"
:assignment="assignment"
:show-title="false"
/>
</div>
<div v-if="cockpitStore.courseSessionUsers?.length" class="mt-6">
<ul>
<ItPersonRow
v-for="csu in cockpitStore.courseSessionUsers"
:key="csu.user_id + csu.session_title"
:name="`${csu.first_name} ${csu.last_name}`"
:avatar-url="csu.avatar_url"
>
<template #center>
<section class="flex w-full justify-between px-8">
<div
v-if="
['evaluation_submitted'].includes(
submissionStatusForUser(csu.user_id)?.userStatus ?? ''
)
"
class="flex items-center"
>
<div
class="relative flex h-7 w-7 items-center justify-center rounded-full border border-green-500 bg-green-500"
>
<it-icon-check class="h-4/5 w-4/5"></it-icon-check>
</div>
<div class="ml-2">Bewertung freigegeben</div>
</div>
<div
v-else-if="
['evaluation_in_progress', 'submitted'].includes(
submissionStatusForUser(csu.user_id)?.userStatus ?? ''
)
"
class="flex items-center"
>
<div
class="relative flex h-7 w-7 items-center justify-center rounded-full border border-green-500"
>
<it-icon-check class="h-6 w-6"></it-icon-check>
</div>
<div class="ml-2">Ergebnisse abgegeben</div>
</div>
<div v-else></div>
<div v-if="submissionStatusForUser(csu.user_id)?.grade">
Note: {{ submissionStatusForUser(csu.user_id)?.grade }}
</div>
</section>
</template>
<template #link>
<router-link
v-if="submissionStatusForUser(csu.user_id)?.progressStatus === 'success'"
:to="`/course/${props.courseSession.course.slug}/cockpit/assignment/${assignment.assignmentId}/${csu.user_id}`"
class="w-full text-right underline"
>
Ergebnisse anzeigen
</router-link>
</template>
</ItPersonRow>
</ul>
</div>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,58 @@
<script setup lang="ts">
import type { StatusCount, StatusCountKey } from "@/components/ui/ItProgress.vue";
import ItProgress from "@/components/ui/ItProgress.vue";
import type { AssignmentLearningContent } from "@/services/assignmentService";
import { loadAssignmentCompletionStatusData } from "@/services/assignmentService";
import type { AssignmentCompletionStatus, CourseSession } from "@/types";
import { countBy } from "lodash";
import log from "loglevel";
import { onMounted, reactive } from "vue";
const props = defineProps<{
courseSession: CourseSession;
assignment: AssignmentLearningContent;
showTitle: boolean;
}>();
log.debug("AssignmentSubmissionProgress created", props.assignment.assignmentId);
const state = reactive({
statusByUser: [] as {
userStatus: AssignmentCompletionStatus;
progressStatus: StatusCountKey;
userId: number;
}[],
progressStatusCount: {} as StatusCount,
});
onMounted(async () => {
state.statusByUser = await loadAssignmentCompletionStatusData(
props.assignment.assignmentId,
props.courseSession.id
);
state.progressStatusCount = countBy(
state.statusByUser,
"progressStatus"
) as StatusCount;
});
</script>
<template>
<div v-if="state.statusByUser.length">
<div v-if="showTitle">
{{ props.assignment.title }}
</div>
<div><ItProgress :status-count="state.progressStatusCount" /></div>
<div>
{{ state.progressStatusCount.success || 0 }} von
{{
(state.progressStatusCount.success || 0) +
(state.progressStatusCount.unknown || 0)
}}
Lernenden haben ihre Ergebnisse eingereicht.
</div>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,71 @@
<script setup lang="ts">
import AssignmentDetails from "@/pages/cockpit/assignmentsPage/AssignmentDetails.vue";
import { calcAssignmentLearningContents } from "@/services/assignmentService";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useLearningPathStore } from "@/stores/learningPath";
import { useUserStore } from "@/stores/user";
import * as log from "loglevel";
import { computed, onMounted } from "vue";
const props = defineProps<{
courseSlug: string;
}>();
log.debug("AssignmentsPage created", props.courseSlug);
const learningPathStore = useLearningPathStore();
const courseSessionsStore = useCourseSessionsStore();
const userStore = useUserStore();
onMounted(async () => {
log.debug("AssignmentsPage mounted");
});
const assignments = computed(() => {
// TODO: filter by selected circle
if (!courseSessionsStore.currentCourseSession) {
return [];
}
return calcAssignmentLearningContents(
learningPathStore.learningPathForUser(
courseSessionsStore.currentCourseSession.course.slug,
userStore.id
)
);
});
</script>
<template>
<div class="bg-gray-200">
<div v-if="courseSessionsStore.currentCourseSession" class="container-large">
<nav class="py-4 pb-4">
<router-link
class="btn-text inline-flex items-center pl-0"
:to="`/course/${props.courseSlug}/cockpit`"
>
<it-icon-arrow-left />
<span>{{ $t("general.back") }}</span>
</router-link>
</nav>
<header>
<h2 class="heading-2 mb-4 flex items-center gap-2">
<it-icon-assignment-large class="h-16 w-16"></it-icon-assignment-large>
<div>Geleitete Fallarbeiten</div>
</h2>
</header>
<main>
<div v-for="assignment in assignments" :key="assignment.id">
<div class="bg-white p-6">
<AssignmentDetails
:course-session="courseSessionsStore.currentCourseSession"
:assignment="assignment"
/>
</div>
</div>
</main>
</div>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,57 @@
<script setup lang="ts">
import AssignmentSubmissionProgress from "@/pages/cockpit/assignmentsPage/AssignmentSubmissionProgress.vue";
import { calcAssignmentLearningContents } from "@/services/assignmentService";
import { useCockpitStore } from "@/stores/cockpit";
import { useLearningPathStore } from "@/stores/learningPath";
import { useUserStore } from "@/stores/user";
import type { CourseSession } from "@/types";
import log from "loglevel";
import { computed } from "vue";
const props = defineProps<{
courseSession: CourseSession;
}>();
log.debug("AssignmentsTile created", props.courseSession.id);
const userStore = useUserStore();
const cockpitStore = useCockpitStore();
const learningPathStore = useLearningPathStore();
const assignments = computed(() => {
// TODO: filter by selected circle
return calcAssignmentLearningContents(
learningPathStore.learningPathForUser(props.courseSession.course.slug, userStore.id)
);
});
</script>
<template>
<div class="bg-white px-6 py-5">
<div v-if="cockpitStore.courseSessionUsers">
<h3 class="heading-3 mb-4 flex items-center gap-2">
<it-icon-assignment-large class="h-16 w-16"></it-icon-assignment-large>
<div>Geleitete Fallarbeiten</div>
</h3>
<div v-for="assignment in assignments" :key="assignment.id">
<AssignmentSubmissionProgress
:show-title="true"
:course-session="props.courseSession"
:assignment="assignment"
/>
</div>
<div class="mt-6">
<router-link
:to="`/course/${props.courseSession.course.slug}/cockpit/assignment`"
class="link"
>
Alle anzeigen
</router-link>
</div>
</div>
</div>
</template>
<style scoped></style>

View File

@ -5,6 +5,7 @@ import ItPersonRow from "@/components/ui/ItPersonRow.vue";
import ItProgress from "@/components/ui/ItProgress.vue";
import type { LearningPath } from "@/services/learningPath";
import AssignmentsTile from "@/pages/cockpit/cockpitPage/AssignmentsTile.vue";
import { useCockpitStore } from "@/stores/cockpit";
import { useCompetenceStore } from "@/stores/competence";
import { useCourseSessionsStore } from "@/stores/courseSessions";
@ -24,7 +25,7 @@ const userStore = useUserStore();
const cockpitStore = useCockpitStore();
const competenceStore = useCompetenceStore();
const learningPathStore = useLearningPathStore();
const courseSessionStore = useCourseSessionsStore();
const courseSessionsStore = useCourseSessionsStore();
function userCountStatusForCircle(userId: number, translationKey: string) {
const criteria = competenceStore.flatPerformanceCriteria(
@ -92,7 +93,7 @@ function setActiveClasses(translationKey: string) {
<button
class="mr-4 rounded-full border-2 border-blue-900 px-4 last:mr-0"
:class="setActiveClasses(circle.translation_key)"
@click="cockpitStore.toggleCourseSelection(circle.translation_key)"
@click="cockpitStore.toggleCircleSelection(circle.translation_key)"
>
{{ circle.title }}
</button>
@ -101,23 +102,15 @@ function setActiveClasses(translationKey: string) {
</div>
<!-- Status -->
<div class="mb-4 grid grid-rows-2 gap-4 lg:grid-cols-2 lg:grid-rows-none">
<AssignmentsTile
v-if="courseSessionsStore.currentCourseSession"
:course-session="courseSessionsStore.currentCourseSession"
/>
<div class="bg-white px-6 py-5">
<h1
class="heading-3 mb-4 bg-assignment bg-60 bg-no-repeat pl-[68px] leading-[60px]"
>
{{ $t("general.transferTask", 2) }}
</h1>
<div class="mb-4">
<ItProgress :status-count="data.transferProgress"></ItProgress>
</div>
<p>{{ $t("cockpit.tasksDone") }}</p>
</div>
<div class="bg-white px-6 py-5">
<h1
class="heading-3 mb-4 bg-test bg-60 bg-no-repeat pl-[68px] leading-[60px]"
>
{{ $t("general.examResult", 2) }}
</h1>
<h3 class="heading-3 mb-4 flex items-center gap-2">
<it-icon-test-large class="h-16 w-16"></it-icon-test-large>
<div>{{ $t("general.examResult", 2) }}</div>
</h3>
<div class="mb-4">
<ItProgress :status-count="data.transferProgress"></ItProgress>
</div>
@ -131,8 +124,8 @@ function setActiveClasses(translationKey: string) {
learningPathStore.learningPathForUser(props.courseSlug, userStore.id)
?.circles || []
"
:course-id="courseSessionStore.currentCourseSession?.course.id || 0"
:url="courseSessionStore.currentCourseSession?.course_url || ''"
:course-id="courseSessionsStore.currentCourseSession?.course.id || 0"
:url="courseSessionsStore.currentCourseSession?.course_url || ''"
></FeedbackSummary>
<div>
<!-- progress -->

View File

@ -1,18 +1,16 @@
<script setup lang="ts">
import log from "loglevel";
import { computed, onMounted } from "vue";
import CircleDiagram from "./CircleDiagram.vue";
import CircleOverview from "./CircleOverview.vue";
import DocumentSection from "./DocumentSection.vue";
import LearningSequence from "./LearningSequence.vue";
import { useAppStore } from "@/stores/app";
import { useCircleStore } from "@/stores/circle";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import type { CourseSessionUser } from "@/types";
import { humanizeDuration } from "@/utils/humanizeDuration";
import sumBy from "lodash/sumBy";
import log from "loglevel";
import { computed, onMounted } from "vue";
import { useRoute } from "vue-router";
import CircleDiagram from "./CircleDiagram.vue";
import CircleOverview from "./CircleOverview.vue";
import DocumentSection from "./DocumentSection.vue";
import LearningSequence from "./LearningSequence.vue";
export interface Props {
courseSlug: string;
@ -31,9 +29,6 @@ const props = withDefaults(defineProps<Props>(), {
log.debug("CirclePage created", props.readonly, props.profileUser);
const appStore = useAppStore();
appStore.showMainNavigationBar = true;
const circleStore = useCircleStore();
const duration = computed(() => {

View File

@ -115,8 +115,7 @@ async function uploadDocument(data: DocumentUploadData) {
data,
courseSessionsStore.currentCourseSession.id
);
const courseSessionStore = useCourseSessionsStore();
courseSessionStore.addDocument(newDocument);
courseSessionsStore.addDocument(newDocument);
showUploadModal.value = false;
isUploading.value = false;
} catch (error) {

View File

@ -8,7 +8,6 @@ defineEmits(["exit"]);
<template>
<div>
<div class="h-full"></div>
<div class="absolute bottom-0 top-0 w-full bg-white">
<div class="h-content overflow-y-auto">
<header

View File

@ -1,6 +1,5 @@
<script setup lang="ts">
import LearningContent from "@/pages/learningPath/learningContentPage/LearningContent.vue";
import { useAppStore } from "@/stores/app";
import { useCircleStore } from "@/stores/circle";
import type { LearningContent as LearningContentType } from "@/types";
import * as log from "loglevel";
@ -16,9 +15,6 @@ const props = defineProps<{
const state: { learningContent?: LearningContentType } = reactive({});
const appStore = useAppStore();
appStore.showMainNavigationBar = false;
const circleStore = useCircleStore();
const loadLearningContent = async () => {

View File

@ -14,7 +14,11 @@ const props = withDefaults(defineProps<Props>(), {
<template>
<h3 class="mt-8">{{ $t("assignment.initialSituationTitle") }}</h3>
<p class="text-large">{{ props.assignment.starting_position }}</p>
<p
v-if="props.assignment.starting_position"
class="default-wagtail-rich-text text-large"
v-html="props.assignment.starting_position"
></p>
<h3 class="mt-8">{{ $t("assignment.taskDefinitionTitle") }}</h3>
<p class="text-large">
@ -55,8 +59,16 @@ const props = withDefaults(defineProps<Props>(), {
</p>
<h3 class="mt-8">{{ $t("assignment.assessmentTitle") }}</h3>
<p class="text-large">{{ props.assignment.assessment_description }}</p>
<a :href="props.assignment.assessment_document_url" class="text-large underline">
<p
v-if="props.assignment.evaluation_description"
class="default-wagtail-rich-text text-large"
v-html="props.assignment.evaluation_description"
></p>
<a
:href="props.assignment.evaluation_document_url"
target="_blank"
class="text-large link"
>
{{ $t("assignment.showAssessmentDocument") }}
</a>
</template>

View File

@ -1,36 +1,50 @@
<script setup lang="ts">
import type { UserDataText } from "@/stores/assignmentStore";
import { useAssignmentStore } from "@/stores/assignmentStore";
import type { AssignmentTask } from "@/types";
import { computed } from "vue";
import type {
Assignment,
AssignmentCompletionData,
AssignmentTask,
UserDataText,
} from "@/types";
const props = defineProps<{
assignment: Assignment;
assignmentCompletionData: AssignmentCompletionData;
allowEdit: boolean;
}>();
const emit = defineEmits<{
(e: "editTask", task: AssignmentTask): void;
}>();
const assignmentStore = useAssignmentStore();
const completionData = computed(() => assignmentStore.completion_data);
</script>
<template>
<div
v-for="task in assignmentStore.assignment?.tasks ?? []"
v-for="task in props.assignment.tasks ?? []"
:key="task.id"
class="mb-6 border-t border-gray-400"
>
<div class="flex flex-row justify-between pt-8">
<p class="text-sm text-gray-900">{{ task.value.title }}</p>
<button
class="link whitespace-nowrap pl-2 text-sm"
v-if="props.allowEdit"
class="link pl-2text-sm whitespace-nowrap"
@click="emit('editTask', task)"
>
{{ $t("assignment.edit") }}
</button>
</div>
<div v-for="taskBlock in task.value.content" :key="taskBlock.id">
<p class="pt-6 text-base font-bold">{{ taskBlock.value.text }}</p>
<p v-if="completionData && taskBlock.id in completionData" class="font-normal">
{{ (completionData[taskBlock.id].user_data as UserDataText).text }}
<p
class="default-wagtail-rich-text pt-6 text-base font-bold"
v-html="taskBlock.value.text"
></p>
<p
v-if="
props.assignmentCompletionData &&
taskBlock.id in props.assignmentCompletionData
"
class="font-normal"
>
{{ (assignmentCompletionData[taskBlock.id].user_data as UserDataText).text }}
</p>
</div>
</div>

View File

@ -5,7 +5,7 @@ import ItSuccessAlert from "@/components/ui/ItSuccessAlert.vue";
import AssignmentSubmissionResponses from "@/pages/learningPath/learningContentPage/assignment/AssignmentSubmissionResponses.vue";
import { useAssignmentStore } from "@/stores/assignmentStore";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import type { Assignment, AssignmentTask } from "@/types";
import type { Assignment, AssignmentCompletionData, AssignmentTask } from "@/types";
import type { Dayjs } from "dayjs";
import log from "loglevel";
import { computed, reactive } from "vue";
@ -13,6 +13,7 @@ import { useI18n } from "vue-i18n";
const props = defineProps<{
assignment: Assignment;
assignmentCompletionData: AssignmentCompletionData;
courseSessionId: number;
dueDate: Dayjs;
}>();
@ -22,7 +23,7 @@ const emit = defineEmits<{
}>();
const assignmentStore = useAssignmentStore();
const courseSessionStore = useCourseSessionsStore();
const courseSessionsStore = useCourseSessionsStore();
const { t } = useI18n();
const state = reactive({
@ -31,30 +32,34 @@ const state = reactive({
});
const circleExpert = computed(() => {
return courseSessionStore.circleExperts[0];
return courseSessionsStore.circleExperts[0];
});
const circleExpertName = computed(() => {
return `${circleExpert.value?.first_name} ${circleExpert.value?.last_name}`;
});
const completionStatus = computed(() => {
return assignmentStore.assignmentCompletion?.completion_status ?? "in_progress";
});
const onEditTask = (task: AssignmentTask) => {
emit("editTask", task);
};
const onSubmit = async () => {
try {
const courseSessionId = courseSessionStore.currentCourseSession?.id;
const courseSessionId = courseSessionsStore.currentCourseSession?.id;
if (!courseSessionId) {
log.error("Invalid courseSessionId");
return;
}
await assignmentStore.upsertAssignmentCompletion(
props.assignment.id,
{},
courseSessionId,
true
);
await assignmentStore.upsertAssignmentCompletion({
assignment_id: props.assignment.id,
course_session_id: courseSessionId,
completion_data: {},
completion_status: "submitted",
});
} catch (error) {
log.error("Could not submit assignment", error);
}
@ -66,7 +71,7 @@ const onSubmit = async () => {
{{ $t("assignment.acceptConditionsDisclaimer") }}
</h3>
<div v-if="!assignmentStore.submitted">
<div v-if="completionStatus === 'in_progress'">
<ItCheckbox
class="w-full border-b border-gray-400 py-6"
:checkbox-item="{
@ -99,7 +104,7 @@ const onSubmit = async () => {
</div>
<div class="flex flex-col space-x-2 pt-6 text-base sm:flex-row">
<p>{{ $t("assignment.assessmentDocumentDisclaimer") }}</p>
<a :href="props.assignment.assessment_document_url" class="underline">
<a :href="props.assignment.evaluation_document_url" class="underline">
{{ $t("assignment.showAssessmentDocument") }}
</a>
</div>
@ -126,6 +131,9 @@ const onSubmit = async () => {
</div>
</div>
<AssignmentSubmissionResponses
:assignment="props.assignment"
:assignment-completion-data="props.assignmentCompletionData"
:allow-edit="completionStatus === 'in_progress'"
@edit-task="onEditTask"
></AssignmentSubmissionResponses>
</template>

View File

@ -1,18 +1,17 @@
<script setup lang="ts">
import ItCheckbox from "@/components/ui/ItCheckbox.vue";
import ItTextarea from "@/components/ui/ItTextarea.vue";
import type {
AssignmentCompletionData,
BlockId,
UserDataConfirmation,
UserDataText,
} from "@/stores/assignmentStore";
import { useAssignmentStore } from "@/stores/assignmentStore";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import type { AssignmentTask } from "@/types";
import type {
AssignmentCompletionData,
AssignmentTask,
UserDataConfirmation,
UserDataText,
} from "@/types";
import { useDebounceFn } from "@vueuse/core";
import dayjs from "dayjs";
import { reactive, ref } from "vue";
import { computed, reactive, ref } from "vue";
const props = defineProps<{
assignmentId: number;
@ -22,24 +21,24 @@ const props = defineProps<{
const lastSaved = ref(dayjs());
const lastSaveUnsuccessful = ref(false);
const checkboxState = reactive({} as Record<BlockId, boolean>);
const checkboxState = reactive({} as Record<string, boolean>);
const courseSessionStore = useCourseSessionsStore();
const courseSessionsStore = useCourseSessionsStore();
const assignmentStore = useAssignmentStore();
async function upsertAssignmentCompletion(completion_data: AssignmentCompletionData) {
try {
const courseSessionId = courseSessionStore.currentCourseSession?.id;
const courseSessionId = courseSessionsStore.currentCourseSession?.id;
if (!courseSessionId) {
console.error("Invalid courseSessionId");
return;
}
await assignmentStore.upsertAssignmentCompletion(
props.assignmentId,
completion_data,
courseSessionId,
false
);
await assignmentStore.upsertAssignmentCompletion({
assignment_id: props.assignmentId,
course_session_id: courseSessionId,
completion_data: completion_data,
completion_status: "in_progress",
});
lastSaved.value = dayjs();
lastSaveUnsuccessful.value = false;
console.debug("Saved user input");
@ -54,7 +53,7 @@ const upsertAssignmentCompletionDebounced = useDebounceFn(
500
);
const onUpdateText = (id: BlockId, value: string) => {
const onUpdateText = (id: string, value: string) => {
const data: AssignmentCompletionData = {};
data[id] = {
user_data: {
@ -64,7 +63,7 @@ const onUpdateText = (id: BlockId, value: string) => {
upsertAssignmentCompletionDebounced(data);
};
const onUpdateConfirmation = (id: BlockId, value: boolean) => {
const onUpdateConfirmation = (id: string, value: boolean) => {
const data: AssignmentCompletionData = {};
data[id] = {
user_data: {
@ -74,7 +73,7 @@ const onUpdateConfirmation = (id: BlockId, value: boolean) => {
upsertAssignmentCompletion(data);
};
const getBlockData = (id: BlockId) => {
const getBlockData = (id: string) => {
const userData = assignmentStore.getCompletionDataForUserInput(id)?.user_data;
if (userData && "text" in userData) {
return userData.text;
@ -84,18 +83,23 @@ const getBlockData = (id: BlockId) => {
return null;
};
const onToggleCheckbox = (id: BlockId) => {
const onToggleCheckbox = (id: string) => {
checkboxState[id] = !checkboxState[id];
onUpdateConfirmation(id, checkboxState[id]);
};
const completionStatus = computed(() => {
return assignmentStore.assignmentCompletion?.completion_status ?? "in_progress";
});
</script>
<template>
<div class="flex flex-col space-y-10">
<div v-for="(block, index) in props.task.value.content" :key="block.id">
<div v-if="block.type === 'explanation'">
<p class="text-large">{{ block.value.text }}</p>
<p class="default-wagtail-rich-text text-large" v-html="block.value.text"></p>
</div>
<div v-if="block.type === 'user_confirmation'">
<ItCheckbox
:checkbox-item="{
@ -103,16 +107,20 @@ const onToggleCheckbox = (id: BlockId) => {
value: `confirmation-${index}`,
checked: getBlockData(block.id) as boolean,
}"
:disabled="assignmentStore.submitted"
:disabled="completionStatus !== 'in_progress'"
@toggle="onToggleCheckbox(block.id)"
></ItCheckbox>
</div>
<div v-if="block.type === 'user_text_input'">
<p class="text-large pb-4">{{ block.value.text }}</p>
<p
v-if="block.value.text"
class="text-large pb-4"
v-html="block.value.text"
></p>
<ItTextarea
:model-value="(getBlockData(block.id) as string) ?? ''"
:cy-key="`user-text-input-${index}`"
:disabled="assignmentStore.submitted"
:disabled="completionStatus !== 'in_progress'"
label=""
@update:model-value="onUpdateText(block.id, $event)"
></ItTextarea>

View File

@ -1,39 +1,38 @@
<script setup lang="ts">
import EvaluationSummary from "@/pages/cockpit/assignmentEvaluationPage/EvaluationSummary.vue";
import AssignmentIntroductionView from "@/pages/learningPath/learningContentPage/assignment/AssignmentIntroductionView.vue";
import AssignmentSubmissionView from "@/pages/learningPath/learningContentPage/assignment/AssignmentSubmissionView.vue";
import AssignmentTaskView from "@/pages/learningPath/learningContentPage/assignment/AssignmentTaskView.vue";
import LearningContentMultiLayout from "@/pages/learningPath/learningContentPage/layouts/LearningContentMultiLayout.vue";
import type { AssignmentCompletionData } from "@/stores/assignmentStore";
import { useAssignmentStore } from "@/stores/assignmentStore";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useUserStore } from "@/stores/user";
import type {
Assignment,
AssignmentTask,
CourseSessionAssignmentDetails,
CourseSessionUser,
LearningContent,
} from "@/types";
import { useRouteQuery } from "@vueuse/router";
import dayjs from "dayjs";
import * as log from "loglevel";
import { computed, onMounted, reactive } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const courseSessionStore = useCourseSessionsStore();
const courseSessionsStore = useCourseSessionsStore();
const assignmentStore = useAssignmentStore();
const userStore = useUserStore();
interface State {
assignment: Assignment | undefined;
courseSessionAssignmentDetails: CourseSessionAssignmentDetails | undefined;
assignmentCompletionData: AssignmentCompletionData | undefined;
pageIndex: number;
}
const state: State = reactive({
assignment: undefined,
courseSessionAssignmentDetails: undefined,
assignmentCompletionData: undefined,
// 0 = introduction, 1 - n = tasks, n+1 = submission
pageIndex: 0,
});
const props = defineProps<{
@ -41,6 +40,14 @@ const props = defineProps<{
learningContent: LearningContent;
}>();
// 0 = introduction, 1 - n = tasks, n+1 = submission
const pageIndex = useRouteQuery("page", "0", { transform: Number, mode: "push" });
const assignmentCompletion = computed(() => assignmentStore.assignmentCompletion);
const completionStatus = computed(() => {
return assignmentCompletion.value?.completion_status ?? "in_progress";
});
onMounted(async () => {
log.debug("AssignmentView mounted", props.assignmentId, props.learningContent);
@ -51,11 +58,17 @@ onMounted(async () => {
state.courseSessionAssignmentDetails = courseSessionsStore.findAssignmentDetails(
props.learningContent.id
);
state.assignmentCompletionData = await assignmentStore.loadAssignmentCompletion(
await assignmentStore.loadAssignmentCompletion(
props.assignmentId,
courseSessionId.value
);
log.debug(state.assignment, state.courseSessionAssignmentDetails);
if (
pageIndex.value === 0 &&
(completionStatus.value ?? "in_progress") !== "in_progress"
) {
pageIndex.value = numPages.value - 1;
}
} catch (error) {
log.error(error);
}
@ -63,92 +76,119 @@ onMounted(async () => {
const numTasks = computed(() => state.assignment?.tasks?.length ?? 0);
const numPages = computed(() => numTasks.value + 2);
const showPreviousButton = computed(() => state.pageIndex != 0);
const showNextButton = computed(() => state.pageIndex + 1 < numPages.value);
const showExitButton = computed(() => numPages.value === state.pageIndex + 1);
const showPreviousButton = computed(() => pageIndex.value != 0);
const showNextButton = computed(() => pageIndex.value + 1 < numPages.value);
const showExitButton = computed(() => numPages.value === pageIndex.value + 1);
const dueDate = computed(() =>
dayjs(state.courseSessionAssignmentDetails?.deadlineDateTimeUtc)
dayjs(state.courseSessionAssignmentDetails?.submissionDeadlineDateTimeUtc)
);
const courseSessionId = computed(
() => courseSessionStore.currentCourseSession?.id ?? 0
() => courseSessionsStore.currentCourseSession?.id ?? 0
);
const currentTask = computed(() => {
if (state.pageIndex > 0 && state.pageIndex <= numTasks.value) {
return state.assignment?.tasks[state.pageIndex - 1];
if (pageIndex.value > 0 && pageIndex.value <= numTasks.value) {
return state.assignment?.tasks[pageIndex.value - 1];
}
return undefined;
});
const handleBack = () => {
log.debug("handleBack");
if (state.pageIndex > 0) {
state.pageIndex -= 1;
if (pageIndex.value > 0) {
pageIndex.value -= 1;
}
log.debug(`pageIndex: ${state.pageIndex}`);
log.debug(`pageIndex: ${pageIndex.value}`);
};
const handleContinue = () => {
log.debug("handleContinue");
if (state.pageIndex + 1 < numPages.value) {
state.pageIndex += 1;
if (pageIndex.value + 1 < numPages.value) {
pageIndex.value += 1;
}
log.debug(`pageIndex: ${state.pageIndex}`);
log.debug(`pageIndex: ${pageIndex.value}`);
};
const jumpToTask = (task: AssignmentTask) => {
log.debug("jumpToTask", task);
const index = state.assignment?.tasks.findIndex((t) => t.id === task.id);
if (index && index >= 0) {
state.pageIndex = index + 1;
pageIndex.value = index + 1;
}
log.debug(`pageIndex: ${state.pageIndex}`);
log.debug(`pageIndex: ${pageIndex.value}`);
};
const getTitle = () => {
if (0 === state.pageIndex) {
if (0 === pageIndex.value) {
return t("general.introduction");
} else if (state.pageIndex === numPages.value - 1) {
} else if (pageIndex.value === numPages.value - 1) {
return t("general.submission");
}
return currentTask?.value?.value.title ?? "Unknown";
};
const assignmentUser = computed(() => {
return (courseSessionsStore.currentCourseSession?.users ?? []).find(
(user) => user.user_id === Number(userStore.id)
) as CourseSessionUser;
});
</script>
<template>
<LearningContentMultiLayout
:current-step="state.pageIndex"
:subtitle="state.assignment?.title ?? ''"
:title="getTitle()"
learning-content-type="assignment"
:steps-count="numPages"
:show-next-button="showNextButton"
:show-exit-button="showExitButton"
:show-start-button="false"
:show-previous-button="showPreviousButton"
start-badge-text="Einleitung"
end-badge-text="Abgabe"
close-button-variant="close"
@previous="handleBack()"
@next="handleContinue()"
>
<div>
<AssignmentIntroductionView
v-if="state.pageIndex === 0 && state.assignment"
:due-date="dueDate"
:assignment="state.assignment!"
></AssignmentIntroductionView>
<AssignmentTaskView
v-if="currentTask"
:task="currentTask"
:assignment-id="props.assignmentId"
></AssignmentTaskView>
<AssignmentSubmissionView
v-if="state.pageIndex + 1 === numPages && state.assignment && courseSessionId"
:due-date="dueDate"
:assignment="state.assignment!"
:course-session-id="courseSessionId!"
@edit-task="jumpToTask($event)"
></AssignmentSubmissionView>
<div v-if="state.assignment">
<div class="flex">
<LearningContentMultiLayout
:current-step="pageIndex"
:subtitle="state.assignment?.title ?? ''"
:title="getTitle()"
learning-content-type="assignment"
:steps-count="numPages"
:show-next-button="showNextButton"
:show-exit-button="showExitButton"
:show-start-button="false"
:show-previous-button="showPreviousButton"
:base-url="props.learningContent.frontend_url"
query-param="page"
start-badge-text="Einleitung"
end-badge-text="Abgabe"
close-button-variant="close"
@previous="handleBack()"
@next="handleContinue()"
>
<div class="flex">
<div>
<AssignmentIntroductionView
v-if="pageIndex === 0 && state.assignment"
:due-date="dueDate"
:assignment="state.assignment!"
></AssignmentIntroductionView>
<AssignmentTaskView
v-if="currentTask"
:task="currentTask"
:assignment-id="props.assignmentId"
></AssignmentTaskView>
<AssignmentSubmissionView
v-if="pageIndex + 1 === numPages && state.assignment && courseSessionId"
:due-date="dueDate"
:assignment="state.assignment!"
:assignment-completion-data="assignmentCompletion?.completion_data ?? {}"
:course-session-id="courseSessionId!"
@edit-task="jumpToTask($event)"
></AssignmentSubmissionView>
</div>
</div>
</LearningContentMultiLayout>
<div
v-if="assignmentCompletion?.completion_status === 'evaluation_submitted'"
class="min-w-2/5 mr-4 bg-gray-200 px-6 py-6"
>
<EvaluationSummary
v-if="state.assignment"
:assignment-user="assignmentUser"
:assignment="state.assignment"
:assignment-completion="assignmentCompletion"
:show-evaluation-user="true"
></EvaluationSummary>
</div>
</div>
</LearningContentMultiLayout>
</div>
</template>

View File

@ -19,6 +19,8 @@ interface Props {
startBadgeText?: string;
endBadgeText?: string;
closeButtonVariant?: ClosingButtonVariant;
baseUrl?: string;
queryParam?: string;
}
const props = withDefaults(defineProps<Props>(), {
@ -55,6 +57,8 @@ const emit = defineEmits(["previous", "next", "exit"]);
:start-badge-text="props.startBadgeText"
:steps="stepsCount"
:end-badge-text="props.endBadgeText"
:base-url="props.baseUrl"
:query-param="props.queryParam"
class="overflow-hidden pb-12"
></ItNavigationProgress>
<slot></slot>

View File

@ -2,8 +2,6 @@
import * as log from "loglevel";
import SelfEvaluation from "@/pages/learningPath/selfEvaluationPage/SelfEvaluation.vue";
import { useAppStore } from "@/stores/app";
import { useCircleStore } from "@/stores/circle";
import type { LearningUnit } from "@/types";
import { onMounted, reactive } from "vue";
@ -16,9 +14,6 @@ const props = defineProps<{
learningUnitSlug: string;
}>();
const appStore = useAppStore();
appStore.showMainNavigationBar = false;
const circleStore = useCircleStore();
const state: { learningUnit?: LearningUnit } = reactive({});

View File

@ -111,7 +111,7 @@ const router = createRouter({
children: [
{
path: "",
component: () => import("@/pages/cockpit/CockpitIndexPage.vue"),
component: () => import("@/pages/cockpit/cockpitPage/CockpitPage.vue"),
props: true,
},
{
@ -129,6 +129,20 @@ const router = createRouter({
component: () => import("@/pages/cockpit/FeedbackPage.vue"),
props: true,
},
{
path: "assignment",
component: () =>
import("@/pages/cockpit/assignmentsPage/AssignmentsPage.vue"),
props: true,
},
{
path: "assignment/:assignmentId/:userId",
component: () =>
import(
"@/pages/cockpit/assignmentEvaluationPage/AssignmentEvaluationPage.vue"
),
props: true,
},
],
},
{
@ -169,11 +183,11 @@ router.beforeEach(updateLoggedIn);
router.beforeEach(redirectToLoginIfRequired);
router.beforeEach((to) => {
const courseSessionStore = useCourseSessionsStore();
const courseSessionsStore = useCourseSessionsStore();
if (to.params.courseSlug) {
courseSessionStore._currentCourseSlug = to.params.courseSlug as string;
courseSessionsStore._currentCourseSlug = to.params.courseSlug as string;
} else {
courseSessionStore._currentCourseSlug = "";
courseSessionsStore._currentCourseSlug = "";
}
});

View File

@ -0,0 +1,32 @@
import { describe, it } from "vitest";
import { pointsToGrade } from "../assignmentService";
describe("assignmentService", () => {
it("pointsToGrade", () => {
expect(pointsToGrade(24, 24)).toBe(6);
expect(pointsToGrade(23, 24)).toBe(6);
expect(pointsToGrade(22, 24)).toBe(5.5);
expect(pointsToGrade(21, 24)).toBe(5.5);
expect(pointsToGrade(20, 24)).toBe(5);
expect(pointsToGrade(19, 24)).toBe(5);
expect(pointsToGrade(18, 24)).toBe(5);
expect(pointsToGrade(17, 24)).toBe(4.5);
expect(pointsToGrade(16, 24)).toBe(4.5);
expect(pointsToGrade(15, 24)).toBe(4);
expect(pointsToGrade(14, 24)).toBe(4);
expect(pointsToGrade(13, 24)).toBe(3.5);
expect(pointsToGrade(12, 24)).toBe(3.5);
expect(pointsToGrade(11, 24)).toBe(3.5);
expect(pointsToGrade(10, 24)).toBe(3);
expect(pointsToGrade(9, 24)).toBe(3);
expect(pointsToGrade(8, 24)).toBe(2.5);
expect(pointsToGrade(7, 24)).toBe(2.5);
expect(pointsToGrade(6, 24)).toBe(2.5);
expect(pointsToGrade(5, 24)).toBe(2);
expect(pointsToGrade(4, 24)).toBe(2);
expect(pointsToGrade(3, 24)).toBe(1.5);
expect(pointsToGrade(2, 24)).toBe(1.5);
expect(pointsToGrade(1, 24)).toBe(1);
expect(pointsToGrade(0, 24)).toBe(1);
});
});

View File

@ -0,0 +1,115 @@
import type { StatusCountKey } from "@/components/ui/ItProgress.vue";
import { itGet } from "@/fetchHelpers";
import type { LearningPath } from "@/services/learningPath";
import { useCockpitStore } from "@/stores/cockpit";
import type {
Assignment,
AssignmentCompletion,
AssignmentCompletionStatus,
CourseSessionUser,
LearningContent,
UserAssignmentCompletionStatus,
} from "@/types";
import { sum } from "d3";
import pick from "lodash/pick";
export interface AssignmentLearningContent extends LearningContent {
assignmentId: number;
}
export function calcAssignmentLearningContents(learningPath?: LearningPath) {
// TODO: filter by circle
if (!learningPath) return [];
return learningPath.circles.flatMap((circle) => {
const learningContents = circle.flatLearningContents.filter(
(lc) => lc.contents[0].type === "assignment"
);
return learningContents.map((lc) => {
return {
...lc,
// @ts-ignore
assignmentId: lc.contents[0].value.assignment,
};
});
}) as AssignmentLearningContent[];
}
export async function loadAssignmentCompletionStatusData(
assignmentId: number,
courseSessionId: number
) {
const cockpitStore = useCockpitStore();
const assignmentCompletionData = (await itGet(
`/api/assignment/${assignmentId}/${courseSessionId}/status/`
)) as UserAssignmentCompletionStatus[];
const courseSessionUsers = await cockpitStore.loadCourseSessionUsers(courseSessionId);
return calcUserAssignmentCompletionStatus(
courseSessionUsers,
assignmentCompletionData
);
}
export function calcUserAssignmentCompletionStatus(
courseSessionUsers: CourseSessionUser[],
assignmentCompletionStatusData: UserAssignmentCompletionStatus[]
) {
return courseSessionUsers.map((u) => {
let userStatus = "unknown" as AssignmentCompletionStatus;
const userAssignmentStatus = assignmentCompletionStatusData?.find(
(s) => s.assignment_user_id === u.user_id
);
if (userAssignmentStatus) {
userStatus = userAssignmentStatus.completion_status;
}
let progressStatus: StatusCountKey = "unknown";
if (
["submitted", "evaluation_in_progress", "evaluation_submitted"].includes(
userStatus
)
) {
progressStatus = "success";
}
return {
userId: u.user_id,
userStatus,
progressStatus,
grade: userAssignmentStatus?.evaluation_grade ?? null,
};
});
}
export function maxAssignmentPoints(assignment: Assignment) {
return sum(assignment.evaluation_tasks.map((task) => task.value.max_points));
}
export function userAssignmentPoints(
assignment: Assignment,
assignmentCompletion: AssignmentCompletion
) {
const evaluationTaskIds = assignment.evaluation_tasks.map((task) => {
return task.id;
});
return sum(
// transform the object of { [expert_id]: { expert_data: { points } } } to an array
// of [ [expert_id, { expert_data: { points } }], ... ] so that we can easily sum
// the points of the user
Object.entries(pick(assignmentCompletion.completion_data, evaluationTaskIds)).map(
(entry) => {
return entry[1]?.expert_data?.points ?? 0;
}
)
);
}
export function pointsToGrade(points: number, maxPoints: number) {
// round to half-grades
const grade = Math.round((points / maxPoints) * 10);
const halfGrade = grade / 2;
return Math.min(halfGrade, 5) + 1;
}

View File

@ -3,33 +3,12 @@ import { defineStore } from "pinia";
export type AppState = {
userLoaded: boolean;
routingFinished: boolean;
showMainNavigationBar: boolean;
currentCourseSlug: string;
};
const showMainNavigationBarInitialState = () => {
let path = window.location.pathname;
// remove dangling slash
if (path.endsWith("/")) {
path = path.slice(0, -1);
}
const numberOfSlashes = (path.match(/\//g) || []).length;
// it should hide main navigation bar when on learning content page
if (path.startsWith("/learn/") && numberOfSlashes >= 4) {
return false;
}
return true;
};
export const useAppStore = defineStore({
id: "app",
state: () =>
({
showMainNavigationBar: showMainNavigationBarInitialState(),
userLoaded: false,
routingFinished: false,
} as AppState),

View File

@ -1,39 +1,29 @@
import { itGet, itPost } from "@/fetchHelpers";
import type { Assignment } from "@/types";
import { calcAssignmentLearningContents } from "@/services/assignmentService";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useLearningPathStore } from "@/stores/learningPath";
import { useUserStore } from "@/stores/user";
import type {
Assignment,
AssignmentCompletion,
EvaluationCompletionData,
UpsertUserAssignmentCompletion,
} from "@/types";
import { merge } from "lodash";
import log from "loglevel";
import { defineStore } from "pinia";
export type AssignmentStoreState = {
assignment: Assignment | undefined;
completion_data: AssignmentCompletionData;
submitted: boolean;
assignmentCompletion: AssignmentCompletion | undefined;
};
export type BlockId = string;
export interface UserDataText {
text: string;
}
export interface UserDataConfirmation {
confirmation: boolean;
}
export interface AssignmentCompletionData {
// {
// "<user_text_input:uuid>": {"user_data": {"text": "some text from user"}},
// "<user_confirmation:uuid>": {"user_data": {"confirmation": true}},
// }
[key: BlockId]: { user_data: UserDataText | UserDataConfirmation };
}
export const useAssignmentStore = defineStore({
id: "assignmentStore",
state: () => {
return {
assignment: undefined,
completion_data: {},
submitted: false,
assignmentCompletion: undefined,
} as AssignmentStoreState;
},
getters: {},
@ -48,39 +38,73 @@ export const useAssignmentStore = defineStore({
this.assignment = assignmentData;
return this.assignment;
},
async loadAssignmentCompletion(assignmentId: number, courseSessionId: number) {
log.debug("load assignment completion", assignmentId, courseSessionId);
async loadAssignmentCompletion(
assignmentId: number,
courseSessionId: number,
userId: string | undefined = undefined
) {
log.debug("load assignment completion", assignmentId, courseSessionId, userId);
this.assignmentCompletion = undefined;
try {
const data = await itGet(`/api/assignment/${assignmentId}/${courseSessionId}/`);
this.completion_data = data.completion_data;
this.submitted = data.completion_status === "submitted";
let url = `/api/assignment/${assignmentId}/${courseSessionId}/`;
if (userId) {
url += `${userId}/`;
}
this.assignmentCompletion = await itGet(url);
} catch (e) {
log.debug("no completion data found ", e);
return undefined;
}
return this.completion_data;
return this.assignmentCompletion;
},
getCompletionDataForUserInput(id: BlockId) {
return this.completion_data[id];
getCompletionDataForUserInput(id: string) {
return this.assignmentCompletion?.completion_data[id];
},
async upsertAssignmentCompletion(
assignmentId: number,
completion_data: AssignmentCompletionData,
courseSessionId: number,
submit: boolean
) {
const data = {
assignment_id: assignmentId,
completion_status: submit ? "submitted" : "in_progress",
course_session_id: courseSessionId,
completion_data: completion_data,
};
async upsertAssignmentCompletion(data: UpsertUserAssignmentCompletion) {
if (this.assignmentCompletion) {
merge(this.assignmentCompletion.completion_data, data.completion_data);
this.assignmentCompletion.completion_status = data.completion_status;
}
const responseData = await itPost(`/api/assignment/upsert/`, data);
if (responseData) {
this.completion_data = responseData.completion_data;
this.submitted = responseData.completion_status === "submitted";
this.assignmentCompletion = responseData;
}
return responseData;
},
async evaluateAssignmentCompletion(data: EvaluationCompletionData) {
if (this.assignmentCompletion) {
merge(this.assignmentCompletion.completion_data, data.completion_data);
this.assignmentCompletion.completion_status = data.completion_status;
}
const responseData = await itPost(`/api/assignment/evaluate/`, data);
if (responseData) {
this.assignmentCompletion = responseData;
}
return responseData;
},
findAssignmentDetail(assignmentId: number) {
const learningPathStore = useLearningPathStore();
const userStore = useUserStore();
const courseSessionsStore = useCourseSessionsStore();
// TODO: filter by selected circle
if (!courseSessionsStore.currentCourseSession) {
return undefined;
}
const learningContents = calcAssignmentLearningContents(
learningPathStore.learningPathForUser(
courseSessionsStore.currentCourseSession.course.slug,
userStore.id
)
);
const learningContent = learningContents.find(
(lc) => lc.assignmentId === assignmentId
);
return courseSessionsStore.findAssignmentDetails(learningContent?.id);
},
},
});

View File

@ -21,11 +21,14 @@ export const useCockpitStore = defineStore({
} as CockpitStoreState;
},
actions: {
async loadCourseSessionUsers(courseSlug: string, reload = false) {
async loadCourseSessionUsers(courseSessionId: number, reload = false) {
log.debug("loadCockpitData called");
const users = (await itGetCached(`/api/course/sessions/${courseSlug}/users/`, {
reload: reload,
})) as CourseSessionUser[];
const users = (await itGetCached(
`/api/course/sessions/${courseSessionId}/users/`,
{
reload: reload,
}
)) as CourseSessionUser[];
this.courseSessionUsers = users.filter((user) => user.role === "MEMBER");
@ -45,7 +48,7 @@ export const useCockpitStore = defineStore({
}
return this.courseSessionUsers;
},
toggleCourseSelection(translationKey: string) {
toggleCircleSelection(translationKey: string) {
if (this.selectedCircles.indexOf(translationKey) < 0) {
this.selectedCircles.push(translationKey);
} else {

View File

@ -39,12 +39,9 @@ function loadCourseSessionsData(reload = false) {
// TODO: refactor after implementing of Klassenkonzept
await Promise.all(
courseSessions.value.map(async (cs) => {
const users = (await itGetCached(
`/api/course/sessions/${cs.course.slug}/users/`,
{
reload: reload,
}
)) as CourseSessionUser[];
const users = (await itGetCached(`/api/course/sessions/${cs.id}/users/`, {
reload: reload,
})) as CourseSessionUser[];
cs.users = users;
})
);
@ -236,9 +233,9 @@ export const useCourseSessionsStore = defineStore("courseSessions", () => {
}
function findAssignmentDetails(
contentId: number
contentId?: number
): CourseSessionAssignmentDetails | undefined {
if (currentCourseSession.value) {
if (contentId && currentCourseSession.value) {
return currentCourseSession.value.assignment_details_list.find(
(assignmentDetails) => assignmentDetails.learningContentId === contentId
);

View File

@ -32,6 +32,11 @@ export interface BaseLearningContentBlock {
export interface AssignmentBlock extends BaseLearningContentBlock {
readonly type: "assignment";
readonly value: {
description: string;
url: string;
assignment: number;
};
}
export interface BookBlock extends BaseLearningContentBlock {
@ -332,14 +337,32 @@ export interface AssignmentTask {
};
}
export interface AssignmentEvaluationSubTask {
title: string;
description: string;
points: number;
}
export interface AssignmentEvaluationTask {
readonly type: "task";
readonly id: string;
readonly value: {
title: string;
description: string;
max_points: number;
sub_tasks: AssignmentEvaluationSubTask[];
};
}
export interface Assignment extends BaseCourseWagtailPage {
readonly type: "assignment.Assignment";
readonly starting_position: string;
readonly effort_required: string;
readonly performance_objectives: AssignmentPerformanceObjective[];
readonly assessment_description: string;
readonly assessment_document_url: string;
readonly evaluation_description: string;
readonly evaluation_document_url: string;
readonly tasks: AssignmentTask[];
readonly evaluation_tasks: AssignmentEvaluationTask[];
}
export interface PerformanceCriteria extends BaseCourseWagtailPage {
@ -407,7 +430,8 @@ export interface CourseSessionAttendanceDay {
export interface CourseSessionAssignmentDetails {
learningContentId: number;
deadlineDateTimeUtc: string;
submissionDeadlineDateTimeUtc: string;
evaluationDeadlineDateTimeUtc: string;
}
export interface CourseSession {
@ -479,3 +503,69 @@ export interface Notification {
actor_avatar_url: string | null;
course: string | null;
}
export type AssignmentCompletionStatus =
| "unknwown"
| "in_progress"
| "submitted"
| "evaluation_in_progress"
| "evaluation_submitted";
export interface UserDataText {
text: string;
}
export interface UserDataConfirmation {
confirmation: boolean;
}
export interface ExpertData {
points?: number;
text?: string;
}
export interface AssignmentCompletionData {
// {
// "<user_text_input:uuid>": {"user_data": {"text": "some text from user"}},
// "<user_confirmation:uuid>": {"user_data": {"confirmation": true}},
// }
[key: string]: {
user_data?: UserDataText | UserDataConfirmation;
expert_data?: ExpertData;
};
}
export interface AssignmentCompletion {
id: number;
created_at: string;
updated_at: string;
submitted_at: string;
evaluation_submitted_at: string | null;
assignment_user: number;
assignment: number;
course_session: number;
completion_status: AssignmentCompletionStatus;
evaluation_user: number | null;
completion_data: AssignmentCompletionData;
evaluation_grade: number | null;
}
export type UpsertUserAssignmentCompletion = {
assignment_id: number;
course_session_id: number;
completion_status: AssignmentCompletionStatus;
completion_data: AssignmentCompletionData;
};
export type EvaluationCompletionData = UpsertUserAssignmentCompletion & {
assignment_user_id: number;
evaluation_grade?: number;
evaluation_points?: number;
};
export interface UserAssignmentCompletionStatus {
id: number;
assignment_user_id: number;
completion_status: AssignmentCompletionStatus;
evaluation_grade: number | null;
}

View File

@ -32,9 +32,6 @@ module.exports = {
"handlungsfelder-overview":
"url('/static/icons/icon-handlungsfelder-overview.svg')",
"lernmedien-overview": "url('/static/icons/icon-lernmedien-overview.svg')",
assignment: "url('/static/icons/icon-lc-assignment.svg')",
feedback: "url('/static/icons/icon-feedback.svg')",
test: "url('/static/icons/icon-lc-test.svg')",
message: "url('/static/icons/icon-message.svg')",
},
borderColor: (theme) => ({

View File

@ -27,6 +27,11 @@ body {
hyphens: auto;
}
.default-wagtail-rich-text ul {
list-style-type: disc;
margin-left: 24px;
}
svg {
@apply fill-current;
}

View File

@ -1,16 +1,12 @@
import { login } from "./helpers";
const navigateToAssignment = () => {
cy.visit(
"/course/überbetriebliche-kurse/learn/fahrzeug/überprüfen-einer-motorfahrzeug-versicherungspolice"
);
};
describe("assignment completion", () => {
describe("student test", () => {
beforeEach(() => {
cy.manageCommand("cypress_reset");
login("admin", "test");
navigateToAssignment();
login("test-student1@example.com", "test");
cy.visit(
"/course/test-lehrgang/learn/fahrzeug/überprüfen-einer-motorfahrzeug-versicherungspolice"
);
});
it("can open assignment", () => {
@ -27,10 +23,10 @@ describe("assignment completion", () => {
"Teilaufgabe 1: Beispiel einer Versicherungspolice finden"
);
// 2 Steps forward, 1 step backwards
for (let i = 0; i !== 2; i++) {
cy.learningContentMultiLayoutNextStep();
}
cy.learningContentMultiLayoutNextStep();
cy.learningContentMultiLayoutNextStep();
cy.testLearningContentTitle("Teilaufgabe 3: Aktuelle Versicherung");
cy.learningContentMultiLayoutPreviousStep();
cy.testLearningContentTitle(
@ -46,10 +42,7 @@ describe("assignment completion", () => {
);
// Click confirmation
cy.get('[data-cy="it-checkbox-confirmation-1"]').click({ force: true });
cy.wait(250);
cy.reload();
// 1 Step forward
cy.learningContentMultiLayoutNextStep();
cy.get('[data-cy="it-checkbox-confirmation-1"]').should("be.checked");
});
@ -64,15 +57,25 @@ describe("assignment completion", () => {
cy.get('[data-cy="it-textarea-user-text-input-1"]')
.clear()
.type("Hallovelo");
// wait because of input debounce
cy.wait(550);
cy.reload();
// 2 Step forward
cy.learningContentMultiLayoutNextStep();
cy.learningContentMultiLayoutNextStep();
cy.get('[data-cy="it-textarea-user-text-input-1"]').should(
"have.value",
"Hallovelo"
);
});
it("can visit sub step directly via url", () => {
cy.visit(
"/course/test-lehrgang/learn/fahrzeug/überprüfen-einer-motorfahrzeug-versicherungspolice?page=3"
);
cy.testLearningContentTitle("Teilaufgabe 3: Aktuelle Versicherung");
});
it("can visit sub step by clicking navigation bar", () => {
cy.get('[data-cy="nav-progress-step-4"]').click();
cy.testLearningContentTitle("Teilaufgabe 4: Deine Empfehlungen");
});
});

View File

@ -10,9 +10,10 @@ from grapple import urls as grapple_urls
from ratelimit.exceptions import Ratelimited
from vbv_lernwelt.assignment.views import (
grade_assignment_completion,
evaluate_assignment_completion,
request_assignment_completion,
request_assignment_completion_for_user,
request_assignment_completion_status,
upsert_user_assignment_completion,
)
from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt
@ -87,7 +88,7 @@ urlpatterns = [
# course
path(r"api/course/sessions/", get_course_sessions, name="get_course_sessions"),
path(r"api/course/sessions/<course_slug>/users/", get_course_session_users,
path(r"api/course/sessions/<course_session_id>/users/", get_course_session_users,
name="get_course_session_users"),
path(r"api/course/page/<slug_or_id>/", course_page_api_view,
name="course_page_api_view"),
@ -102,11 +103,14 @@ urlpatterns = [
# assignment
path(r"api/assignment/upsert/", upsert_user_assignment_completion,
name="upsert_user_assignment_completion"),
path(r"api/assignment/grade/", grade_assignment_completion,
name="grade_assignment_completion"),
path(r"api/assignment/evaluate/", evaluate_assignment_completion,
name="evaluate_assignment_completion"),
path(r"api/assignment/<int:assignment_id>/<int:course_session_id>/",
request_assignment_completion,
name="request_assignment_completion"),
path(r"api/assignment/<int:assignment_id>/<int:course_session_id>/status/",
request_assignment_completion_status,
name="request_assignment_completion_status"),
path(r"api/assignment/<int:assignment_id>/<int:course_session_id>/<int:user_id>/",
request_assignment_completion_for_user,
name="request_assignment_completion_for_user"),

View File

@ -1,7 +1,12 @@
from vbv_lernwelt.assignment.models import TaskContentStreamBlock
from vbv_lernwelt.assignment.models import (
EvaluationSubTaskBlock,
TaskContentStreamBlock,
)
from vbv_lernwelt.assignment.tests.assignment_factories import (
AssignmentFactory,
AssignmentListPageFactory,
EvaluationSubTaskBlockFactory,
EvaluationTaskBlockFactory,
ExplanationBlockFactory,
PerformanceObjectiveBlockFactory,
TaskBlockFactory,
@ -11,6 +16,8 @@ from vbv_lernwelt.core.utils import replace_whitespace
from vbv_lernwelt.course.consts import COURSE_TEST_ID, COURSE_UK
from vbv_lernwelt.course.models import CoursePage
from wagtail.blocks import StreamValue
from wagtail.blocks.list_block import ListBlock, ListValue
from wagtail.rich_text import RichText
def create_uk_assignments(course_id=COURSE_UK):
@ -46,8 +53,187 @@ def create_uk_assignments(course_id=COURSE_UK):
),
),
],
assessment_document_url="https://www.vbv.ch",
assessment_description="Diese geleitete Fallarbeit wird auf Grund des folgenden Beurteilungsintrument bewertet.",
evaluation_document_url="/static/media/assignments/UK_03_09_NACH_KN_Beurteilungsraster.pdf",
evaluation_description="Diese geleitete Fallarbeit wird auf Grund des folgenden Beurteilungsintrument bewertet.",
)
assignment.evaluation_tasks = []
assignment.evaluation_tasks.append(
(
"task",
EvaluationTaskBlockFactory(
title="Ausgangslage des Auftrags",
description=RichText(
"Beschreibt der/die Lernende die Ausgangslage des Auftrags vollständig?"
),
max_points=6,
sub_tasks=ListValue(
ListBlock(EvaluationSubTaskBlock()),
values=[
EvaluationSubTaskBlockFactory(
title="Die Ausgangslage des Auftrag ist vollständig beschrieben.",
description=RichText(
replace_whitespace(
"""
<ul>
<li>Worum geht es? Was ist die Aufgabe?</li>
<li>Sind das Kundenprofil und die Kundenbeziehung vollständig und nachvollziehbar dargestellt?</li>
<li>Ist das Alter des Fahrzeugs dokumentiert?</li>
<li>Welche Ressourcen stehen zur Verfügung?</li>
</ul>
"""
)
),
points=6,
),
EvaluationSubTaskBlockFactory(
title="Die Ausgangslage ist grösstenteils vollständig beschrieben.",
points=4,
),
EvaluationSubTaskBlockFactory(
title="Die Ausgangslage ist unvollständig - nur 2 Punkte wurden beschrieben.",
points=2,
),
EvaluationSubTaskBlockFactory(
title="Die Ausgangslage des Auftrag ist unvollständig - es fehlen mehr als 2 Punkte in der Beschreibung.",
points=0,
),
],
),
),
),
)
assignment.evaluation_tasks.append(
(
"task",
EvaluationTaskBlockFactory(
title="Inhaltsanalyse und Struktur",
max_points=6,
description=RichText(
"Sind die Deckungen der Police vollständig und nachvollziehbar dokumentiert?"
),
sub_tasks=ListValue(
ListBlock(EvaluationSubTaskBlock()),
values=[
EvaluationSubTaskBlockFactory(
title="Die Analyse beinhaltet alle in der Police vorhandenen Deckungen und ist logisch aufgebaut.",
points=6,
),
EvaluationSubTaskBlockFactory(
title="Die Analyse beinhaltet die meisten vorhandenen Deckungen in der Police und ist grösstenteils logisch aufgebaut.",
points=4,
),
EvaluationSubTaskBlockFactory(
title="Die Analyse ist unvollständig (es fehlen mehr als 3 Deckungen) und der rote Faden ist nicht erkennbar.",
points=2,
),
EvaluationSubTaskBlockFactory(
title="Die Analyse ist insgesamt nicht nachvollziehbar und es fehlen einige Deckungen.",
points=0,
),
],
),
),
),
)
assignment.evaluation_tasks.append(
(
"task",
EvaluationTaskBlockFactory(
title="Sinnvolle Empfehlungen",
max_points=6,
description=RichText(
"Leitet die lernende Person sinnvolle und geeignete Empfehlungen ab?"
),
sub_tasks=ListValue(
ListBlock(EvaluationSubTaskBlock()),
values=[
EvaluationSubTaskBlockFactory(
title="Die Empfehlungen sind durchgängig sinnvoll und nachvollziehbar begründet.",
points=6,
),
EvaluationSubTaskBlockFactory(
title="Die Empfehlungen sind grösstenteils sinnvoll und nachvollziehbar begründet.",
points=4,
),
EvaluationSubTaskBlockFactory(
title="Die Empfehlungen sind wenig sinnvoll und unvollständig begründet.",
points=2,
),
EvaluationSubTaskBlockFactory(
title="Die Empfehlungen sind weder sinnvoll nch nachvollziehbar begründet.",
points=0,
),
],
),
),
),
)
assignment.evaluation_tasks.append(
(
"task",
EvaluationTaskBlockFactory(
title="Qualität der Reflexion",
max_points=3,
description=RichText(
"Reflektiert die lernende Person die Durchführung der geleiteten Fallarbeit?"
),
sub_tasks=ListValue(
ListBlock(EvaluationSubTaskBlock()),
values=[
EvaluationSubTaskBlockFactory(
title="Die Reflexion bezieht sich auf die geleitete Fallarbeit und umfasst nachvollziehbare positive wie negative Aspekte.",
points=3,
),
EvaluationSubTaskBlockFactory(
title="Die Reflexion bezieht sich auf die geleitete Fallarbeit und umfasst grösstenteils nachvollziehbare positive wie negative Aspekte.",
points=2,
),
EvaluationSubTaskBlockFactory(
title="Die Reflexion ist unvollständig.",
points=1,
),
EvaluationSubTaskBlockFactory(
title="Die Reflexion bezieht sich nicht auf die geleitete Fallarbeit.",
points=0,
),
],
),
),
),
)
assignment.evaluation_tasks.append(
(
"task",
EvaluationTaskBlockFactory(
title="Eignung der Learnings",
max_points=3,
description=RichText(
"Leitet die lernende Person geeignete Learnings aus der Reflexion ab?"
),
sub_tasks=ListValue(
ListBlock(EvaluationSubTaskBlock()),
values=[
EvaluationSubTaskBlockFactory(
title="Die Learnings beziehen sich auf die geleitete Fallarbeit und sind inhaltlich sinnvoll.",
points=3,
),
EvaluationSubTaskBlockFactory(
title="Die Learnings beziehen sich grösstenteils auf die geleitete Fallarbeit und sind inhaltlich sinnvoll.",
points=2,
),
EvaluationSubTaskBlockFactory(
title="Die Learnings beziehen sich teilweise auf die geleitete Fallarbeit und sind inhaltlich wenig sinnvoll.",
points=1,
),
EvaluationSubTaskBlockFactory(
title="Die Learnings beziehen sich nicht auf die geleitete Fallarbeit.",
points=0,
),
],
),
),
),
)
assignment.tasks = []
@ -57,18 +243,24 @@ def create_uk_assignments(course_id=COURSE_UK):
TaskBlockFactory(
title="Teilaufgabe 1: Beispiel einer Versicherungspolice finden",
# it is hard to create a StreamValue programmatically, we have to
# create a `StreamValue` manually. Ask the Daniel and/or Ramon
# create a `StreamValue` manually. Ask Daniel and/or Ramon
content=StreamValue(
TaskContentStreamBlock(),
stream_data=[
(
"explanation",
ExplanationBlockFactory(text="Dies ist ein Beispieltext."),
ExplanationBlockFactory(
text=RichText(
"Bitte jemand aus deiner Familie oder deinem Freundeskreis darum, dir seine/ihre Motorfahrzeugversicherungspolice zur Verfügung zu stellen."
)
),
),
(
"user_confirmation",
ExplanationBlockFactory(
text="Ja, ich habe Motorfahrzeugversicherungspolice von jemandem aus meiner Familie oder meinem Freundeskreis erhalten."
text=RichText(
"Ja, ich habe Motorfahrzeugversicherungspolice von jemandem aus meiner Familie oder meinem Freundeskreis erhalten."
)
),
),
],
@ -88,12 +280,16 @@ def create_uk_assignments(course_id=COURSE_UK):
(
"explanation",
ExplanationBlockFactory(
text=replace_whitespace(
"""
Erläutere die Kundensituation und die Ausgangslage.
* Hast du alle Informationen, die du für den Policen-Check benötigst?
* Halte die wichtigsten Eckwerte des aktuellen Versicherungsverhältnisse in deiner Dokumentation fest (z.B wie lang wo versichert, Alter des Fahrzeugs, Kundenprofil, etc.)
"""
text=RichText(
replace_whitespace(
"""
Erläutere die Kundensituation und die Ausgangslage.
<ul>
<li>Hast du alle Informationen, die du für den Policen-Check benötigst?</li>
<li>Halte die wichtigsten Eckwerte des aktuellen Versicherungsverhältnisse in deiner Dokumentation fest (z.B wie lang wo versichert, Alter des Fahrzeugs, Kundenprofil, etc.</li>
</ul>
"""
)
)
),
),
@ -116,10 +312,8 @@ def create_uk_assignments(course_id=COURSE_UK):
(
"explanation",
ExplanationBlockFactory(
text=replace_whitespace(
"""
Zeige nun detailliert auf, wie dein Kundenbeispiel momentan versichert ist.
"""
text=RichText(
"Zeige nun detailliert auf, wie dein Kundenbeispiel momentan versichert ist."
)
),
),
@ -141,40 +335,32 @@ def create_uk_assignments(course_id=COURSE_UK):
(
"explanation",
ExplanationBlockFactory(
text=replace_whitespace(
"""
Erarbeite nun basierend auf deinen Erkenntnissen eine Empfehlung für die Person.
"""
text=RichText(
"Erarbeite nun basierend auf deinen Erkenntnissen eine Empfehlung für die Person."
)
),
),
(
"user_text_input",
UserTextInputBlockFactory(
text=replace_whitespace(
"""
Gibt es zusätzliche Deckungen, die du der Person empfehlen würdest? Begründe deine Empfehlung
"""
text=RichText(
"Gibt es zusätzliche Deckungen, die du der Person empfehlen würdest? Begründe deine Empfehlung"
)
),
),
(
"user_text_input",
UserTextInputBlockFactory(
text=replace_whitespace(
"""
Gibt es Deckungen, die du streichen würdest? Begründe deine Empfehlung.
"""
text=RichText(
"Gibt es Deckungen, die du streichen würdest? Begründe deine Empfehlung."
)
),
),
(
"user_text_input",
UserTextInputBlockFactory(
text=replace_whitespace(
"""
Wenn die Person gemäss deiner Einschätzung genau richtig versichert ist, argumentiere, warum dies der Fall ist.
"""
text=RichText(
"Wenn die Person gemäss deiner Einschätzung genau richtig versichert ist, argumentiere, warum dies der Fall ist."
)
),
),
@ -195,40 +381,32 @@ def create_uk_assignments(course_id=COURSE_UK):
(
"explanation",
ExplanationBlockFactory(
text=replace_whitespace(
"""
Reflektiere dein Handeln und halte deine Erkenntnisse fest. Frage dich dabei:
"""
text=RichText(
"Reflektiere dein Handeln und halte deine Erkenntnisse fest. Frage dich dabei:"
)
),
),
(
"user_text_input",
UserTextInputBlockFactory(
text=replace_whitespace(
"""
War die Bearbeitung dieser geleiteten Fallarbeit erfolgreich? Begründe deine Einschätzung.
"""
text=RichText(
"War die Bearbeitung dieser geleiteten Fallarbeit erfolgreich? Begründe deine Einschätzung."
)
),
),
(
"user_text_input",
UserTextInputBlockFactory(
text=replace_whitespace(
"""
Was ist dir bei der Bearbeitung des Auftrags gut gelungen?
"""
text=RichText(
"Was ist dir bei der Bearbeitung des Auftrags gut gelungen?"
)
),
),
(
"user_text_input",
UserTextInputBlockFactory(
text=replace_whitespace(
"""
Was ist dir bei der Bearbeitung des Auftrags weniger gut gelungen?
"""
text=RichText(
"Was ist dir bei der Bearbeitung des Auftrags weniger gut gelungen?"
)
),
),
@ -249,30 +427,24 @@ def create_uk_assignments(course_id=COURSE_UK):
(
"explanation",
ExplanationBlockFactory(
text=replace_whitespace(
"""
Leite aus der Teilaufgabe 5 deine persönlichen Learnings ab.
"""
text=RichText(
"Leite aus der Teilaufgabe 5 deine persönlichen Learnings ab."
)
),
),
(
"user_text_input",
UserTextInputBlockFactory(
text=replace_whitespace(
"""
Was würdest du beim nächsten Mal anders machen?
"""
text=RichText(
"Was würdest du beim nächsten Mal anders machen?"
)
),
),
(
"user_text_input",
UserTextInputBlockFactory(
text=replace_whitespace(
"""
Was hast du beim Bearbeiten des Auftrags Neues gelernt?
"""
text=RichText(
"Was hast du beim Bearbeiten des Auftrags Neues gelernt?"
)
),
),
@ -284,6 +456,8 @@ def create_uk_assignments(course_id=COURSE_UK):
assignment.save()
return assignment
def create_test_assignment(course_id=COURSE_TEST_ID):
course_page = CoursePage.objects.get(course_id=course_id)
@ -318,8 +492,187 @@ def create_test_assignment(course_id=COURSE_TEST_ID):
),
),
],
assessment_document_url="https://www.vbv.ch",
assessment_description="Diese geleitete Fallarbeit wird auf Grund des folgenden Beurteilungsintrument bewertet.",
evaluation_document_url="/static/media/assignments/UK_03_09_NACH_KN_Beurteilungsraster.pdf",
evaluation_description="Diese geleitete Fallarbeit wird auf Grund des folgenden Beurteilungsintrument bewertet.",
)
assignment.evaluation_tasks = []
assignment.evaluation_tasks.append(
(
"task",
EvaluationTaskBlockFactory(
title="Ausgangslage des Auftrags",
description=RichText(
"Beschreibt der/die Lernende die Ausgangslage des Auftrags vollständig?"
),
max_points=6,
sub_tasks=ListValue(
ListBlock(EvaluationSubTaskBlock()),
values=[
EvaluationSubTaskBlockFactory(
title="Die Ausgangslage des Auftrag ist vollständig beschrieben.",
description=RichText(
replace_whitespace(
"""
<ul>
<li>Worum geht es? Was ist die Aufgabe?</li>
<li>Sind das Kundenprofil und die Kundenbeziehung vollständig und nachvollziehbar dargestellt?</li>
<li>Ist das Alter des Fahrzeugs dokumentiert?</li>
<li>Welche Ressourcen stehen zur Verfügung?</li>
</ul>
"""
)
),
points=6,
),
EvaluationSubTaskBlockFactory(
title="Die Ausgangslage ist grösstenteils vollständig beschrieben.",
points=4,
),
EvaluationSubTaskBlockFactory(
title="Die Ausgangslage ist unvollständig - nur 2 Punkte wurden beschrieben.",
points=2,
),
EvaluationSubTaskBlockFactory(
title="Die Ausgangslage des Auftrag ist unvollständig - es fehlen mehr als 2 Punkte in der Beschreibung.",
points=0,
),
],
),
),
),
)
assignment.evaluation_tasks.append(
(
"task",
EvaluationTaskBlockFactory(
title="Inhaltsanalyse und Struktur",
max_points=6,
description=RichText(
"Sind die Deckungen der Police vollständig und nachvollziehbar dokumentiert?"
),
sub_tasks=ListValue(
ListBlock(EvaluationSubTaskBlock()),
values=[
EvaluationSubTaskBlockFactory(
title="Die Analyse beinhaltet alle in der Police vorhandenen Deckungen und ist logisch aufgebaut.",
points=6,
),
EvaluationSubTaskBlockFactory(
title="Die Analyse beinhaltet die meisten vorhandenen Deckungen in der Police und ist grösstenteils logisch aufgebaut.",
points=4,
),
EvaluationSubTaskBlockFactory(
title="Die Analyse ist unvollständig (es fehlen mehr als 3 Deckungen) und der rote Faden ist nicht erkennbar.",
points=2,
),
EvaluationSubTaskBlockFactory(
title="Die Analyse ist insgesamt nicht nachvollziehbar und es fehlen einige Deckungen.",
points=0,
),
],
),
),
),
)
assignment.evaluation_tasks.append(
(
"task",
EvaluationTaskBlockFactory(
title="Sinnvolle Empfehlungen",
max_points=6,
description=RichText(
"Leitet die lernende Person sinnvolle und geeignete Empfehlungen ab?"
),
sub_tasks=ListValue(
ListBlock(EvaluationSubTaskBlock()),
values=[
EvaluationSubTaskBlockFactory(
title="Die Empfehlungen sind durchgängig sinnvoll und nachvollziehbar begründet.",
points=6,
),
EvaluationSubTaskBlockFactory(
title="Die Empfehlungen sind grösstenteils sinnvoll und nachvollziehbar begründet.",
points=4,
),
EvaluationSubTaskBlockFactory(
title="Die Empfehlungen sind wenig sinnvoll und unvollständig begründet.",
points=2,
),
EvaluationSubTaskBlockFactory(
title="Die Empfehlungen sind weder sinnvoll nch nachvollziehbar begründet.",
points=0,
),
],
),
),
),
)
assignment.evaluation_tasks.append(
(
"task",
EvaluationTaskBlockFactory(
title="Qualität der Reflexion",
max_points=3,
description=RichText(
"Reflektiert die lernende Person die Durchführung der geleiteten Fallarbeit?"
),
sub_tasks=ListValue(
ListBlock(EvaluationSubTaskBlock()),
values=[
EvaluationSubTaskBlockFactory(
title="Die Reflexion bezieht sich auf die geleitete Fallarbeit und umfasst nachvollziehbare positive wie negative Aspekte.",
points=3,
),
EvaluationSubTaskBlockFactory(
title="Die Reflexion bezieht sich auf die geleitete Fallarbeit und umfasst grösstenteils nachvollziehbare positive wie negative Aspekte.",
points=2,
),
EvaluationSubTaskBlockFactory(
title="Die Reflexion ist unvollständig.",
points=1,
),
EvaluationSubTaskBlockFactory(
title="Die Reflexion bezieht sich nicht auf die geleitete Fallarbeit.",
points=0,
),
],
),
),
),
)
assignment.evaluation_tasks.append(
(
"task",
EvaluationTaskBlockFactory(
title="Eignung der Learnings",
max_points=3,
description=RichText(
"Leitet die lernende Person geeignete Learnings aus der Reflexion ab?"
),
sub_tasks=ListValue(
ListBlock(EvaluationSubTaskBlock()),
values=[
EvaluationSubTaskBlockFactory(
title="Die Learnings beziehen sich auf die geleitete Fallarbeit und sind inhaltlich sinnvoll.",
points=3,
),
EvaluationSubTaskBlockFactory(
title="Die Learnings beziehen sich grösstenteils auf die geleitete Fallarbeit und sind inhaltlich sinnvoll.",
points=2,
),
EvaluationSubTaskBlockFactory(
title="Die Learnings beziehen sich teilweise auf die geleitete Fallarbeit und sind inhaltlich wenig sinnvoll.",
points=1,
),
EvaluationSubTaskBlockFactory(
title="Die Learnings beziehen sich nicht auf die geleitete Fallarbeit.",
points=0,
),
],
),
),
),
)
assignment.tasks = []
@ -329,18 +682,24 @@ def create_test_assignment(course_id=COURSE_TEST_ID):
TaskBlockFactory(
title="Teilaufgabe 1: Beispiel einer Versicherungspolice finden",
# it is hard to create a StreamValue programmatically, we have to
# create a `StreamValue` manually. Ask the Daniel and/or Ramon
# create a `StreamValue` manually. Ask Daniel and/or Ramon
content=StreamValue(
TaskContentStreamBlock(),
stream_data=[
(
"explanation",
ExplanationBlockFactory(text="Dies ist ein Beispieltext."),
ExplanationBlockFactory(
text=RichText(
"Bitte jemand aus deiner Familie oder deinem Freundeskreis darum, dir seine/ihre Motorfahrzeugversicherungspolice zur Verfügung zu stellen."
)
),
),
(
"user_confirmation",
ExplanationBlockFactory(
text="Ja, ich habe Motorfahrzeugversicherungspolice von jemandem aus meiner Familie oder meinem Freundeskreis erhalten."
text=RichText(
"Ja, ich habe Motorfahrzeugversicherungspolice von jemandem aus meiner Familie oder meinem Freundeskreis erhalten."
)
),
),
],
@ -360,12 +719,16 @@ def create_test_assignment(course_id=COURSE_TEST_ID):
(
"explanation",
ExplanationBlockFactory(
text=replace_whitespace(
"""
Erläutere die Kundensituation und die Ausgangslage.
* Hast du alle Informationen, die du für den Policen-Check benötigst?
* Halte die wichtigsten Eckwerte des aktuellen Versicherungsverhältnisse in deiner Dokumentation fest (z.B wie lang wo versichert, Alter des Fahrzeugs, Kundenprofil, etc.)
"""
text=RichText(
replace_whitespace(
"""
Erläutere die Kundensituation und die Ausgangslage.
<ul>
<li>Hast du alle Informationen, die du für den Policen-Check benötigst?</li>
<li>Halte die wichtigsten Eckwerte des aktuellen Versicherungsverhältnisse in deiner Dokumentation fest (z.B wie lang wo versichert, Alter des Fahrzeugs, Kundenprofil, etc.</li>
</ul>
"""
)
)
),
),
@ -388,10 +751,8 @@ def create_test_assignment(course_id=COURSE_TEST_ID):
(
"explanation",
ExplanationBlockFactory(
text=replace_whitespace(
"""
Zeige nun detailliert auf, wie dein Kundenbeispiel momentan versichert ist.
"""
text=RichText(
"Zeige nun detailliert auf, wie dein Kundenbeispiel momentan versichert ist."
)
),
),
@ -413,40 +774,32 @@ def create_test_assignment(course_id=COURSE_TEST_ID):
(
"explanation",
ExplanationBlockFactory(
text=replace_whitespace(
"""
Erarbeite nun basierend auf deinen Erkenntnissen eine Empfehlung für die Person.
"""
text=RichText(
"Erarbeite nun basierend auf deinen Erkenntnissen eine Empfehlung für die Person."
)
),
),
(
"user_text_input",
UserTextInputBlockFactory(
text=replace_whitespace(
"""
Gibt es zusätzliche Deckungen, die du der Person empfehlen würdest? Begründe deine Empfehlung
"""
text=RichText(
"Gibt es zusätzliche Deckungen, die du der Person empfehlen würdest? Begründe deine Empfehlung"
)
),
),
(
"user_text_input",
UserTextInputBlockFactory(
text=replace_whitespace(
"""
Gibt es Deckungen, die du streichen würdest? Begründe deine Empfehlung.
"""
text=RichText(
"Gibt es Deckungen, die du streichen würdest? Begründe deine Empfehlung."
)
),
),
(
"user_text_input",
UserTextInputBlockFactory(
text=replace_whitespace(
"""
Wenn die Person gemäss deiner Einschätzung genau richtig versichert ist, argumentiere, warum dies der Fall ist.
"""
text=RichText(
"Wenn die Person gemäss deiner Einschätzung genau richtig versichert ist, argumentiere, warum dies der Fall ist."
)
),
),
@ -467,40 +820,32 @@ def create_test_assignment(course_id=COURSE_TEST_ID):
(
"explanation",
ExplanationBlockFactory(
text=replace_whitespace(
"""
Reflektiere dein Handeln und halte deine Erkenntnisse fest. Frage dich dabei:
"""
text=RichText(
"Reflektiere dein Handeln und halte deine Erkenntnisse fest. Frage dich dabei:"
)
),
),
(
"user_text_input",
UserTextInputBlockFactory(
text=replace_whitespace(
"""
War die Bearbeitung dieser geleiteten Fallarbeit erfolgreich? Begründe deine Einschätzung.
"""
text=RichText(
"War die Bearbeitung dieser geleiteten Fallarbeit erfolgreich? Begründe deine Einschätzung."
)
),
),
(
"user_text_input",
UserTextInputBlockFactory(
text=replace_whitespace(
"""
Was ist dir bei der Bearbeitung des Auftrags gut gelungen?
"""
text=RichText(
"Was ist dir bei der Bearbeitung des Auftrags gut gelungen?"
)
),
),
(
"user_text_input",
UserTextInputBlockFactory(
text=replace_whitespace(
"""
Was ist dir bei der Bearbeitung des Auftrags weniger gut gelungen?
"""
text=RichText(
"Was ist dir bei der Bearbeitung des Auftrags weniger gut gelungen?"
)
),
),
@ -521,30 +866,24 @@ def create_test_assignment(course_id=COURSE_TEST_ID):
(
"explanation",
ExplanationBlockFactory(
text=replace_whitespace(
"""
Leite aus der Teilaufgabe 5 deine persönlichen Learnings ab.
"""
text=RichText(
"Leite aus der Teilaufgabe 5 deine persönlichen Learnings ab."
)
),
),
(
"user_text_input",
UserTextInputBlockFactory(
text=replace_whitespace(
"""
Was würdest du beim nächsten Mal anders machen?
"""
text=RichText(
"Was würdest du beim nächsten Mal anders machen?"
)
),
),
(
"user_text_input",
UserTextInputBlockFactory(
text=replace_whitespace(
"""
Was hast du beim Bearbeiten des Auftrags Neues gelernt?
"""
text=RichText(
"Was hast du beim Bearbeiten des Auftrags Neues gelernt?"
)
),
),

View File

@ -1,19 +1,20 @@
# Generated by Django 3.2.13 on 2023-04-11 09:30
# Generated by Django 3.2.13 on 2023-05-05 14:10
import django.db.models.deletion
import wagtail.blocks
import wagtail.fields
from django.conf import settings
from django.db import migrations, models
import vbv_lernwelt.assignment.models
class Migration(migrations.Migration):
initial = True
dependencies = [
("wagtailcore", "0069_log_entry_jsonfield"),
("wagtailcore", "0083_workflowcontenttype"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("course", "0006_alter_coursesession_attendance_days"),
]
operations = [
@ -33,7 +34,9 @@ class Migration(migrations.Migration):
),
(
"starting_position",
models.TextField(help_text="Erläuterung der Ausgangslage"),
wagtail.fields.RichTextField(
help_text="Erläuterung der Ausgangslage"
),
),
(
"effort_required",
@ -57,20 +60,6 @@ class Migration(migrations.Migration):
use_json_field=True,
),
),
(
"assessment_description",
models.TextField(
blank=True, help_text="Beschreibung der Bewertung"
),
),
(
"assessment_document_url",
models.CharField(
blank=True,
help_text="URL zum Beurteilungsinstrument",
max_length=255,
),
),
(
"tasks",
wagtail.fields.StreamField(
@ -94,14 +83,35 @@ class Migration(migrations.Migration):
[
(
"text",
wagtail.blocks.TextBlock(),
wagtail.blocks.RichTextBlock(
features=[
"ul",
"bold",
"italic",
]
),
)
]
),
),
(
"user_text_input",
vbv_lernwelt.assignment.models.UserTextInputBlock(),
wagtail.blocks.StructBlock(
[
(
"text",
wagtail.blocks.RichTextBlock(
blank=True,
features=[
"ul",
"bold",
"italic",
],
required=False,
),
)
]
),
),
(
"user_confirmation",
@ -109,7 +119,13 @@ class Migration(migrations.Migration):
[
(
"text",
wagtail.blocks.TextBlock(),
wagtail.blocks.RichTextBlock(
features=[
"ul",
"bold",
"italic",
]
),
)
]
),
@ -127,6 +143,78 @@ class Migration(migrations.Migration):
use_json_field=True,
),
),
(
"evaluation_description",
wagtail.fields.RichTextField(
blank=True, help_text="Beschreibung der Bewertung"
),
),
(
"evaluation_document_url",
models.CharField(
blank=True,
help_text="URL zum Beurteilungsinstrument",
max_length=255,
),
),
(
"evaluation_tasks",
wagtail.fields.StreamField(
[
(
"task",
wagtail.blocks.StructBlock(
[
("title", wagtail.blocks.TextBlock()),
(
"description",
wagtail.blocks.RichTextBlock(
blank=True,
features=["ul", "bold", "italic"],
required=False,
),
),
("max_points", wagtail.blocks.IntegerBlock()),
(
"sub_tasks",
wagtail.blocks.ListBlock(
wagtail.blocks.StructBlock(
[
(
"title",
wagtail.blocks.TextBlock(),
),
(
"description",
wagtail.blocks.RichTextBlock(
blank=True,
features=[
"ul",
"bold",
"italic",
],
required=False,
),
),
(
"points",
wagtail.blocks.IntegerBlock(),
),
]
),
blank=True,
use_json_field=True,
),
),
]
),
)
],
blank=True,
help_text="Beurteilungsschritte",
use_json_field=True,
),
),
],
options={
"verbose_name": "Auftrag",
@ -153,4 +241,155 @@ class Migration(migrations.Migration):
},
bases=("wagtailcore.page",),
),
migrations.CreateModel(
name="AssignmentCompletionAuditLog",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"completion_status",
models.CharField(
choices=[
(1, "in_progress"),
(2, "submitted"),
(3, "evaluation_in_progress"),
(4, "evaluation_submitted"),
],
default="in_progress",
max_length=255,
),
),
("completion_data", models.JSONField(default=dict)),
("additional_json_data", models.JSONField(default=dict)),
("assignment_user_email", models.CharField(max_length=255)),
("assignment_slug", models.CharField(max_length=255)),
(
"evaluation_user_email",
models.CharField(blank=True, default="", max_length=255),
),
("evaluation_grade", models.FloatField(blank=True, null=True)),
("evaluation_points", models.FloatField(blank=True, null=True)),
(
"assignment",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="assignment.assignment",
),
),
(
"assignment_user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
(
"course_session",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="course.coursesession",
),
),
(
"evaluation_user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.CreateModel(
name="AssignmentCompletion",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("submitted_at", models.DateTimeField(blank=True, null=True)),
(
"evaluation_submitted_at",
models.DateTimeField(blank=True, null=True),
),
("evaluation_grade", models.FloatField(blank=True, null=True)),
("evaluation_points", models.FloatField(blank=True, null=True)),
(
"completion_status",
models.CharField(
choices=[
(1, "in_progress"),
(2, "submitted"),
(3, "evaluation_in_progress"),
(4, "evaluation_submitted"),
],
default="in_progress",
max_length=255,
),
),
("completion_data", models.JSONField(default=dict)),
("additional_json_data", models.JSONField(default=dict)),
(
"assignment",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="assignment.assignment",
),
),
(
"assignment_user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
(
"course_session",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="course.coursesession",
),
),
(
"evaluation_user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.AddConstraint(
model_name="assignmentcompletion",
constraint=models.UniqueConstraint(
fields=("assignment_user", "assignment", "course_session"),
name="assignment_completion_unique_user_assignment_course_session",
),
),
]

View File

@ -1,161 +0,0 @@
# Generated by Django 3.2.13 on 2023-04-25 06:49
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("course", "0004_coursesession_assignment_details_list"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("assignment", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="AssignmentCompletionAuditLog",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"completion_status",
models.CharField(
choices=[
(1, "in_progress"),
(2, "submitted"),
(3, "grading_in_progress"),
(4, "graded"),
],
default="in_progress",
max_length=255,
),
),
("completion_data", models.JSONField(default=dict)),
("additional_json_data", models.JSONField(default=dict)),
("assignment_user_email", models.CharField(max_length=255)),
("assignment_slug", models.CharField(max_length=255)),
(
"grading_user_email",
models.CharField(blank=True, default="", max_length=255),
),
(
"assignment",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="assignment.assignment",
),
),
(
"assignment_user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
(
"course_session",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="course.coursesession",
),
),
(
"grading_user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.CreateModel(
name="AssignmentCompletion",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("submitted_at", models.DateTimeField(blank=True, null=True)),
("graded_at", models.DateTimeField(blank=True, null=True)),
(
"completion_status",
models.CharField(
choices=[
(1, "in_progress"),
(2, "submitted"),
(3, "grading_in_progress"),
(4, "graded"),
],
default="in_progress",
max_length=255,
),
),
("completion_data", models.JSONField(default=dict)),
("additional_json_data", models.JSONField(default=dict)),
(
"assignment",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="assignment.assignment",
),
),
(
"assignment_user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
(
"course_session",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="course.coursesession",
),
),
(
"grading_user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.AddConstraint(
model_name="assignmentcompletion",
constraint=models.UniqueConstraint(
fields=("assignment_user", "assignment", "course_session"),
name="assignment_completion_unique_user_assignment_course_session",
),
),
]

View File

@ -5,9 +5,10 @@ from django.db.models import UniqueConstraint
from slugify import slugify
from wagtail import blocks
from wagtail.admin.panels import FieldPanel
from wagtail.fields import StreamField
from wagtail.fields import RichTextField, StreamField
from wagtail.models import Page
from vbv_lernwelt.core.constants import DEFAULT_RICH_TEXT_FEATURES
from vbv_lernwelt.core.model_utils import find_available_slug
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseBasePage
@ -28,12 +29,8 @@ class AssignmentListPage(CourseBasePage):
return f"{self.title}"
# class AssignmentSubmission(modModel):
# created_at = models.DateTimeField(auto_now_add=True)
class ExplanationBlock(blocks.StructBlock):
text = blocks.TextBlock()
text = blocks.RichTextBlock(features=DEFAULT_RICH_TEXT_FEATURES)
class Meta:
icon = "comment"
@ -47,14 +44,16 @@ class PerformanceObjectiveBlock(blocks.StructBlock):
class UserTextInputBlock(blocks.StructBlock):
text = blocks.TextBlock(blank=True)
text = blocks.RichTextBlock(
blank=True, required=False, features=DEFAULT_RICH_TEXT_FEATURES
)
class Meta:
icon = "edit"
class UserConfirmationBlock(blocks.StructBlock):
text = blocks.TextBlock()
text = blocks.RichTextBlock(features=DEFAULT_RICH_TEXT_FEATURES)
class Meta:
icon = "tick-inverse"
@ -78,17 +77,47 @@ class TaskBlock(blocks.StructBlock):
label = "Teilauftrag"
class EvaluationSubTaskBlock(blocks.StructBlock):
title = blocks.TextBlock()
description = blocks.RichTextBlock(
blank=True, required=False, features=DEFAULT_RICH_TEXT_FEATURES
)
points = blocks.IntegerBlock()
class Meta:
icon = "tick"
label = "Beurteilung"
class EvaluationTaskBlock(blocks.StructBlock):
title = blocks.TextBlock()
description = blocks.RichTextBlock(
blank=True, required=False, features=DEFAULT_RICH_TEXT_FEATURES
)
max_points = blocks.IntegerBlock()
sub_tasks = blocks.ListBlock(
EvaluationSubTaskBlock(), blank=True, use_json_field=True
)
class Meta:
icon = "tasks"
label = "Beurteilungskriterium"
class Assignment(CourseBasePage):
serialize_field_names = [
"starting_position",
"effort_required",
"performance_objectives",
"assessment_description",
"assessment_document_url",
"evaluation_description",
"evaluation_document_url",
"tasks",
"evaluation_tasks",
]
starting_position = models.TextField(help_text="Erläuterung der Ausgangslage")
starting_position = RichTextField(
help_text="Erläuterung der Ausgangslage", features=DEFAULT_RICH_TEXT_FEATURES
)
effort_required = models.CharField(
max_length=100, help_text="Zeitaufwand als Text", blank=True
)
@ -101,14 +130,6 @@ class Assignment(CourseBasePage):
blank=True,
help_text="Leistungsziele des Auftrags",
)
assessment_description = models.TextField(
blank=True, help_text="Beschreibung der Bewertung"
)
assessment_document_url = models.CharField(
max_length=255,
blank=True,
help_text="URL zum Beurteilungsinstrument",
)
tasks = StreamField(
[
@ -119,13 +140,34 @@ class Assignment(CourseBasePage):
help_text="Teilaufgaben",
)
evaluation_description = RichTextField(
blank=True,
help_text="Beschreibung der Bewertung",
features=DEFAULT_RICH_TEXT_FEATURES,
)
evaluation_document_url = models.CharField(
max_length=255,
blank=True,
help_text="URL zum Beurteilungsinstrument",
)
evaluation_tasks = StreamField(
[
("task", EvaluationTaskBlock()),
],
use_json_field=True,
blank=True,
help_text="Beurteilungsschritte",
)
content_panels = Page.content_panels + [
FieldPanel("starting_position"),
FieldPanel("effort_required"),
FieldPanel("performance_objectives"),
FieldPanel("assessment_description"),
FieldPanel("assessment_document_url"),
FieldPanel("tasks"),
FieldPanel("evaluation_description"),
FieldPanel("evaluation_document_url"),
FieldPanel("evaluation_tasks"),
]
subpage_types = []
@ -172,10 +214,16 @@ class Assignment(CourseBasePage):
if sub_dict["type"] in subtask_types
]
def get_evaluation_tasks(self):
return [task for task in self.evaluation_tasks.raw_data]
def get_input_tasks(self):
return self.filter_user_subtasks() + self.get_evaluation_tasks()
AssignmentCompletionStatus = Enum(
"AssignmentCompletionStatus",
["in_progress", "submitted", "grading_in_progress", "graded"],
["in_progress", "submitted", "evaluation_in_progress", "evaluation_submitted"],
)
@ -184,14 +232,16 @@ class AssignmentCompletion(models.Model):
updated_at = models.DateTimeField(auto_now=True)
submitted_at = models.DateTimeField(null=True, blank=True)
graded_at = models.DateTimeField(null=True, blank=True)
grading_user = models.ForeignKey(
evaluation_submitted_at = models.DateTimeField(null=True, blank=True)
evaluation_user = models.ForeignKey(
User,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="+",
)
evaluation_grade = models.FloatField(null=True, blank=True)
evaluation_points = models.FloatField(null=True, blank=True)
assignment_user = models.ForeignKey(User, on_delete=models.CASCADE)
assignment = models.ForeignKey(Assignment, on_delete=models.CASCADE)
@ -217,12 +267,12 @@ class AssignmentCompletion(models.Model):
class AssignmentCompletionAuditLog(models.Model):
"""
This model is used to store the "submitted" and "graded" data separately
This model is used to store the "submitted" and "evaluation_submitted" data separately
"""
created_at = models.DateTimeField(auto_now_add=True)
grading_user = models.ForeignKey(
evaluation_user = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True, related_name="+"
)
assignment_user = models.ForeignKey(
@ -246,4 +296,6 @@ class AssignmentCompletionAuditLog(models.Model):
assignment_user_email = models.CharField(max_length=255)
assignment_slug = models.CharField(max_length=255)
grading_user_email = models.CharField(max_length=255, blank=True, default="")
evaluation_user_email = models.CharField(max_length=255, blank=True, default="")
evaluation_grade = models.FloatField(null=True, blank=True)
evaluation_points = models.FloatField(null=True, blank=True)

View File

@ -11,12 +11,14 @@ class AssignmentCompletionSerializer(serializers.ModelSerializer):
"created_at",
"updated_at",
"submitted_at",
"graded_at",
"evaluation_submitted_at",
"assignment_user",
"assignment",
"course_session",
"completion_status",
"completion_data",
"grading_user",
"evaluation_user",
"additional_json_data",
"evaluation_grade",
"evaluation_points",
]

View File

@ -21,7 +21,9 @@ def update_assignment_completion(
course_session: CourseSession,
completion_data=None,
completion_status: Type[AssignmentCompletionStatus] = "in_progress",
grading_user: User | None = None,
evaluation_user: User | None = None,
evaluation_grade: float | None = None,
evaluation_points: float | None = None,
validate_completion_status_change: bool = True,
copy_task_data: bool = False,
) -> AssignmentCompletion:
@ -40,7 +42,7 @@ def update_assignment_completion(
},
}
:param copy_task_data: if true, the task data will be copied to the completion data
used for "submitted" and "graded" status, so that we don't lose the question
used for "submitted" and "evaluation_submitted" status, so that we don't lose the question
context
:return: AssignmentCompletion
"""
@ -56,31 +58,61 @@ def update_assignment_completion(
if validate_completion_status_change:
# TODO: check time?
if completion_status == "submitted":
if ac.completion_status in ["submitted", "grading_in_progress", "graded"]:
if ac.completion_status in [
"submitted",
"evaluation_in_progress",
"evaluation_submitted",
]:
raise serializers.ValidationError(
{
"completion_status": f"Cannot update completion status from {ac.completion_status} to submitted"
}
)
elif completion_status == "graded":
if ac.completion_status == "graded":
elif completion_status == "evaluation_submitted":
if ac.completion_status == "evaluation_submitted":
raise serializers.ValidationError(
{
"completion_status": f"Cannot update completion status from {ac.completion_status} to graded"
"completion_status": f"Cannot update completion status from {ac.completion_status} to evaluation_submitted"
}
)
if completion_status in ["graded", "grading_in_progress"]:
if grading_user is None:
if completion_status == "in_progress" and ac.completion_status != "in_progress":
raise serializers.ValidationError(
{"grading_user": "grading_user is required for graded status"}
{
"completion_status": f"Cannot set completion status to in_progress when it is {ac.completion_status}"
}
)
ac.grading_user = grading_user
if completion_status in ["evaluation_submitted", "evaluation_in_progress"]:
if evaluation_user is None:
raise serializers.ValidationError(
{
"evaluation_user": "evaluation_user is required for evaluation_submitted status"
}
)
ac.evaluation_user = evaluation_user
if completion_status == "evaluation_submitted":
if evaluation_grade is None:
raise serializers.ValidationError(
{
"evaluation_grade": "evaluation_grade is required for evaluation_submitted status"
}
)
if evaluation_points is None:
raise serializers.ValidationError(
{
"evaluation_points": "evaluation_points is required for evaluation_submitted status"
}
)
ac.evaluation_grade = evaluation_grade
ac.evaluation_points = evaluation_points
if completion_status == "submitted":
ac.submitted_at = timezone.now()
elif completion_status == "graded":
ac.graded_at = timezone.now()
elif completion_status == "evaluation_submitted":
ac.evaluation_submitted_at = timezone.now()
ac.completion_status = completion_status
@ -94,32 +126,34 @@ def update_assignment_completion(
if copy_task_data:
# copy over the question data, so that we don't lose the context
substasks = assignment.filter_user_subtasks()
substasks = assignment.get_input_tasks()
for key, value in ac.completion_data.items():
task_data = find_first(substasks, pred=lambda x: x["id"] == key)
ac.completion_data[key].update(task_data)
if task_data:
ac.completion_data[key].update(task_data)
ac.save()
if completion_status in ["graded", "submitted"]:
if completion_status in ["evaluation_submitted", "submitted"]:
acl = AssignmentCompletionAuditLog.objects.create(
assignment_user=assignment_user,
assignment=assignment,
course_session=course_session,
grading_user=grading_user,
evaluation_user=evaluation_user,
completion_status=completion_status,
assignment_user_email=assignment_user.email,
assignment_slug=assignment.slug,
completion_data=deepcopy(ac.completion_data),
)
if grading_user:
acl.grading_user_email = grading_user.email
if evaluation_user:
acl.evaluation_user_email = evaluation_user.email
# copy over the question data, so that we don't lose the context
substasks = assignment.filter_user_subtasks()
subtasks = assignment.get_input_tasks()
for key, value in acl.completion_data.items():
task_data = find_first(substasks, pred=lambda x: x["id"] == key)
acl.completion_data[key].update(task_data)
task_data = find_first(subtasks, pred=lambda x: x["id"] == key)
if task_data:
acl.completion_data[key].update(task_data)
acl.save()
return ac
@ -129,12 +163,8 @@ def _remove_unknown_entries(assignment, completion_data):
"""
Removes all entries from completion_data which are not known to the assignment
"""
possible_subtask_uuids = [
subtask["id"] for subtask in assignment.filter_user_subtasks()
]
input_task_ids = [task["id"] for task in assignment.get_input_tasks()]
filtered_completion_data = {
key: value
for key, value in completion_data.items()
if key in possible_subtask_uuids
key: value for key, value in completion_data.items() if key in input_task_ids
}
return filtered_completion_data

View File

@ -1,9 +1,12 @@
import wagtail_factories
from factory import SubFactory
from wagtail.rich_text import RichText
from vbv_lernwelt.assignment.models import (
Assignment,
AssignmentListPage,
EvaluationSubTaskBlock,
EvaluationTaskBlock,
ExplanationBlock,
PerformanceObjectiveBlock,
TaskBlock,
@ -15,14 +18,16 @@ from vbv_lernwelt.core.utils import replace_whitespace
class ExplanationBlockFactory(wagtail_factories.StructBlockFactory):
text = "Dies ist ein Beispieltext."
text = RichText("Dies ist ein Beispieltext.")
class Meta:
model = ExplanationBlock
class UserConfirmationBlockFactory(wagtail_factories.StructBlockFactory):
text = "Ja, ich habe Motorfahrzeugversicherungspolice von jemandem aus meiner Familie oder meinem Freundeskreis erhalten."
text = RichText(
"Ja, ich habe Motorfahrzeugversicherungspolice von jemandem aus meiner Familie oder meinem Freundeskreis erhalten."
)
class Meta:
model = UserConfirmationBlock
@ -50,6 +55,24 @@ class TaskBlockFactory(wagtail_factories.StructBlockFactory):
model = TaskBlock
class EvaluationSubTaskBlockFactory(wagtail_factories.StructBlockFactory):
title = "Beurteilung"
description = RichText("")
points = 6
class Meta:
model = EvaluationSubTaskBlock
class EvaluationTaskBlockFactory(wagtail_factories.StructBlockFactory):
title = "Beurteilungskriterum"
description = RichText("")
max_points = 6
class Meta:
model = EvaluationTaskBlock
class PerformanceObjectiveBlockFactory(wagtail_factories.StructBlockFactory):
text = "Die Teilnehmer können die wichtigsten Eckwerte eines Versicherungsverhältnisses erfassen."

View File

@ -173,7 +173,7 @@ class AssignmentApiTestCase(APITestCase):
# make api call
self.client.login(username="admin", password="test")
url = f"/api/assignment/grade/"
url = f"/api/assignment/evaluate/"
response = self.client.post(
url,
@ -181,7 +181,7 @@ class AssignmentApiTestCase(APITestCase):
"assignment_id": self.assignment.id,
"assignment_user_id": self.student.id,
"course_session_id": self.cs.id,
"completion_status": "grading_in_progress",
"completion_status": "evaluation_in_progress",
"completion_data": {
user_text_input["id"]: {
"expert_data": {"points": 1, "comment": "Gut gemacht!"}
@ -196,7 +196,7 @@ class AssignmentApiTestCase(APITestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response_json["assignment_user"], self.student.id)
self.assertEqual(response_json["assignment"], self.assignment.id)
self.assertEqual(response_json["completion_status"], "grading_in_progress")
self.assertEqual(response_json["completion_status"], "evaluation_in_progress")
self.assertDictEqual(
response_json["completion_data"],
{
@ -212,7 +212,7 @@ class AssignmentApiTestCase(APITestCase):
course_session_id=self.cs.id,
assignment_id=self.assignment.id,
)
self.assertEqual(db_entry.completion_status, "grading_in_progress")
self.assertEqual(db_entry.completion_status, "evaluation_in_progress")
self.assertDictEqual(
db_entry.completion_data,
{
@ -230,12 +230,14 @@ class AssignmentApiTestCase(APITestCase):
"assignment_id": self.assignment.id,
"assignment_user_id": self.student.id,
"course_session_id": self.cs.id,
"completion_status": "graded",
"completion_status": "evaluation_submitted",
"completion_data": {
user_text_input["id"]: {
"expert_data": {"points": 1, "comment": "Gut gemacht!"}
},
},
"evaluation_grade": 4.5,
"evaluation_points": 16,
},
format="json",
)
@ -245,7 +247,7 @@ class AssignmentApiTestCase(APITestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response_json["assignment_user"], self.student.id)
self.assertEqual(response_json["assignment"], self.assignment.id)
self.assertEqual(response_json["completion_status"], "graded")
self.assertEqual(response_json["completion_status"], "evaluation_submitted")
self.assertDictEqual(
response_json["completion_data"],
{
@ -261,7 +263,7 @@ class AssignmentApiTestCase(APITestCase):
course_session_id=self.cs.id,
assignment_id=self.assignment.id,
)
self.assertEqual(db_entry.completion_status, "graded")
self.assertEqual(db_entry.completion_status, "evaluation_submitted")
self.assertDictEqual(
db_entry.completion_data,
{
@ -272,12 +274,12 @@ class AssignmentApiTestCase(APITestCase):
},
)
# `graded` will create a new AssignmentCompletionAuditLog
# `evaluation_submitted` will create a new AssignmentCompletionAuditLog
acl = AssignmentCompletionAuditLog.objects.get(
assignment_user=self.student,
course_session_id=self.cs.id,
assignment_id=self.assignment.id,
completion_status="graded",
completion_status="evaluation_submitted",
)
self.maxDiff = None
self.assertDictEqual(

View File

@ -296,7 +296,42 @@ class UpdateAssignmentCompletionTestCase(TestCase):
)
)
def test_can_add_grading_data_without_loosing_user_input_data(self):
def test_can_evaluate_with_evaluation_tasks(self):
ac = AssignmentCompletion.objects.create(
assignment_user=self.user,
assignment=self.assignment,
course_session=self.course_session,
completion_status="submitted",
)
evaluation_task = self.assignment.get_evaluation_tasks()[0]
update_assignment_completion(
assignment_user=self.user,
assignment=self.assignment,
course_session=self.course_session,
completion_data={
evaluation_task["id"]: {
"expert_data": {"points": 2, "text": "Gut gemacht!"}
},
},
completion_status="evaluation_in_progress",
evaluation_user=self.trainer,
)
ac = AssignmentCompletion.objects.get(
assignment_user=self.user,
assignment=self.assignment,
course_session=self.course_session,
)
self.assertEqual(ac.completion_status, "evaluation_in_progress")
trainer_input = ac.completion_data[evaluation_task["id"]]
self.assertDictEqual(
trainer_input["expert_data"], {"points": 2, "text": "Gut gemacht!"}
)
def test_can_add_evaluation_data_without_loosing_user_input_data(self):
subtasks = self.assignment.filter_user_subtasks(
subtask_types=["user_text_input"]
)
@ -329,8 +364,8 @@ class UpdateAssignmentCompletionTestCase(TestCase):
"expert_data": {"points": 1, "comment": "Gut gemacht!"}
},
},
completion_status="grading_in_progress",
grading_user=self.trainer,
completion_status="evaluation_in_progress",
evaluation_user=self.trainer,
)
ac = AssignmentCompletion.objects.get(
@ -339,7 +374,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
course_session=self.course_session,
)
self.assertEqual(ac.completion_status, "grading_in_progress")
self.assertEqual(ac.completion_status, "evaluation_in_progress")
user_input = ac.completion_data[user_text_input["id"]]
self.assertDictEqual(
user_input["expert_data"], {"points": 1, "comment": "Gut gemacht!"}
@ -348,7 +383,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
user_input["user_data"]["text"], "Ich würde nichts weiteres empfehlen."
)
def test_cannot_grading_data_without_grading_user(self):
def test_cannot_evaluate_data_without_evaluation_user(self):
subtasks = self.assignment.filter_user_subtasks(
subtask_types=["user_text_input"]
)
@ -377,7 +412,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
assignment_user=self.user,
assignment=self.assignment,
course_session=self.course_session,
completion_status="grading_in_progress",
completion_status="evaluation_in_progress",
completion_data={
user_text_input["id"]: {
"expert_data": {"points": 1, "comment": "Gut gemacht!"}
@ -386,5 +421,122 @@ class UpdateAssignmentCompletionTestCase(TestCase):
)
self.assertTrue(
"grading_user" in error.exception.detail,
"evaluation_user" in error.exception.detail,
)
def test_can_submit_evaluation(self):
subtasks = self.assignment.filter_user_subtasks(
subtask_types=["user_text_input"]
)
user_text_input = find_first(
subtasks,
pred=lambda x: (value := x.get("value"))
and value.get("text", "").startswith(
"Gibt es zusätzliche Deckungen, die du der Person empfehlen würdest?"
),
)
ac = AssignmentCompletion.objects.create(
assignment_user=self.user,
assignment=self.assignment,
course_session=self.course_session,
completion_status="submitted",
completion_data={
user_text_input["id"]: {
"user_data": {"text": "Ich würde nichts weiteres empfehlen."}
},
},
)
evaluation_task = self.assignment.get_evaluation_tasks()[0]
update_assignment_completion(
assignment_user=self.user,
assignment=self.assignment,
course_session=self.course_session,
completion_data={
evaluation_task["id"]: {
"expert_data": {"points": 2, "text": "Gut gemacht!"}
},
},
completion_status="evaluation_in_progress",
evaluation_user=self.trainer,
)
with self.assertRaises(serializers.ValidationError) as error:
# not setting grade will raise an error
update_assignment_completion(
assignment_user=self.user,
assignment=self.assignment,
course_session=self.course_session,
completion_data={},
completion_status="evaluation_submitted",
evaluation_user=self.trainer,
evaluation_grade=None,
evaluation_points=None,
)
update_assignment_completion(
assignment_user=self.user,
assignment=self.assignment,
course_session=self.course_session,
completion_data={},
completion_status="evaluation_submitted",
evaluation_user=self.trainer,
evaluation_grade=4.5,
evaluation_points=16,
)
ac = AssignmentCompletion.objects.get(
assignment_user=self.user,
assignment=self.assignment,
course_session=self.course_session,
)
self.assertEqual(ac.completion_status, "evaluation_submitted")
self.assertEqual(ac.evaluation_grade, 4.5)
self.assertEqual(ac.evaluation_points, 16)
trainer_input = ac.completion_data[evaluation_task["id"]]
self.assertDictEqual(
trainer_input["expert_data"], {"points": 2, "text": "Gut gemacht!"}
)
user_input = ac.completion_data[user_text_input["id"]]
self.assertDictEqual(
user_input["user_data"], {"text": "Ich würde nichts weiteres empfehlen."}
)
# will create AssignmentCompletionAuditLog entry
acl = AssignmentCompletionAuditLog.objects.get(
assignment_user=self.user,
assignment=self.assignment,
course_session=self.course_session,
completion_status="evaluation_submitted",
)
self.assertEqual(acl.created_at.date(), date.today())
self.assertEqual(acl.assignment_user_email, "student")
self.assertEqual(
acl.assignment_slug,
"versicherungsvermittler-in-assignment-überprüfen-einer-motorfahrzeugs-versicherungspolice",
)
trainer_input = acl.completion_data[evaluation_task["id"]]
self.assertDictEqual(
trainer_input["expert_data"], {"points": 2, "text": "Gut gemacht!"}
)
user_input = acl.completion_data[user_text_input["id"]]
self.assertDictEqual(
user_input["user_data"], {"text": "Ich würde nichts weiteres empfehlen."}
)
# AssignmentCompletionAuditLog entry will remain event after deletion of foreign keys
ac.delete()
self.user.delete()
self.assignment.delete()
acl = AssignmentCompletionAuditLog.objects.get(id=acl.id)
self.assertEqual(acl.created_at.date(), date.today())
self.assertEqual(acl.assignment_user_email, "student")
self.assertEqual(
acl.assignment_slug,
"versicherungsvermittler-in-assignment-überprüfen-einer-motorfahrzeugs-versicherungspolice",
)
self.assertIsNone(acl.assignment_user)
self.assertIsNone(acl.assignment)

View File

@ -64,6 +64,19 @@ def request_assignment_completion_for_user(
raise PermissionDenied()
@api_view(["GET"])
def request_assignment_completion_status(request, assignment_id, course_session_id):
# TODO quickfix before GraphQL...
if is_course_session_expert(request.user, course_session_id):
qs = AssignmentCompletion.objects.filter(
course_session_id=course_session_id,
assignment_id=assignment_id,
).values("id", "assignment_user_id", "completion_status", "evaluation_grade")
return Response(status=200, data=qs)
raise PermissionDenied()
@api_view(["POST"])
def upsert_user_assignment_completion(request):
try:
@ -107,13 +120,17 @@ def upsert_user_assignment_completion(request):
@api_view(["POST"])
def grade_assignment_completion(request):
def evaluate_assignment_completion(request):
try:
assignment_id = request.data.get("assignment_id")
assignment_user_id = request.data.get("assignment_user_id")
course_session_id = request.data.get("course_session_id")
completion_status = request.data.get("completion_status", "grading_in_progress")
completion_status = request.data.get(
"completion_status", "evaluation_in_progress"
)
completion_data = request.data.get("completion_data", {})
evaluation_grade = request.data.get("evaluation_grade", None)
evaluation_points = request.data.get("evaluation_grade", None)
assignment_page = Page.objects.get(id=assignment_id)
assignment_user = User.objects.get(id=assignment_user_id)
@ -133,7 +150,9 @@ def grade_assignment_completion(request):
completion_data=completion_data,
completion_status=completion_status,
copy_task_data=False,
grading_user=request.user,
evaluation_user=request.user,
evaluation_grade=evaluation_grade,
evaluation_points=evaluation_points,
)
logger.debug(
@ -144,7 +163,9 @@ def grade_assignment_completion(request):
assignment_user_id=assignment_user_id,
course_session_id=course_session_id,
completion_status=completion_status,
grading_user_id=request.user.id,
evaluation_user_id=request.user.id,
evaluation_grade=evaluation_grade,
evaluation_points=evaluation_points,
)
return Response(status=200, data=AssignmentCompletionSerializer(ac).data)

View File

@ -0,0 +1,5 @@
DEFAULT_RICH_TEXT_FEATURES = [
"ul",
"bold",
"italic",
]

View File

@ -176,6 +176,7 @@ def create_default_users(user_model=User, group_model=Group, default_password=No
first_name="Lina",
last_name="Egger",
avatar_url="/static/avatars/uk1.lina.egger.jpg",
password="myvbv1234",
)
_create_student_user(
email="evelyn.schmid@example.com",
@ -229,6 +230,23 @@ def create_default_users(user_model=User, group_model=Group, default_password=No
language="fr",
)
# users for cypress tests
_create_student_user(
email="test-trainer1@example.com",
first_name="Test",
last_name="Trainer1",
)
_create_student_user(
email="test-student1@example.com",
first_name="Test",
last_name="Student1",
)
_create_student_user(
email="test-student2@example.com",
first_name="Test",
last_name="Student2",
)
def _get_or_create_user(user_model, *args, **kwargs):
username = kwargs.get("username", None)

View File

@ -1,5 +1,6 @@
import djclick as click
from vbv_lernwelt.assignment.models import AssignmentCompletion
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseCompletion
from vbv_lernwelt.notify.models import Notification
@ -10,4 +11,5 @@ def command():
print("cypress reset data")
CourseCompletion.objects.all().delete()
Notification.objects.all().delete()
AssignmentCompletion.objects.all().delete()
User.objects.all().update(language="de")

View File

@ -142,7 +142,7 @@ def cypress_reset_view(request):
if settings.APP_ENVIRONMENT != "production":
call_command("cypress_reset")
return HttpResponseRedirect("/admin/")
return HttpResponseRedirect("/server/admin/")
@django_view_authentication_exempt

View File

@ -13,10 +13,18 @@ from vbv_lernwelt.competence.factories import (
PerformanceCriteriaFactory,
)
from vbv_lernwelt.competence.models import CompetencePage
from vbv_lernwelt.core.models import User
from vbv_lernwelt.core.tests.helpers import create_locales_for_wagtail
from vbv_lernwelt.course.consts import COURSE_TEST_ID
from vbv_lernwelt.course.factories import CoursePageFactory
from vbv_lernwelt.course.models import Course, CourseCategory, CoursePage, CourseSession
from vbv_lernwelt.course.models import (
Course,
CourseCategory,
CoursePage,
CourseSession,
CourseSessionUser,
)
from vbv_lernwelt.learnpath.models import Circle
from vbv_lernwelt.learnpath.tests.learning_path_factories import (
AssignmentBlockFactory,
AttendanceDayBlockFactory,
@ -55,15 +63,40 @@ def create_test_course(include_uk=True, include_vv=True, with_sessions=False):
if with_sessions:
# course sessions
CourseSession.objects.create(
cs_bern = CourseSession.objects.create(
course_id=COURSE_TEST_ID,
title="Bern 2022 a",
)
CourseSession.objects.create(
cs_zurich = CourseSession.objects.create(
course_id=COURSE_TEST_ID,
title="Zürich 2022 a",
)
trainer1 = User.objects.get(email="test-trainer1@example.com")
csu = CourseSessionUser.objects.create(
course_session=cs_bern,
user=trainer1,
role=CourseSessionUser.Role.EXPERT,
)
csu.expert.add(Circle.objects.get(slug="test-lehrgang-lp-circle-fahrzeug"))
student1 = User.objects.get(email="test-student1@example.com")
_csu = CourseSessionUser.objects.create(
course_session=cs_bern,
user=student1,
)
student2 = User.objects.get(email="test-student2@example.com")
_csu = CourseSessionUser.objects.create(
course_session=cs_bern,
user=student2,
)
student2 = User.objects.get(email="test-student2@example.com")
_csu = CourseSessionUser.objects.create(
course_session=cs_zurich,
user=student2,
)
return course

View File

@ -69,7 +69,7 @@ def command(course):
slug="überbetriebliche-kurse-assignment-überprüfen-einer-motorfahrzeugs-versicherungspolice"
),
course_session=CourseSession.objects.get(title="Bern 2023 a"),
user=User.objects.get(email="michael.meier@example.com"),
user=User.objects.get(email="lina.egger@example.com"),
)
if COURSE_UK_FR in course:
@ -166,7 +166,8 @@ def create_course_uk_de():
"learningContentId": LearningContent.objects.get(
slug="überbetriebliche-kurse-lp-circle-fahrzeug-lc-überprüfen-einer-motorfahrzeug-versicherungspolice"
).id,
"deadlineDateTimeUtc": "2023-05-30T19:00:00Z",
"submissionDeadlineDateTimeUtc": "2023-06-13T19:00:00Z",
"evaluationDeadlineDateTimeUtc": "2023-06-27T19:00:00Z",
}
],
)
@ -320,6 +321,12 @@ def create_course_uk_de_assignment_completion_data(assignment, course_session, u
}
},
)
update_assignment_completion(
assignment_user=user,
assignment=assignment,
course_session=course_session,
completion_status="submitted",
)
def create_course_uk_de_completion_data(course_session):

View File

@ -0,0 +1,19 @@
# Generated by Django 3.2.13 on 2023-04-26 16:28
import django_jsonform.models.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("course", "0005_alter_coursesession_attendance_days"),
]
operations = [
migrations.AlterField(
model_name="coursesession",
name="attendance_days",
field=django_jsonform.models.fields.JSONField(blank=True, default=list),
),
]

View File

@ -149,12 +149,9 @@ def get_course_sessions(request):
@api_view(["GET"])
def get_course_session_users(request, course_slug):
def get_course_session_users(request, course_session_id):
try:
course_sessions = course_sessions_for_user_qs(request.user).filter(
course__slug=course_slug
)
qs = CourseSessionUser.objects.filter(course_session__in=course_sessions)
qs = CourseSessionUser.objects.filter(course_session_id=course_session_id)
user_data = [csu.to_dict() for csu in qs]
return Response(status=200, data=user_data)

View File

@ -9,10 +9,6 @@
{% csrf_token %}
<button class="btn" name="">Testdaten zurück setzen</button>
</form>
<form action="/api/core/schemareset/" method="post">
{% csrf_token %}
<button class="btn" name="">Datenbank zurück setzen</button>
</form>
</div>
</div>