Merged in feature/VBV-597-umsetzung-cockpit-lernbegleitung (pull request #248)

Cockpit & Management Lernbegleitung

Approved-by: Daniel Egger
This commit is contained in:
Reto Aebersold 2023-12-22 09:50:49 +00:00 committed by Daniel Egger
commit cbc89b7641
98 changed files with 3414 additions and 436 deletions

View File

@ -0,0 +1,54 @@
<script setup lang="ts">
import type { RouteLocationRaw } from "vue-router";
import { computed } from "vue";
const props = defineProps<{
taskTitle: string;
circleTitle: string;
pendingTasks: number;
pendingTasksLabel: string;
taskLink: RouteLocationRaw;
taskLinkLabel: string;
taskLinkPendingLabel: string;
}>();
const linkLabel = computed(() => {
if (!props.taskLinkPendingLabel) {
return props.taskLinkLabel;
}
return props.pendingTasks > 0 ? props.taskLinkPendingLabel : props.taskLinkLabel;
});
const hasPendingTasks = computed(() => props.pendingTasks > 0);
</script>
<template>
<div
class="flex flex-col items-start justify-between gap-4 border-b py-2 pl-5 pr-5 last:border-b-0 md:flex-row md:items-center md:justify-between md:gap-16"
>
<div class="flex flex-grow flex-row items-center justify-start">
<div class="w-80">
<div class="font-bold">{{ taskTitle }}</div>
<div class="text-small text-gray-900">
{{ $t("a.Circle") }} «{{ circleTitle }}»
</div>
</div>
<div class="flex flex-grow flex-row items-center justify-start space-x-2 pl-20">
<template v-if="hasPendingTasks">
<div
class="flex h-7 w-7 items-center justify-center rounded-full border-2 border-green-500 px-3 py-1 text-sm font-bold"
>
<span>{{ pendingTasks }}</span>
</div>
<span>{{ pendingTasksLabel }}</span>
</template>
</div>
<router-link
:class="[hasPendingTasks ? 'btn-primary' : 'underline']"
:to="taskLink"
>
{{ linkLabel }}
</router-link>
</div>
</div>
</template>

View File

@ -0,0 +1,23 @@
<script setup lang="ts">
import AssignmentItem from "@/components/cockpit/mentor/AssignmentItem.vue";
import type { RouteLocationRaw } from "vue-router";
defineProps<{
taskTitle: string;
circleTitle: string;
pendingTasks: number;
taskLink: RouteLocationRaw;
}>();
</script>
<template>
<AssignmentItem
:task-title="`${$t('a.Praxisauftrag')}: ${taskTitle}`"
:circle-title="circleTitle"
:pending-tasks="pendingTasks"
:task-link="taskLink"
:pending-tasks-label="$t('a.Ergebnisse abgegeben')"
:task-link-pending-label="$t('a.Ergebnisse bewerten')"
:task-link-label="$t('a.Praxisaufträge anschauen')"
/>
</template>

View File

@ -18,9 +18,12 @@ import CoursePreviewBar from "@/components/header/CoursePreviewBar.vue";
import {
getCockpitUrl,
getCompetenceNaviUrl,
getLearningMentorManagementUrl,
getLearningPathUrl,
getMediaCenterUrl,
} from "@/utils/utils";
import { useCockpitStore } from "@/stores/cockpit";
import { VV_COURSE_IDS } from "@/constants";
log.debug("MainNavigationBar created");
@ -31,6 +34,7 @@ const notificationsStore = useNotificationsStore();
const {
inCockpit,
inCompetenceProfile,
inLearningMentor,
inCourse,
inLearningPath,
inMediaLibrary,
@ -66,6 +70,42 @@ const appointmentsUrl = computed(() => {
onMounted(() => {
log.debug("MainNavigationBar mounted");
});
const hasMediaLibraryMenu = computed(() => {
if (useCockpitStore().hasMentorCockpitType) {
return false;
}
return inCourse() && Boolean(courseSessionsStore.currentCourseSession);
});
const hasCockpitMenu = computed(() => {
return courseSessionsStore.currentCourseSessionHasCockpit;
});
const hasPreviewMenu = computed(() => {
return useCockpitStore().hasExpertCockpitType;
});
const hasAppointmentsMenu = computed(() => {
if (useCockpitStore().hasMentorCockpitType) {
return false;
}
return userStore.loggedIn;
});
const hasNotificationsMenu = computed(() => {
return userStore.loggedIn;
});
const hasMentorManagementMenu = computed(() => {
if (courseSessionsStore.currentCourseSessionHasCockpit) {
return false;
}
// learning mentor management is only available for VV courses
const currentCourseId = courseSessionsStore.currentCourseSession?.course.id || "";
return inCourse() && VV_COURSE_IDS.includes(currentCourseId);
});
</script>
<template>
@ -76,6 +116,9 @@ onMounted(() => {
v-if="userStore.loggedIn"
:show="state.showMobileNavigationMenu"
:course-session="courseSessionsStore.currentCourseSession"
:has-media-library-menu="hasMediaLibraryMenu"
:has-cockpit-menu="hasCockpitMenu"
:has-preview-menu="hasPreviewMenu"
:media-url="
getMediaCenterUrl(courseSessionsStore.currentCourseSession?.course?.slug)
"
@ -135,6 +178,7 @@ onMounted(() => {
<div class="hidden space-x-8 lg:flex">
<template v-if="courseSessionsStore.currentCourseSessionHasCockpit">
<router-link
v-if="hasCockpitMenu"
data-cy="navigation-cockpit-link"
:to="
getCockpitUrl(
@ -148,6 +192,7 @@ onMounted(() => {
</router-link>
<router-link
v-if="hasPreviewMenu"
data-cy="navigation-preview-link"
:to="
getLearningPathUrl(
@ -189,6 +234,20 @@ onMounted(() => {
>
{{ t("competences.title") }}
</router-link>
<router-link
v-if="hasMentorManagementMenu"
data-cy="navigation-learning-mentor-link"
:to="
getLearningMentorManagementUrl(
courseSessionsStore.currentCourseSession.course.slug
)
"
class="nav-item"
:class="{ 'nav-item--active': inLearningMentor() }"
>
{{ t("a.Lernbegleitung") }}
</router-link>
</template>
</div>
</template>
@ -196,10 +255,10 @@ onMounted(() => {
<div class="flex items-stretch justify-start space-x-8">
<router-link
v-if="inCourse() && courseSessionsStore.currentCourseSession"
v-if="hasMediaLibraryMenu"
:to="
getMediaCenterUrl(
courseSessionsStore.currentCourseSession.course.slug
courseSessionsStore.currentCourseSession?.course.slug
)
"
data-cy="medialibrary-link"
@ -210,7 +269,7 @@ onMounted(() => {
</router-link>
<router-link
v-if="userStore.loggedIn"
v-if="hasAppointmentsMenu"
:to="appointmentsUrl"
data-cy="all-duedates-link"
class="nav-item"
@ -220,7 +279,7 @@ onMounted(() => {
</router-link>
<!-- Notification Bell & Menu -->
<div v-if="userStore.loggedIn" class="nav-item leading-none">
<div v-if="hasNotificationsMenu" class="nav-item leading-none">
<NotificationPopover>
<template #toggleButtonContent>
<div class="relative h-8 w-8">

View File

@ -15,6 +15,9 @@ const router = useRouter();
defineProps<{
show: boolean;
hasMediaLibraryMenu: boolean;
hasPreviewMenu: boolean;
hasCockpitMenu: boolean;
courseSession: CourseSession | undefined;
mediaUrl?: string;
user: UserState | undefined;
@ -62,7 +65,7 @@ const courseSessionsStore = useCourseSessionsStore();
<h4 class="text-sm text-gray-900">{{ courseSession.course.title }}</h4>
<ul class="mt-6">
<template v-if="courseSessionsStore.currentCourseSessionHasCockpit">
<li class="mb-6">
<li v-if="hasCockpitMenu" class="mb-6">
<button
data-cy="navigation-mobile-cockpit-link"
@click="clickLink(getCockpitUrl(courseSession.course.slug))"
@ -70,7 +73,7 @@ const courseSessionsStore = useCourseSessionsStore();
{{ $t("cockpit.title") }}
</button>
</li>
<li class="mb-6">
<li v-if="hasPreviewMenu" class="mb-6">
<button
data-cy="navigation-mobile-preview-link"
@click="clickLink(getLearningPathUrl(courseSession.course.slug))"
@ -97,7 +100,7 @@ const courseSessionsStore = useCourseSessionsStore();
</button>
</li>
</template>
<li class="mb-6">
<li v-if="hasMediaLibraryMenu" class="mb-6">
<button
data-cy="medialibrary-link"
@click="clickLink(getMediaCenterUrl(courseSession.course.slug))"
@ -110,9 +113,7 @@ const courseSessionsStore = useCourseSessionsStore();
<div class="mt-6 border-b">
<ul>
<li class="mb-6">
<button data-cy="medialibrary-link" @click="clickLink('/')">
myVBV
</button>
<button data-cy="dashboard-link" @click="clickLink('/')">myVBV</button>
</li>
</ul>
</div>

View File

@ -0,0 +1,121 @@
<script setup lang="ts">
import ItCheckbox from "@/components/ui/ItCheckbox.vue";
import { computed, reactive } from "vue";
import type { CourseSessionUserObjectsType } from "@/gql/graphql";
import dayjs from "dayjs";
import DateEmbedding from "@/components/dueDates/DateEmbedding.vue";
import ItButton from "@/components/ui/ItButton.vue";
import { useMutation } from "@urql/vue";
import { UPSERT_ASSIGNMENT_COMPLETION_MUTATION } from "@/graphql/mutations";
import { useUserStore } from "@/stores/user";
import { bustItGetCache } from "@/fetchHelpers";
import eventBus from "@/utils/eventBus";
import log from "loglevel";
import type { Assignment } from "@/types";
const props = defineProps<{
evaluationDocumentUrl: string;
submissionDeadlineStart?: string | null;
circleExpert?: CourseSessionUserObjectsType;
courseSessionId: string;
assignment: Assignment;
learningContentId: string;
}>();
const state = reactive({
confirmInput: false,
confirmPerson: false,
});
const circleExpertName = computed(() => {
return `${props.circleExpert?.first_name} ${props.circleExpert?.last_name}`;
});
const cannotSubmit = computed(() => {
return !state.confirmInput || !state.confirmPerson;
});
const upsertAssignmentCompletionMutation = useMutation(
UPSERT_ASSIGNMENT_COMPLETION_MUTATION
);
const onSubmit = async () => {
try {
await upsertAssignmentCompletionMutation.executeMutation({
assignmentId: props.assignment.id,
courseSessionId: props.courseSessionId,
learningContentId: props.learningContentId,
completionDataString: JSON.stringify({}),
completionStatus: "SUBMITTED",
});
bustItGetCache(
`/api/course/completion/${props.courseSessionId}/${useUserStore().id}/`
);
// if solution sample is available, do not close the assigment automatically
if (!props.assignment.solution_sample) {
eventBus.emit("finishedLearningContent", true);
}
} catch (error) {
log.error("Could not submit assignment", error);
}
};
</script>
<template>
<div>
<ItCheckbox
class="w-full border-b border-gray-400 py-10 sm:py-6"
:checkbox-item="{
label: $t('assignment.confirmSubmitResults'),
value: 'value',
checked: state.confirmInput,
}"
data-cy="confirm-submit-results"
@toggle="state.confirmInput = !state.confirmInput"
></ItCheckbox>
<div class="w-full border-b border-gray-400">
<ItCheckbox
class="py-6"
:checkbox-item="{
label: $t('assignment.confirmSubmitPerson'),
value: 'value',
checked: state.confirmPerson,
}"
data-cy="confirm-submit-person"
@toggle="state.confirmPerson = !state.confirmPerson"
></ItCheckbox>
<div v-if="circleExpert" class="flex flex-row items-center pb-6 pl-[49px]">
<img
alt="Notification icon"
class="mr-2 h-[45px] min-w-[45px] rounded-full"
:src="circleExpert.avatar_url"
/>
<p class="text-base font-bold">
{{ circleExpertName }}
</p>
</div>
</div>
<div class="flex flex-col space-x-2 pt-6 text-base sm:flex-row">
<p>{{ $t("assignment.assessmentDocumentDisclaimer") }}</p>
<a :href="evaluationDocumentUrl" class="underline">
{{ $t("assignment.showAssessmentDocument") }}
</a>
</div>
<p v-if="submissionDeadlineStart" class="pt-6">
{{ $t("assignment.dueDateSubmission") }}
<DateEmbedding
:single-date="dayjs(props.submissionDeadlineStart)"
></DateEmbedding>
</p>
<ItButton
class="mt-6"
variant="blue"
size="large"
:disabled="cannotSubmit"
data-cy="submit-assignment"
@click="onSubmit"
>
<p>{{ $t("assignment.submitAssignment") }}</p>
</ItButton>
</div>
</template>

View File

@ -0,0 +1,125 @@
<script setup lang="ts">
import ItButton from "@/components/ui/ItButton.vue";
import ItCheckbox from "@/components/ui/ItCheckbox.vue";
import { ref } from "vue";
import { bustItGetCache, useCSRFFetch } from "@/fetchHelpers";
import { useUserStore } from "@/stores/user";
import eventBus from "@/utils/eventBus";
import log from "loglevel";
import dayjs from "dayjs";
import { useMutation } from "@urql/vue";
import { UPSERT_ASSIGNMENT_COMPLETION_MUTATION } from "@/graphql/mutations";
import type { Assignment } from "@/types";
import DateEmbedding from "@/components/dueDates/DateEmbedding.vue";
import { useCurrentCourseSession } from "@/composables";
const currentCourseSession = useCurrentCourseSession();
const props = defineProps<{
submissionDeadlineStart?: string | null;
courseSessionId: string;
assignment: Assignment;
learningContentId: string;
}>();
const confirmPerson = ref(false);
const upsertAssignmentCompletionMutation = useMutation(
UPSERT_ASSIGNMENT_COMPLETION_MUTATION
);
const { data: learningMentors } = useCSRFFetch(
`/api/mentor/${props.courseSessionId}/mentors`
).json();
const selectedLearningMentor = ref();
const onSubmit = async () => {
try {
await upsertAssignmentCompletionMutation.executeMutation({
assignmentId: props.assignment.id,
courseSessionId: props.courseSessionId,
learningContentId: props.learningContentId,
completionDataString: JSON.stringify({}),
completionStatus: "SUBMITTED",
evaluationUserId: selectedLearningMentor.value,
});
bustItGetCache(
`/api/course/completion/${props.courseSessionId}/${useUserStore().id}/`
);
// if solution sample is available, do not close the assigment automatically
if (!props.assignment.solution_sample) {
eventBus.emit("finishedLearningContent", true);
}
} catch (error) {
log.error("Could not submit assignment", error);
}
};
</script>
<template>
<div class="w-full border-b border-gray-400">
<div v-if="learningMentors?.length" class="my-6">
<ItCheckbox
class="py-6"
:checkbox-item="{
label: $t('a.confirmSubmitPersonPraxisAssignment'),
value: 'value',
checked: confirmPerson,
}"
data-cy="confirm-submit-person"
@toggle="confirmPerson = !confirmPerson"
></ItCheckbox>
<div>
<select v-model="selectedLearningMentor" data-cy="select-learning-mentor">
<option
v-for="learningMentor in learningMentors"
:key="learningMentor.id"
:value="learningMentor.mentor.id"
>
{{ learningMentor.mentor.first_name }} {{ learningMentor.mentor.last_name }}
</option>
</select>
</div>
</div>
<div v-else class="my-6">
<div class="flex space-x-2 bg-sky-200 p-4">
<it-icon-info class="it-icon h-6 w-6 text-sky-700" />
<div>
<div class="mb-4">
{{
$t(
"a.Aktuell hast du noch keine Person als Lernbegleitung eingeladen. Lade jetzt jemanden ein."
)
}}
</div>
<router-link
:to="{
name: 'learningMentorManagement',
params: { courseSlug: currentCourseSession.course.slug },
}"
class="btn-blue px-4 py-2 font-bold"
>
{{ $t("a.Lernbegleitung einladen") }}
</router-link>
</div>
</div>
</div>
</div>
<p v-if="props.submissionDeadlineStart" class="pt-6">
{{ $t("assignment.dueDateSubmission") }}
<DateEmbedding :single-date="dayjs(props.submissionDeadlineStart)"></DateEmbedding>
</p>
<ItButton
class="mt-6"
variant="primary"
size="large"
:disabled="!confirmPerson || !selectedLearningMentor"
data-cy="submit-assignment"
@click="onSubmit"
>
<p>{{ $t("a.Ergebnisse teilen") }}</p>
</ItButton>
</template>

View File

@ -0,0 +1,68 @@
<script setup lang="ts">
import ItButton from "@/components/ui/ItButton.vue";
import ItCheckbox from "@/components/ui/ItCheckbox.vue";
import { ref } from "vue";
import { bustItGetCache } from "@/fetchHelpers";
import { useUserStore } from "@/stores/user";
import eventBus from "@/utils/eventBus";
import log from "loglevel";
import { useMutation } from "@urql/vue";
import { UPSERT_ASSIGNMENT_COMPLETION_MUTATION } from "@/graphql/mutations";
import type { Assignment } from "@/types";
const props = defineProps<{
courseSessionId: string;
assignment: Assignment;
learningContentId: string;
}>();
const upsertAssignmentCompletionMutation = useMutation(
UPSERT_ASSIGNMENT_COMPLETION_MUTATION
);
const confirmInput = ref(false);
const onSubmit = async () => {
try {
await upsertAssignmentCompletionMutation.executeMutation({
assignmentId: props.assignment.id,
courseSessionId: props.courseSessionId,
learningContentId: props.learningContentId,
completionDataString: JSON.stringify({}),
completionStatus: "SUBMITTED",
});
bustItGetCache(
`/api/course/completion/${props.courseSessionId}/${useUserStore().id}/`
);
// if solution sample is available, do not close the assigment automatically
if (!props.assignment.solution_sample) {
eventBus.emit("finishedLearningContent", true);
}
} catch (error) {
log.error("Could not submit assignment", error);
}
};
</script>
<template>
<ItCheckbox
class="w-full border-b border-gray-400 py-10 sm:py-6"
:checkbox-item="{
label: $t('assignment.confirmSubmitResults'),
value: 'value',
checked: confirmInput,
}"
data-cy="confirm-submit-results"
@toggle="confirmInput = !confirmInput"
></ItCheckbox>
<ItButton
class="mt-6"
variant="blue"
size="large"
:disabled="!confirmInput"
data-cy="submit-assignment"
@click="onSubmit"
>
<p>{{ $t("assignment.submitAssignment") }}</p>
</ItButton>
</template>

View File

@ -3,3 +3,9 @@ export const itCheckboxDefaultIconCheckedTailwindClass =
export const itCheckboxDefaultIconUncheckedTailwindClass =
"bg-[url(/static/icons/icon-checkbox-unchecked.svg)] hover:bg-[url(/static/icons/icon-checkbox-unchecked-hover.svg)]";
export const VV_COURSE_IDS = [
"-4", // vv-de
"-10", // vv-fr
"-11", // vv-it
];

View File

@ -1,4 +1,5 @@
import { getCookieValue } from "@/router/guards";
import { createFetch } from "@vueuse/core";
class FetchError extends Error {
response: Response;
@ -92,3 +93,13 @@ export const itGetCached = (
return itGetPromiseCache.get(url.toString()) as Promise<any>;
};
export const useCSRFFetch = createFetch({
options: {
async beforeFetch({ options }) {
const headers = options.headers as Record<string, string>;
headers["X-CSRFToken"] = getCookieValue("csrftoken");
return { options };
},
},
});

View File

@ -14,13 +14,13 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-
*/
const documents = {
"\n mutation AttendanceCheckMutation(\n $attendanceCourseId: ID!\n $attendanceUserList: [AttendanceUserInputType]!\n ) {\n update_course_session_attendance_course_users(\n id: $attendanceCourseId\n attendance_user_list: $attendanceUserList\n ) {\n course_session_attendance_course {\n id\n attendance_user_list {\n user_id\n first_name\n last_name\n email\n status\n }\n }\n }\n }\n": types.AttendanceCheckMutationDocument,
"\n mutation UpsertAssignmentCompletion(\n $assignmentId: ID!\n $courseSessionId: ID!\n $learningContentId: ID\n $assignmentUserId: UUID\n $completionStatus: AssignmentCompletionStatus!\n $completionDataString: String!\n $evaluationPoints: Float\n $initializeCompletion: Boolean\n ) {\n upsert_assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n learning_content_page_id: $learningContentId\n assignment_user_id: $assignmentUserId\n completion_status: $completionStatus\n completion_data_string: $completionDataString\n evaluation_points: $evaluationPoints\n initialize_completion: $initializeCompletion\n ) {\n assignment_completion {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_points\n completion_data\n task_completion_data\n }\n }\n }\n": types.UpsertAssignmentCompletionDocument,
"\n mutation UpsertAssignmentCompletion(\n $assignmentId: ID!\n $courseSessionId: ID!\n $learningContentId: ID\n $assignmentUserId: UUID\n $completionStatus: AssignmentCompletionStatus!\n $completionDataString: String!\n $evaluationPoints: Float\n $initializeCompletion: Boolean\n $evaluationUserId: ID\n ) {\n upsert_assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n learning_content_page_id: $learningContentId\n assignment_user_id: $assignmentUserId\n completion_status: $completionStatus\n completion_data_string: $completionDataString\n evaluation_points: $evaluationPoints\n initialize_completion: $initializeCompletion\n evaluation_user_id: $evaluationUserId\n ) {\n assignment_completion {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_points\n completion_data\n task_completion_data\n }\n }\n }\n": types.UpsertAssignmentCompletionDocument,
"\n fragment CoursePageFields on CoursePageInterface {\n title\n id\n slug\n content_type\n frontend_url\n }\n": types.CoursePageFieldsFragmentDoc,
"\n query attendanceCheckQuery($courseSessionId: ID!) {\n course_session_attendance_course(id: $courseSessionId) {\n id\n attendance_user_list {\n user_id\n status\n }\n }\n }\n": types.AttendanceCheckQueryDocument,
"\n query assignmentCompletionQuery(\n $assignmentId: ID!\n $courseSessionId: ID!\n $learningContentId: ID\n $assignmentUserId: UUID\n ) {\n assignment(id: $assignmentId) {\n assignment_type\n needs_expert_evaluation\n max_points\n content_type\n effort_required\n evaluation_description\n evaluation_document_url\n evaluation_tasks\n id\n intro_text\n performance_objectives\n slug\n tasks\n title\n translation_key\n solution_sample {\n id\n url\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n assignment_user_id: $assignmentUserId\n learning_content_page_id: $learningContentId\n ) {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_user {\n id\n }\n assignment_user {\n id\n }\n evaluation_points\n evaluation_max_points\n evaluation_passed\n edoniq_extended_time_flag\n completion_data\n task_completion_data\n }\n }\n": types.AssignmentCompletionQueryDocument,
"\n query competenceCertificateQuery($courseSlug: String!, $courseSessionId: ID!) {\n competence_certificate_list(course_slug: $courseSlug) {\n ...CoursePageFields\n competence_certificates {\n ...CoursePageFields\n assignments {\n ...CoursePageFields\n assignment_type\n max_points\n completion(course_session_id: $courseSessionId) {\n id\n completion_status\n submitted_at\n evaluation_points\n evaluation_max_points\n evaluation_passed\n }\n learning_content {\n ...CoursePageFields\n circle {\n id\n title\n slug\n }\n }\n }\n }\n }\n }\n": types.CompetenceCertificateQueryDocument,
"\n query courseSessionDetail($courseSessionId: ID!) {\n course_session(id: $courseSessionId) {\n id\n title\n course {\n id\n title\n slug\n enable_circle_documents\n }\n users {\n id\n user_id\n first_name\n last_name\n email\n avatar_url\n role\n circles {\n id\n title\n slug\n }\n }\n attendance_courses {\n id\n location\n trainer\n due_date {\n id\n start\n end\n }\n learning_content_id\n learning_content {\n id\n title\n circle {\n id\n title\n slug\n }\n }\n }\n assignments {\n id\n submission_deadline {\n id\n start\n }\n evaluation_deadline {\n id\n start\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n edoniq_tests {\n id\n deadline {\n id\n start\n end\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n }\n }\n": types.CourseSessionDetailDocument,
"\n query courseQuery($slug: String!) {\n course(slug: $slug) {\n id\n title\n slug\n category_name\n enable_circle_documents\n action_competences {\n competence_id\n ...CoursePageFields\n performance_criteria {\n competence_id\n learning_unit {\n id\n slug\n evaluate_url\n }\n ...CoursePageFields\n }\n }\n learning_path {\n ...CoursePageFields\n topics {\n is_visible\n ...CoursePageFields\n circles {\n description\n goals\n ...CoursePageFields\n learning_sequences {\n icon\n ...CoursePageFields\n learning_units {\n evaluate_url\n ...CoursePageFields\n performance_criteria {\n ...CoursePageFields\n }\n learning_contents {\n can_user_self_toggle_course_completion\n content_url\n minutes\n description\n ...CoursePageFields\n ... on LearningContentAssignmentObjectType {\n assignment_type\n content_assignment {\n id\n assignment_type\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentEdoniqTestObjectType {\n checkbox_text\n has_extended_time_test\n content_assignment {\n id\n assignment_type\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentRichTextObjectType {\n text\n }\n }\n }\n }\n }\n }\n }\n }\n }\n": types.CourseQueryDocument,
"\n query courseSessionDetail($courseSessionId: ID!) {\n course_session(id: $courseSessionId) {\n id\n title\n course {\n id\n title\n slug\n enable_circle_documents\n circle_contact_type\n }\n users {\n id\n user_id\n first_name\n last_name\n email\n avatar_url\n role\n circles {\n id\n title\n slug\n }\n }\n attendance_courses {\n id\n location\n trainer\n due_date {\n id\n start\n end\n }\n learning_content_id\n learning_content {\n id\n title\n circle {\n id\n title\n slug\n }\n }\n }\n assignments {\n id\n submission_deadline {\n id\n start\n }\n evaluation_deadline {\n id\n start\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n edoniq_tests {\n id\n deadline {\n id\n start\n end\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n }\n }\n": types.CourseSessionDetailDocument,
"\n query courseQuery($slug: String!) {\n course(slug: $slug) {\n id\n title\n slug\n category_name\n enable_circle_documents\n circle_contact_type\n action_competences {\n competence_id\n ...CoursePageFields\n performance_criteria {\n competence_id\n learning_unit {\n id\n slug\n evaluate_url\n }\n ...CoursePageFields\n }\n }\n learning_path {\n ...CoursePageFields\n topics {\n is_visible\n ...CoursePageFields\n circles {\n description\n goals\n ...CoursePageFields\n learning_sequences {\n icon\n ...CoursePageFields\n learning_units {\n evaluate_url\n ...CoursePageFields\n performance_criteria {\n ...CoursePageFields\n }\n learning_contents {\n can_user_self_toggle_course_completion\n content_url\n minutes\n description\n ...CoursePageFields\n ... on LearningContentAssignmentObjectType {\n assignment_type\n content_assignment {\n id\n assignment_type\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentEdoniqTestObjectType {\n checkbox_text\n has_extended_time_test\n content_assignment {\n id\n assignment_type\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentRichTextObjectType {\n text\n }\n }\n }\n }\n }\n }\n }\n }\n }\n": types.CourseQueryDocument,
"\n query dashboardConfig {\n dashboard_config {\n id\n slug\n name\n dashboard_type\n }\n }\n": types.DashboardConfigDocument,
"\n query dashboardProgress($courseId: ID!) {\n course_progress(course_id: $courseId) {\n _id\n course_id\n session_to_continue_id\n competence {\n _id\n total_count\n success_count\n fail_count\n }\n assignment {\n _id\n total_count\n points_max_count\n points_achieved_count\n }\n }\n }\n": types.DashboardProgressDocument,
"\n query courseStatistics($courseId: ID!) {\n course_statistics(course_id: $courseId) {\n _id\n course_id\n course_title\n course_slug\n course_session_properties {\n _id\n sessions {\n id\n name\n }\n generations\n circles {\n id\n name\n }\n }\n course_session_selection_ids\n course_session_selection_metrics {\n _id\n session_count\n participant_count\n expert_count\n }\n attendance_day_presences {\n _id\n records {\n _id\n course_session_id\n generation\n circle_id\n due_date\n participants_present\n participants_total\n details_url\n }\n summary {\n _id\n days_completed\n participants_present\n }\n }\n feedback_responses {\n _id\n records {\n _id\n course_session_id\n generation\n circle_id\n experts\n satisfaction_average\n satisfaction_max\n details_url\n }\n summary {\n _id\n satisfaction_average\n satisfaction_max\n total_responses\n }\n }\n assignments {\n _id\n summary {\n _id\n completed_count\n average_passed\n }\n records {\n _id\n course_session_id\n course_session_assignment_id\n circle_id\n generation\n assignment_title\n assignment_type_translation_key\n details_url\n deadline\n metrics {\n _id\n passed_count\n failed_count\n unranked_count\n ranking_completed\n average_passed\n }\n }\n }\n competences {\n _id\n summary {\n _id\n success_total\n fail_total\n }\n records {\n _id\n course_session_id\n generation\n circle_id\n title\n success_count\n fail_count\n details_url\n }\n }\n }\n }\n": types.CourseStatisticsDocument,
@ -48,7 +48,7 @@ export function graphql(source: "\n mutation AttendanceCheckMutation(\n $att
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation UpsertAssignmentCompletion(\n $assignmentId: ID!\n $courseSessionId: ID!\n $learningContentId: ID\n $assignmentUserId: UUID\n $completionStatus: AssignmentCompletionStatus!\n $completionDataString: String!\n $evaluationPoints: Float\n $initializeCompletion: Boolean\n ) {\n upsert_assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n learning_content_page_id: $learningContentId\n assignment_user_id: $assignmentUserId\n completion_status: $completionStatus\n completion_data_string: $completionDataString\n evaluation_points: $evaluationPoints\n initialize_completion: $initializeCompletion\n ) {\n assignment_completion {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_points\n completion_data\n task_completion_data\n }\n }\n }\n"): (typeof documents)["\n mutation UpsertAssignmentCompletion(\n $assignmentId: ID!\n $courseSessionId: ID!\n $learningContentId: ID\n $assignmentUserId: UUID\n $completionStatus: AssignmentCompletionStatus!\n $completionDataString: String!\n $evaluationPoints: Float\n $initializeCompletion: Boolean\n ) {\n upsert_assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n learning_content_page_id: $learningContentId\n assignment_user_id: $assignmentUserId\n completion_status: $completionStatus\n completion_data_string: $completionDataString\n evaluation_points: $evaluationPoints\n initialize_completion: $initializeCompletion\n ) {\n assignment_completion {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_points\n completion_data\n task_completion_data\n }\n }\n }\n"];
export function graphql(source: "\n mutation UpsertAssignmentCompletion(\n $assignmentId: ID!\n $courseSessionId: ID!\n $learningContentId: ID\n $assignmentUserId: UUID\n $completionStatus: AssignmentCompletionStatus!\n $completionDataString: String!\n $evaluationPoints: Float\n $initializeCompletion: Boolean\n $evaluationUserId: ID\n ) {\n upsert_assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n learning_content_page_id: $learningContentId\n assignment_user_id: $assignmentUserId\n completion_status: $completionStatus\n completion_data_string: $completionDataString\n evaluation_points: $evaluationPoints\n initialize_completion: $initializeCompletion\n evaluation_user_id: $evaluationUserId\n ) {\n assignment_completion {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_points\n completion_data\n task_completion_data\n }\n }\n }\n"): (typeof documents)["\n mutation UpsertAssignmentCompletion(\n $assignmentId: ID!\n $courseSessionId: ID!\n $learningContentId: ID\n $assignmentUserId: UUID\n $completionStatus: AssignmentCompletionStatus!\n $completionDataString: String!\n $evaluationPoints: Float\n $initializeCompletion: Boolean\n $evaluationUserId: ID\n ) {\n upsert_assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n learning_content_page_id: $learningContentId\n assignment_user_id: $assignmentUserId\n completion_status: $completionStatus\n completion_data_string: $completionDataString\n evaluation_points: $evaluationPoints\n initialize_completion: $initializeCompletion\n evaluation_user_id: $evaluationUserId\n ) {\n assignment_completion {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_points\n completion_data\n task_completion_data\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@ -68,11 +68,11 @@ export function graphql(source: "\n query competenceCertificateQuery($courseSlu
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query courseSessionDetail($courseSessionId: ID!) {\n course_session(id: $courseSessionId) {\n id\n title\n course {\n id\n title\n slug\n enable_circle_documents\n }\n users {\n id\n user_id\n first_name\n last_name\n email\n avatar_url\n role\n circles {\n id\n title\n slug\n }\n }\n attendance_courses {\n id\n location\n trainer\n due_date {\n id\n start\n end\n }\n learning_content_id\n learning_content {\n id\n title\n circle {\n id\n title\n slug\n }\n }\n }\n assignments {\n id\n submission_deadline {\n id\n start\n }\n evaluation_deadline {\n id\n start\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n edoniq_tests {\n id\n deadline {\n id\n start\n end\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query courseSessionDetail($courseSessionId: ID!) {\n course_session(id: $courseSessionId) {\n id\n title\n course {\n id\n title\n slug\n enable_circle_documents\n }\n users {\n id\n user_id\n first_name\n last_name\n email\n avatar_url\n role\n circles {\n id\n title\n slug\n }\n }\n attendance_courses {\n id\n location\n trainer\n due_date {\n id\n start\n end\n }\n learning_content_id\n learning_content {\n id\n title\n circle {\n id\n title\n slug\n }\n }\n }\n assignments {\n id\n submission_deadline {\n id\n start\n }\n evaluation_deadline {\n id\n start\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n edoniq_tests {\n id\n deadline {\n id\n start\n end\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n }\n }\n"];
export function graphql(source: "\n query courseSessionDetail($courseSessionId: ID!) {\n course_session(id: $courseSessionId) {\n id\n title\n course {\n id\n title\n slug\n enable_circle_documents\n circle_contact_type\n }\n users {\n id\n user_id\n first_name\n last_name\n email\n avatar_url\n role\n circles {\n id\n title\n slug\n }\n }\n attendance_courses {\n id\n location\n trainer\n due_date {\n id\n start\n end\n }\n learning_content_id\n learning_content {\n id\n title\n circle {\n id\n title\n slug\n }\n }\n }\n assignments {\n id\n submission_deadline {\n id\n start\n }\n evaluation_deadline {\n id\n start\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n edoniq_tests {\n id\n deadline {\n id\n start\n end\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query courseSessionDetail($courseSessionId: ID!) {\n course_session(id: $courseSessionId) {\n id\n title\n course {\n id\n title\n slug\n enable_circle_documents\n circle_contact_type\n }\n users {\n id\n user_id\n first_name\n last_name\n email\n avatar_url\n role\n circles {\n id\n title\n slug\n }\n }\n attendance_courses {\n id\n location\n trainer\n due_date {\n id\n start\n end\n }\n learning_content_id\n learning_content {\n id\n title\n circle {\n id\n title\n slug\n }\n }\n }\n assignments {\n id\n submission_deadline {\n id\n start\n }\n evaluation_deadline {\n id\n start\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n edoniq_tests {\n id\n deadline {\n id\n start\n end\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query courseQuery($slug: String!) {\n course(slug: $slug) {\n id\n title\n slug\n category_name\n enable_circle_documents\n action_competences {\n competence_id\n ...CoursePageFields\n performance_criteria {\n competence_id\n learning_unit {\n id\n slug\n evaluate_url\n }\n ...CoursePageFields\n }\n }\n learning_path {\n ...CoursePageFields\n topics {\n is_visible\n ...CoursePageFields\n circles {\n description\n goals\n ...CoursePageFields\n learning_sequences {\n icon\n ...CoursePageFields\n learning_units {\n evaluate_url\n ...CoursePageFields\n performance_criteria {\n ...CoursePageFields\n }\n learning_contents {\n can_user_self_toggle_course_completion\n content_url\n minutes\n description\n ...CoursePageFields\n ... on LearningContentAssignmentObjectType {\n assignment_type\n content_assignment {\n id\n assignment_type\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentEdoniqTestObjectType {\n checkbox_text\n has_extended_time_test\n content_assignment {\n id\n assignment_type\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentRichTextObjectType {\n text\n }\n }\n }\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query courseQuery($slug: String!) {\n course(slug: $slug) {\n id\n title\n slug\n category_name\n enable_circle_documents\n action_competences {\n competence_id\n ...CoursePageFields\n performance_criteria {\n competence_id\n learning_unit {\n id\n slug\n evaluate_url\n }\n ...CoursePageFields\n }\n }\n learning_path {\n ...CoursePageFields\n topics {\n is_visible\n ...CoursePageFields\n circles {\n description\n goals\n ...CoursePageFields\n learning_sequences {\n icon\n ...CoursePageFields\n learning_units {\n evaluate_url\n ...CoursePageFields\n performance_criteria {\n ...CoursePageFields\n }\n learning_contents {\n can_user_self_toggle_course_completion\n content_url\n minutes\n description\n ...CoursePageFields\n ... on LearningContentAssignmentObjectType {\n assignment_type\n content_assignment {\n id\n assignment_type\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentEdoniqTestObjectType {\n checkbox_text\n has_extended_time_test\n content_assignment {\n id\n assignment_type\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentRichTextObjectType {\n text\n }\n }\n }\n }\n }\n }\n }\n }\n }\n"];
export function graphql(source: "\n query courseQuery($slug: String!) {\n course(slug: $slug) {\n id\n title\n slug\n category_name\n enable_circle_documents\n circle_contact_type\n action_competences {\n competence_id\n ...CoursePageFields\n performance_criteria {\n competence_id\n learning_unit {\n id\n slug\n evaluate_url\n }\n ...CoursePageFields\n }\n }\n learning_path {\n ...CoursePageFields\n topics {\n is_visible\n ...CoursePageFields\n circles {\n description\n goals\n ...CoursePageFields\n learning_sequences {\n icon\n ...CoursePageFields\n learning_units {\n evaluate_url\n ...CoursePageFields\n performance_criteria {\n ...CoursePageFields\n }\n learning_contents {\n can_user_self_toggle_course_completion\n content_url\n minutes\n description\n ...CoursePageFields\n ... on LearningContentAssignmentObjectType {\n assignment_type\n content_assignment {\n id\n assignment_type\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentEdoniqTestObjectType {\n checkbox_text\n has_extended_time_test\n content_assignment {\n id\n assignment_type\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentRichTextObjectType {\n text\n }\n }\n }\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query courseQuery($slug: String!) {\n course(slug: $slug) {\n id\n title\n slug\n category_name\n enable_circle_documents\n circle_contact_type\n action_competences {\n competence_id\n ...CoursePageFields\n performance_criteria {\n competence_id\n learning_unit {\n id\n slug\n evaluate_url\n }\n ...CoursePageFields\n }\n }\n learning_path {\n ...CoursePageFields\n topics {\n is_visible\n ...CoursePageFields\n circles {\n description\n goals\n ...CoursePageFields\n learning_sequences {\n icon\n ...CoursePageFields\n learning_units {\n evaluate_url\n ...CoursePageFields\n performance_criteria {\n ...CoursePageFields\n }\n learning_contents {\n can_user_self_toggle_course_completion\n content_url\n minutes\n description\n ...CoursePageFields\n ... on LearningContentAssignmentObjectType {\n assignment_type\n content_assignment {\n id\n assignment_type\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentEdoniqTestObjectType {\n checkbox_text\n has_extended_time_test\n content_assignment {\n id\n assignment_type\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentRichTextObjectType {\n text\n }\n }\n }\n }\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

File diff suppressed because one or more lines are too long

View File

@ -237,10 +237,20 @@ type CourseObjectType {
category_name: String!
slug: String!
enable_circle_documents: Boolean!
circle_contact_type: CourseCourseCircleContactTypeChoices!
learning_path: LearningPathObjectType!
action_competences: [ActionCompetenceObjectType!]!
}
"""An enumeration."""
enum CourseCourseCircleContactTypeChoices {
"""EXPERT"""
EXPERT
"""LEARNING_MENTOR"""
LEARNING_MENTOR
}
type ActionCompetenceObjectType implements CoursePageInterface {
competence_id: String!
id: ID!
@ -863,7 +873,7 @@ type CompetenceCertificateListObjectType implements CoursePageInterface {
type Mutation {
send_feedback(course_session_id: ID!, data: GenericScalar, learning_content_page_id: ID!, learning_content_type: String!, submitted: Boolean = false): SendFeedbackMutation
update_course_session_attendance_course_users(attendance_user_list: [AttendanceUserInputType]!, id: ID!): AttendanceCourseUserMutation
upsert_assignment_completion(assignment_id: ID!, assignment_user_id: UUID, completion_data_string: String, completion_status: AssignmentCompletionStatus, course_session_id: ID!, evaluation_passed: Boolean, evaluation_points: Float, initialize_completion: Boolean, learning_content_page_id: ID): AssignmentCompletionMutation
upsert_assignment_completion(assignment_id: ID!, assignment_user_id: UUID, completion_data_string: String, completion_status: AssignmentCompletionStatus, course_session_id: ID!, evaluation_passed: Boolean, evaluation_points: Float, evaluation_user_id: ID, initialize_completion: Boolean, learning_content_page_id: ID): AssignmentCompletionMutation
}
type SendFeedbackMutation {
@ -903,4 +913,4 @@ enum AssignmentCompletionStatus {
SUBMITTED
EVALUATION_IN_PROGRESS
EVALUATION_SUBMITTED
}
}

View File

@ -25,6 +25,7 @@ export const CompetenceRecordStatisticsType = "CompetenceRecordStatisticsType";
export const CompetencesStatisticsType = "CompetencesStatisticsType";
export const ContentDocumentObjectType = "ContentDocumentObjectType";
export const CoreUserLanguageChoices = "CoreUserLanguageChoices";
export const CourseCourseCircleContactTypeChoices = "CourseCourseCircleContactTypeChoices";
export const CourseObjectType = "CourseObjectType";
export const CoursePageInterface = "CoursePageInterface";
export const CourseProgressType = "CourseProgressType";

View File

@ -33,6 +33,7 @@ export const UPSERT_ASSIGNMENT_COMPLETION_MUTATION = graphql(`
$completionDataString: String!
$evaluationPoints: Float
$initializeCompletion: Boolean
$evaluationUserId: ID
) {
upsert_assignment_completion(
assignment_id: $assignmentId
@ -43,6 +44,7 @@ export const UPSERT_ASSIGNMENT_COMPLETION_MUTATION = graphql(`
completion_data_string: $completionDataString
evaluation_points: $evaluationPoints
initialize_completion: $initializeCompletion
evaluation_user_id: $evaluationUserId
) {
assignment_completion {
id

View File

@ -122,6 +122,7 @@ export const COURSE_SESSION_DETAIL_QUERY = graphql(`
title
slug
enable_circle_documents
circle_contact_type
}
users {
id
@ -206,6 +207,7 @@ export const COURSE_QUERY = graphql(`
slug
category_name
enable_circle_documents
circle_contact_type
action_competences {
competence_id
...CoursePageFields

View File

@ -1,55 +0,0 @@
<script setup lang="ts">
import {
useCourseDataWithCompletion,
useCourseSessionDetailQuery,
} from "@/composables";
import { useCockpitStore } from "@/stores/cockpit";
import * as log from "loglevel";
import { onMounted, ref } from "vue";
log.debug("CockpitParentPage created");
const props = defineProps<{
courseSlug: string;
}>();
const cockpitStore = useCockpitStore();
const courseSessionDetailResult = useCourseSessionDetailQuery();
const loaded = ref(false);
onMounted(async () => {
log.debug("CockpitParentPage mounted", props.courseSlug);
try {
await courseSessionDetailResult.waitForData();
await cockpitStore.loadCircles(
props.courseSlug,
courseSessionDetailResult.findCurrentUser()
);
// workaround so that the completion data is loaded before display
const userDataPromises = courseSessionDetailResult.filterMembers().map((m) => {
const completionData = useCourseDataWithCompletion(props.courseSlug, m.id);
return completionData.resultPromise;
});
await Promise.all(userDataPromises);
loaded.value = true;
} catch (error) {
log.error(error);
}
});
</script>
<template>
<div class="bg-gray-200">
<main>
<div v-if="loaded">
<router-view></router-view>
</div>
</main>
</div>
</template>
<style scoped></style>

View File

@ -3,6 +3,7 @@ import CirclePage from "@/pages/learningPath/circlePage/CirclePage.vue";
import * as log from "loglevel";
import { computed, onMounted } from "vue";
import { useCourseSessionDetailQuery } from "@/composables";
import { useExpertCockpitPageData } from "@/pages/cockpit/cockpitPage/composables";
const props = defineProps<{
userId: string;
@ -12,6 +13,8 @@ const props = defineProps<{
log.debug("CockpitUserCirclePage created", props.userId, props.circleSlug);
const { loading } = useExpertCockpitPageData(props.courseSlug);
onMounted(async () => {
log.debug("CockpitUserCirclePage mounted");
});
@ -25,7 +28,7 @@ const user = computed(() => {
<template>
<CirclePage
v-if="user"
v-if="user && !loading"
:course-slug="props.courseSlug"
:circle-slug="props.circleSlug"
:profile-user="user"

View File

@ -5,9 +5,10 @@ import { computed, onMounted } from "vue";
import CompetenceDetail from "@/pages/competence/ActionCompetenceDetail.vue";
import LearningPathPathView from "@/pages/learningPath/learningPathPage/LearningPathPathView.vue";
import {
useCourseSessionDetailQuery,
useCourseDataWithCompletion,
useCourseSessionDetailQuery,
} from "@/composables";
import { useExpertCockpitPageData } from "@/pages/cockpit/cockpitPage/composables";
const props = defineProps<{
userId: string;
@ -16,6 +17,8 @@ const props = defineProps<{
log.debug("CockpitUserProfilePage created", props.userId);
const { loading } = useExpertCockpitPageData(props.courseSlug);
const courseCompletionData = useCourseDataWithCompletion(
props.courseSlug,
props.userId
@ -43,7 +46,7 @@ function setActiveClasses(isActive: boolean) {
</script>
<template>
<div class="bg-gray-200">
<div v-if="!loading" class="bg-gray-200">
<div v-if="user" class="container-large">
<nav class="py-4 pb-4">
<router-link

View File

@ -1,5 +1,5 @@
<template>
<div class="bg-gray-200">
<div v-if="!loading" class="bg-gray-200">
<div class="container-large">
<nav class="py-4 pb-4">
<router-link
@ -36,6 +36,7 @@ import { onMounted, ref } from "vue";
import type { FeedbackData, FeedbackType } from "@/types";
import FeedbackPageVV from "@/pages/cockpit/FeedbackPageVV.vue";
import FeedbackPageUK from "@/pages/cockpit/FeedbackPageUK.vue";
import { useExpertCockpitPageData } from "@/pages/cockpit/cockpitPage/composables";
const props = defineProps<{
courseSlug: string;
@ -43,7 +44,7 @@ const props = defineProps<{
}>();
log.debug("FeedbackPage created", props.circleId);
const { loading } = useExpertCockpitPageData(props.courseSlug);
const courseSession = useCurrentCourseSession();
const feedbackData = ref<FeedbackData | undefined>(undefined);
const feedbackType = ref<FeedbackType | undefined>(undefined);

View File

@ -10,6 +10,7 @@ import { computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { getPreviousRoute } from "@/router/history";
import { getAssignmentTypeTitle } from "../../../utils/utils";
import { useExpertCockpitPageData } from "@/pages/cockpit/cockpitPage/composables";
const props = defineProps<{
courseSlug: string;
@ -19,6 +20,7 @@ const props = defineProps<{
log.debug("AssignmentEvaluationPage created", props.assignmentId, props.userId);
const { loading } = useExpertCockpitPageData(props.courseSlug);
const courseSession = useCurrentCourseSession();
const router = useRouter();
@ -62,7 +64,7 @@ const assignment = computed(
</script>
<template>
<div class="absolute bottom-0 top-0 z-10 w-full bg-white">
<div v-if="!loading" class="absolute bottom-0 top-0 z-10 w-full bg-white">
<div v-if="queryResult.fetching.value"></div>
<div v-else-if="queryResult.error.value">{{ queryResult.error.value }}</div>
<div v-else>

View File

@ -4,6 +4,7 @@ import AssignmentDetails from "@/pages/cockpit/assignmentsPage/AssignmentDetails
import * as log from "loglevel";
import { computed, onMounted } from "vue";
import type { LearningContentAssignment, LearningContentEdoniqTest } from "@/types";
import { useExpertCockpitPageData } from "@/pages/cockpit/cockpitPage/composables";
const props = defineProps<{
courseSlug: string;
@ -13,6 +14,7 @@ const props = defineProps<{
log.debug("AssignmentsPage created", props.courseSlug);
const courseSession = useCurrentCourseSession();
const { loading } = useExpertCockpitPageData(props.courseSlug);
onMounted(async () => {
log.debug("AssignmentsPage mounted");
@ -26,7 +28,7 @@ const learningContentAssignment = computed(() => {
</script>
<template>
<div class="bg-gray-200">
<div v-if="!loading" class="bg-gray-200">
<div class="container-large">
<nav class="py-4 pb-4">
<router-link

View File

@ -2,15 +2,15 @@
import { useCurrentCourseSession } from "@/composables";
import DueDateSingle from "@/components/dueDates/DueDateSingle.vue";
import { computed } from "vue";
import { useCockpitStore } from "@/stores/cockpit";
import { useExpertCockpitStore } from "@/stores/expertCockpit";
const cockpitStore = useCockpitStore();
const expertCockpitStore = useExpertCockpitStore();
const courseSession = useCurrentCourseSession();
const circleDates = computed(() => {
const dueDates = courseSession.value.due_dates.filter((dueDate) => {
if (!cockpitStore.currentCircle) return false;
return cockpitStore.currentCircle.id == dueDate?.circle?.id;
if (!expertCockpitStore.currentCircle) return false;
return expertCockpitStore.currentCircle.id == dueDate?.circle?.id;
});
return dueDates.slice(0, 4);
});

View File

@ -4,11 +4,12 @@ import ItPersonRow from "@/components/ui/ItPersonRow.vue";
import { useCourseSessionDetailQuery, useCurrentCourseSession } from "@/composables";
import SubmissionsOverview from "@/pages/cockpit/cockpitPage/SubmissionsOverview.vue";
import { useCockpitStore } from "@/stores/cockpit";
import { useExpertCockpitStore } from "@/stores/expertCockpit";
import log from "loglevel";
import CockpitDates from "@/pages/cockpit/cockpitPage/CockpitDates.vue";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import UserStatusCount from "@/pages/cockpit/cockpitPage/UserStatusCount.vue";
import { useExpertCockpitPageData } from "@/pages/cockpit/cockpitPage/composables";
const props = defineProps<{
courseSlug: string;
@ -16,22 +17,24 @@ const props = defineProps<{
log.debug("CockpitIndexPage created", props.courseSlug);
const cockpitStore = useCockpitStore();
const { loading } = useExpertCockpitPageData(props.courseSlug);
const expertCockpitStore = useExpertCockpitStore();
const courseSession = useCurrentCourseSession();
const courseSessionDetailResult = useCourseSessionDetailQuery();
</script>
<template>
<div class="bg-gray-200">
<div v-if="cockpitStore.circles?.length">
<div v-if="cockpitStore.currentCircle" class="container-large">
<div v-if="!loading" class="bg-gray-200">
<div v-if="expertCockpitStore.circles?.length">
<div v-if="expertCockpitStore.currentCircle" class="container-large">
<div class="mb-9 flex flex-col lg:flex-row lg:items-center lg:justify-between">
<h1>Cockpit</h1>
<ItDropdownSelect
:model-value="cockpitStore.currentCircle"
:model-value="expertCockpitStore.currentCircle"
class="mt-4 w-full lg:mt-0 lg:w-96"
:items="cockpitStore.circles"
@update:model-value="cockpitStore.setCurrentCourseCircleFromEvent"
:items="expertCockpitStore.circles"
@update:model-value="expertCockpitStore.setCurrentCourseCircleFromEvent"
></ItDropdownSelect>
</div>
<!-- Status -->
@ -113,7 +116,7 @@ const courseSessionDetailResult = useCourseSessionDetailQuery();
</div>
<SubmissionsOverview
:course-session="courseSession"
:selected-circle="cockpitStore.currentCircle.id"
:selected-circle="expertCockpitStore.currentCircle.id"
></SubmissionsOverview>
<div class="pt-4">
<!-- progress -->
@ -137,12 +140,12 @@ const courseSessionDetailResult = useCourseSessionDetailQuery();
:course-session-id="courseSession.id"
:course-slug="props.courseSlug"
:user-id="csu.user_id"
:show-circle-slugs="[cockpitStore.currentCircle.slug]"
:show-circle-slugs="[expertCockpitStore.currentCircle.slug]"
diagram-type="singleSmall"
class="mr-4"
></LearningPathDiagram>
<p class="lg:min-w-[150px]">
{{ cockpitStore.currentCircle.title }}
{{ expertCockpitStore.currentCircle.title }}
</p>
<UserStatusCount
:course-slug="props.courseSlug"
@ -164,11 +167,19 @@ const courseSessionDetailResult = useCourseSessionDetailQuery();
</div>
</div>
<div v-else class="container-large mt-4">
<!-- No circle selected -->
<span class="text-lg text-orange-600">
{{ $t("a.Kein Circle verfügbar oder ausgewählt.") }}
</span>
</div>
</div>
<div v-else class="container-large mt-4">
<span class="text-lg text-orange-600">
<!-- No circle at all (should never happen, mostly
for us to reduce confusion why the cockpit is just empty...) -->
{{ $t("a.Kein Circle verfügbar oder ausgewählt.") }}
</span>
</div>
</div>
</template>

View File

@ -0,0 +1,38 @@
<script setup lang="ts">
import { useRoute } from "vue-router";
const route = useRoute();
</script>
<template>
<div class="bg-gray-200">
<nav class="border-b bg-white px-4 lg:px-8">
<ul class="flex flex-col lg:flex-row">
<li
class="border-t-2 border-t-transparent"
:class="{
'border-b-2 border-b-blue-900':
route.name === 'mentorCockpitOverview' || route.name === 'mentorCockpit',
}"
>
<router-link :to="{ name: 'mentorCockpitOverview' }" class="block py-3">
{{ $t("a.Übersicht") }}
</router-link>
</li>
<li
class="border-t-2 border-t-transparent lg:ml-12"
:class="{
'border-b-2 border-b-blue-900': route.name === 'mentorCockpitParticipants',
}"
>
<router-link :to="{ name: 'mentorCockpitParticipants' }" class="block py-3">
{{ $t("a.Teilnehmer") }}
</router-link>
</li>
</ul>
</nav>
<main class="container-large">
<router-view></router-view>
</main>
</div>
</template>

View File

@ -17,6 +17,7 @@ import {
useCourseSessionDetailQuery,
} from "@/composables";
import { circleFlatLearningContents } from "@/services/circle";
import { getCockpitUrl } from "@/utils/utils";
interface Submittable {
id: string;
@ -126,10 +127,12 @@ const getShowDetailsText = (lc: LearningContent) => {
};
const getDetailsLink = (lc: LearningContent, circleId: string) => {
const base = getCockpitUrl(props.courseSession.course.slug);
if (isFeedback(lc)) {
return `cockpit/feedback/${circleId}`;
return `${base}/feedback/${circleId}`;
} else if (isAssignment(lc) || isEdoniqTest(lc)) {
return `cockpit/assignment/${lc.id}`;
return `${base}/assignment/${lc.id}`;
}
return "";
};
@ -191,18 +194,18 @@ const getIconName = (lc: LearningContent) => {
class="grow pr-8"
></FeedbackSubmissionProgress>
<div class="flex items-center lg:w-1/4 lg:justify-end">
<button v-if="submittable.detailsLink" class="btn-primary">
<router-link
:to="submittable.detailsLink"
:data-cy="
isFeedback(submittable.content)
? `show-feedback-btn-${submittable.content.slug}`
: `show-details-btn-${submittable.content.slug}`
"
>
{{ submittable.showDetailsText }}
</router-link>
</button>
<router-link
v-if="submittable.detailsLink"
class="btn-primary"
:to="submittable.detailsLink"
:data-cy="
isFeedback(submittable.content)
? `show-feedback-btn-${submittable.content.slug}`
: `show-details-btn-${submittable.content.slug}`
"
>
{{ submittable.showDetailsText }}
</router-link>
</div>
</div>
</div>

View File

@ -0,0 +1,36 @@
import {
useCourseDataWithCompletion,
useCourseSessionDetailQuery,
} from "@/composables";
import { useExpertCockpitStore } from "@/stores/expertCockpit";
import { ref } from "vue";
export function useExpertCockpitPageData(courseSlug: string) {
const loading = ref(true);
const cockpitStore = useExpertCockpitStore();
const courseSessionDetailResult = useCourseSessionDetailQuery();
async function loadData() {
loading.value = true;
await courseSessionDetailResult.waitForData();
await cockpitStore.loadCircles(
courseSlug,
courseSessionDetailResult.findCurrentUser()
);
const userDataPromises = courseSessionDetailResult.filterMembers().map((m) => {
const completionData = useCourseDataWithCompletion(courseSlug, m.id);
return completionData.resultPromise;
});
await Promise.all(userDataPromises);
loading.value = false;
}
loadData();
return {
loading,
};
}

View File

@ -0,0 +1,15 @@
<script setup lang="ts"></script>
<template>
<router-link
:to="{ name: 'mentorCockpitOverview' }"
class="btn-text mb-4 inline-flex items-center pl-0"
>
<it-icon-arrow-left class="it-icon"></it-icon-arrow-left>
{{ $t("general.back") }}
</router-link>
<div class="bg-white">
<router-view></router-view>
</div>
</template>

View File

@ -0,0 +1,85 @@
<script setup lang="ts">
import type { PraxisAssignment } from "@/services/mentorCockpit";
import { useMentorCockpit } from "@/services/mentorCockpit";
import { useCurrentCourseSession } from "@/composables";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import { computed, type Ref, ref } from "vue";
import PraxisAssignmentItem from "@/components/cockpit/mentor/PraxisAssignmentItem.vue";
import { useTranslation } from "i18next-vue";
const { t } = useTranslation();
const courseSession = useCurrentCourseSession();
const mentorCockpitStore = useMentorCockpit(courseSession.value.id);
const summary = mentorCockpitStore.summary;
const statusFilterValue = ref({ name: t("Alle"), id: "_all" });
const statusFilter = ref([
{ name: t("Alle"), id: "_all" },
{ name: t("a.Zu erledigen"), id: "todo" },
]);
const circleFilterValue = ref({ name: t("a.AlleCircle"), id: "_all" });
const circleFilter = computed(() => {
if (!summary.value) return [];
return [
{ name: t("a.AlleCircle"), id: "_all" },
...summary.value.circles.map((circle) => ({
name: `Circle: ${circle.title}`,
id: circle.id,
})),
];
});
const filteredAssignments: Ref<PraxisAssignment[]> = computed(() => {
if (!summary.value) return [];
let filtered = summary.value.assignments;
if (statusFilterValue.value.id !== "_all") {
filtered = filtered.filter((item) => item.pending_evaluations > 0);
}
if (circleFilterValue.value.id !== "_all") {
filtered = filtered.filter(
(item) => item.circle_id === String(circleFilterValue.value.id)
);
}
return filtered;
});
</script>
<template>
<div v-if="summary" class="bg-white">
<div class="flex flex-col space-x-2 lg:flex-row">
<ItDropdownSelect
v-model="statusFilterValue"
class="min-w-[10rem]"
:items="statusFilter"
borderless
></ItDropdownSelect>
<ItDropdownSelect
v-model="circleFilterValue"
class="min-w-[18rem]"
:items="circleFilter"
borderless
></ItDropdownSelect>
</div>
<template v-for="item in filteredAssignments" :key="item.id">
<PraxisAssignmentItem
v-if="item.type === 'praxis_assignment'"
:circle-title="mentorCockpitStore.getCircleTitleById(item.circle_id)"
:pending-tasks="item.pending_evaluations"
:task-link="{
name: 'mentorCockpitPraxisAssignments',
params: { praxisAssignmentId: item.id },
}"
:task-title="item.title"
/>
</template>
</div>
</template>

View File

@ -0,0 +1,35 @@
<script setup lang="ts">
import { useMentorCockpit } from "@/services/mentorCockpit";
import { useCurrentCourseSession } from "@/composables";
const courseSession = useCurrentCourseSession();
const { summary } = useMentorCockpit(courseSession.value.id);
</script>
<template>
<div v-if="summary" class="bg-white px-4 py-2">
<div
v-for="participant in summary.participants"
:key="participant.id"
class="flex flex-col items-start justify-between gap-4 border-b py-2 last:border-b-0 md:flex-row md:items-center md:gap-16"
>
<div class="flex items-center space-x-2">
<img
:alt="participant.last_name"
class="h-11 w-11 rounded-full"
:src="participant.avatar_url || '/static/avatars/myvbv-default-avatar.png'"
/>
<div>
<div class="text-bold">
{{ participant.first_name }}
{{ participant.last_name }}
</div>
{{ participant.email }}
</div>
</div>
<!-- <router-link :to="{name: 'cockpitUserProfile', params: {userId: participant.id}}" class="underline">-->
<!-- {{ $t("a.Profil anzeigen") }}-->
<!-- </router-link>-->
</div>
</div>
</template>

View File

@ -0,0 +1,104 @@
<script setup lang="ts">
import type { Participant, PraxisAssignment } from "@/services/mentorCockpit";
import { useMentorCockpit } from "@/services/mentorCockpit";
import { computed, type Ref } from "vue";
import { useCurrentCourseSession } from "@/composables";
const props = defineProps<{
praxisAssignmentId: string;
}>();
const courseSession = useCurrentCourseSession();
const mentorCockpitStore = useMentorCockpit(courseSession.value.id);
const praxisAssignment: Ref<PraxisAssignment | null> = computed(() =>
mentorCockpitStore.getPraxisAssignmentById(props.praxisAssignmentId)
);
const getParticipantById = (id: string): Participant | null => {
if (mentorCockpitStore.summary.value?.participants) {
const found = mentorCockpitStore.summary.value.participants.find(
(item) => item.id === id
);
return found || null;
}
return null;
};
</script>
<template>
<div v-if="praxisAssignment">
<div class="p-6">
<h2 class="mb-2">{{ $t("a.Praxisauftrag") }}: {{ praxisAssignment.title }}</h2>
<span class="text-gray-800">
Circle «{{ mentorCockpitStore.getCircleTitleById(praxisAssignment.circle_id) }}»
</span>
<template v-if="praxisAssignment.pending_evaluations > 0">
<div class="flex flex-row items-center space-x-2 pt-4">
<div
class="flex h-7 w-7 items-center justify-center rounded-full border-2 border-green-500 px-3 text-sm font-bold"
>
<span>{{ praxisAssignment.pending_evaluations }}</span>
</div>
<span>{{ $t("a.Ergebnisse abgegeben") }}</span>
</div>
</template>
</div>
<div class="border-t">
<div
v-for="item in praxisAssignment.completions"
:key="item.user_id"
class="flex flex-col items-start justify-between gap-4 border-b py-2 pl-5 pr-5 last:border-b-0 md:flex-row md:items-center md:justify-between md:gap-16"
>
<!-- Left -->
<div class="flex flex-grow flex-row items-center justify-start">
<div class="w-80">
<div class="flex items-center space-x-2">
<img
:alt="item.last_name"
class="h-11 w-11 rounded-full"
:src="
getParticipantById(item.user_id)?.avatar_url ||
'/static/avatars/myvbv-default-avatar.png'
"
/>
<div>
<div class="text-bold">
{{ getParticipantById(item.user_id)?.first_name }}
{{ getParticipantById(item.user_id)?.last_name }}
</div>
</div>
</div>
</div>
<!-- Center -->
<div
class="flex flex-grow flex-row items-center justify-start space-x-2 pl-20"
>
<template v-if="item.status == 'SUBMITTED'">
<div
class="flex h-7 w-7 items-center justify-center rounded-full border-2 border-green-500 px-3 py-1 text-sm font-bold"
>
<span class="flex items-center">
<it-icon-check class="h-5 w-5"></it-icon-check>
</span>
</div>
<span>{{ $t("a.Ergebnisse abgegeben") }}</span>
</template>
<template v-if="item.status == 'EVALUATED'">
<div
class="flex h-7 w-7 items-center justify-center rounded-full border-2 border-green-500 bg-green-500 px-3 py-1 text-sm font-bold"
>
<span class="flex items-center">
<it-icon-check class="h-5 w-5"></it-icon-check>
</span>
</div>
<span>{{ $t("a.Bewertung freigeben") }}</span>
</template>
</div>
<!-- Right -->
<div></div>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { useCurrentCourseSession, useCourseData } from "@/composables";
import { useCourseData, useCurrentCourseSession } from "@/composables";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import { useCockpitStore } from "@/stores/cockpit";
import { useExpertCockpitStore } from "@/stores/expertCockpit";
import ItModal from "@/components/ui/ItModal.vue";
import DocumentUploadForm from "@/pages/cockpit/documentPage/DocumentUploadForm.vue";
import { computed, onMounted, ref, watch } from "vue";
@ -16,14 +16,17 @@ import {
} from "@/services/files";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import DocumentListItem from "@/components/circle/DocumentListItem.vue";
import { useExpertCockpitPageData } from "@/pages/cockpit/cockpitPage/composables";
const cockpitStore = useCockpitStore();
const cockpitStore = useExpertCockpitStore();
const courseSession = useCurrentCourseSession();
const courseSessionsStore = useCourseSessionsStore();
const courseData = useCourseData(courseSession.value?.course.slug);
const { t } = useTranslation();
useExpertCockpitPageData(courseData.course.value?.slug || "");
const showUploadModal = ref(false);
const showUploadErrorMessage = ref(false);
const isUploading = ref(false);

View File

@ -0,0 +1,11 @@
<script setup lang="ts"></script>
<template>
<div class="bg-gray-200">
<main>
<div>
<router-view></router-view>
</div>
</main>
</div>
</template>

View File

@ -37,35 +37,44 @@ onMounted(async () => {
<nav class="border-b bg-white px-4 lg:px-8">
<ul class="flex flex-col lg:flex-row">
<li
class="inline-block border-t-2 border-t-transparent py-3"
class="border-t-2 border-t-transparent"
:class="{ 'border-b-2 border-b-blue-900': routeInOverview() }"
>
<router-link :to="`/course/${courseSlug}/competence`">
<router-link :to="`/course/${courseSlug}/competence`" class="block py-3">
{{ $t("a.Übersicht") }}
</router-link>
</li>
<li
class="inline-block border-t-2 border-t-transparent py-3 lg:ml-12"
class="border-t-2 border-t-transparent lg:ml-12"
:class="{ 'border-b-2 border-b-blue-900': routeInCompetenceCertificate() }"
>
<router-link :to="`/course/${courseSlug}/competence/certificates`">
<router-link
:to="`/course/${courseSlug}/competence/certificates`"
class="block py-3"
>
{{ $t("a.Kompetenznachweise") }}
</router-link>
</li>
<li
class="inline-block border-t-2 border-t-transparent py-3 lg:ml-12"
class="border-t-2 border-t-transparent lg:ml-12"
:class="{ 'border-b-2 border-b-blue-900': routeInPerformanceCriteria() }"
>
<router-link :to="`/course/${courseSlug}/competence/criteria`">
<router-link
:to="`/course/${courseSlug}/competence/criteria`"
class="block py-3"
>
{{ $t("a.Selbsteinschätzungen") }}
</router-link>
</li>
<li
class="inline-block border-t-2 border-t-transparent py-3 lg:ml-12"
class="border-t-2 border-t-transparent lg:ml-12"
:class="{ 'border-b-2 border-b-blue-900': routeInActionCompetences() }"
>
<router-link :to="`/course/${courseSlug}/competence/competences`">
<router-link
:to="`/course/${courseSlug}/competence/competences`"
class="block py-3"
>
{{ $t("a.Handlungskompetenzen") }}
</router-link>
</li>

View File

@ -10,6 +10,7 @@ import type {
} from "@/gql/graphql";
import CompetenceSummaryBox from "@/components/dashboard/CompetenceSummaryBox.vue";
import AssignmentProgressSummaryBox from "@/components/dashboard/AssignmentProgressSummaryBox.vue";
import { VV_COURSE_IDS } from "@/constants";
const dashboardStore = useDashboardStore();
@ -46,6 +47,13 @@ const competenceCertificateUrl = computed(() => {
const competenceCriteriaUrl = computed(() => {
return `/course/${courseSlug.value}/competence/criteria?courseSessionId=${courseSessionProgress.value?.session_to_continue_id}`;
});
const isVVCourse = computed(() => {
if (!dashboardStore.currentDashboardConfig) {
return false;
}
return VV_COURSE_IDS.includes(dashboardStore.currentDashboardConfig.id);
});
</script>
<template>
@ -74,6 +82,7 @@ const competenceCriteriaUrl = computed(() => {
</div>
<div class="grid auto-rows-fr grid-cols-1 gap-8 xl:grid-cols-2">
<AssignmentProgressSummaryBox
v-if="!isVVCourse"
:total-assignments="assignment.total_count"
:achieved-points-count="assignment.points_achieved_count"
:max-points-count="assignment.points_max_count"

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import { useCSRFFetch } from "@/fetchHelpers";
import { getCockpitUrl } from "@/utils/utils";
const props = defineProps<{
courseId: string;
invitationId: string;
}>();
const { data, error } = useCSRFFetch(
`/api/mentor/${props.courseId}/invitations/accept`,
{
onFetchError(ctx) {
ctx.error = ctx.data;
return ctx;
},
}
)
.post({
invitation_id: props.invitationId,
})
.json();
</script>
<template>
<div class="flex-grow bg-gray-200">
<div class="container-large">
<header class="mb-8 mt-12">
<h1 class="mb-8">{{ $t("a.Einladung") }}</h1>
</header>
<main>
<div class="bg-white p-6">
<template v-if="error">
{{
$t(
"a.Die Einladung konnte nicht akzeptiert werden. Bitte melde dich beim Support."
)
}}
<div>
<a class="underline" href="mailto:help@vbv.ch">help@vbv.ch</a>
</div>
<div v-if="error.message" class="my-4">
{{ $t("a.Fehlermeldung") }}: {{ error.message }}
</div>
</template>
<template v-else>
<i18next
:translation="
$t('a.Du hast die Einladung von {name} erfolgreich akzeptiert.')
"
>
<template #name>
<b>{{ data.user.first_name }} {{ data.user.last_name }}</b>
</template>
</i18next>
<div class="mt-4">
<a class="underline" :href="getCockpitUrl(data.course_slug)">
{{ $t("a.Cockpit anschauen") }}
</a>
</div>
</template>
</div>
</main>
</div>
</div>
</template>

View File

@ -0,0 +1,148 @@
<script setup lang="ts">
import { useCurrentCourseSession } from "@/composables";
import ItModal from "@/components/ui/ItModal.vue";
import { computed, ref } from "vue";
import { useCSRFFetch } from "@/fetchHelpers";
const courseSession = useCurrentCourseSession();
const showInvitationModal = ref(false);
const inviteeEmail = ref("");
const { execute: refreshMentors, data: mentors } = useCSRFFetch(
`/api/mentor/${courseSession.value.id}/mentors`
).json();
const { execute: refreshInvitations, data: invitations } = useCSRFFetch(
`/api/mentor/${courseSession.value.id}/invitations`
).json();
const hasMentors = computed(() => {
return (
(mentors.value && mentors.value.length > 0) ||
(invitations.value && invitations.value.length > 0)
);
});
const validEmail = computed(() => {
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
return emailRegex.test(inviteeEmail.value);
});
const removeInvitation = async (invitationId: string) => {
await useCSRFFetch(
`/api/mentor/${courseSession.value.id}/invitations/${invitationId}/delete`
).delete();
await refreshInvitations();
};
const removeMentor = async (mentorId: string) => {
await useCSRFFetch(
`/api/mentor/${courseSession.value.id}/mentors/${mentorId}/leave`
).delete();
await refreshMentors();
};
const inviteMentor = async () => {
await useCSRFFetch(`/api/mentor/${courseSession.value.id}/invitations/create`).post({
email: inviteeEmail.value,
});
await refreshInvitations();
showInvitationModal.value = false;
inviteeEmail.value = "";
};
</script>
<template>
<div class="bg-gray-200">
<div class="container-large">
<header class="mb-8 mt-12">
<h1 class="mb-8">{{ $t("a.Lernbegleitung") }}</h1>
<p>
{{
$t(
"a.Hier kannst du Personen einladen, damit sie deine Lernbegleitung werden. Zudem siehst du jederzeit eine Übersicht aller Personen, die du bereits als Lernbegleitung hinzugefügt hast."
)
}}
</p>
</header>
<main>
<div class="bg-white p-6">
<div class="mb-8">
<button
class="btn-secondary flex items-center"
@click="showInvitationModal = true"
>
<it-icon-add class="it-icon mr-2 h-6 w-6" />
{{ $t("a.Neue Lernbegleitung einladen") }}
</button>
</div>
<div class="border-t">
<div
v-for="invitation in invitations"
:key="invitation.id"
class="flex flex-col justify-between gap-4 border-b py-4 md:flex-row md:gap-16"
>
<div class="flex flex-col justify-between md:flex-grow md:flex-row">
{{ invitation.email }}
<div class="flex items-center">
<it-icon-info class="it-icon mr-2 h-6 w-6" />
{{ $t("a.Die Einladung wurde noch nicht angenommen.") }}
</div>
</div>
<button class="underline" @click="removeInvitation(invitation.id)">
{{ $t("a.Entfernen") }}
</button>
</div>
<div
v-for="learningMentor in mentors"
:key="learningMentor.id"
class="flex flex-col justify-between gap-4 border-b py-4 md:flex-row md:gap-16"
>
<div class="flex items-center space-x-2">
<img
:alt="learningMentor.mentor.last_name"
class="h-11 w-11 rounded-full"
:src="learningMentor.mentor.avatar_url"
/>
<div>
<div class="text-bold">
{{ learningMentor.mentor.first_name }}
{{ learningMentor.mentor.last_name }}
</div>
{{ learningMentor.mentor.email }}
</div>
</div>
<button class="underline" @click="removeMentor(learningMentor.id)">
{{ $t("a.Entfernen") }}
</button>
</div>
</div>
<div v-if="!hasMentors" class="mt-8 flex items-center">
<it-icon-info class="it-icon mr-2 h-6 w-6" />
{{
$t("a.Aktuell hast du noch keine Person als Lernbegleitung eingeladen.")
}}
</div>
</div>
</main>
</div>
<ItModal v-model="showInvitationModal">
<template #title>{{ $t("a.Neue Lernbegleitung einladen") }}</template>
<template #body>
<div class="flex flex-col">
<label for="mentor-email">{{ $t("a.E-Mail Adresse") }}</label>
<input id="mentor-email" v-model="inviteeEmail" type="email" />
<button
:disabled="!validEmail"
class="btn-primary mt-8"
@click="inviteMentor()"
>
{{ $t("a.Einladung abschicken") }}
</button>
</div>
</template>
</ItModal>
</div>
</template>

View File

@ -9,10 +9,12 @@ import DocumentSection from "./DocumentSection.vue";
import {
useCourseDataWithCompletion,
useCourseSessionDetailQuery,
useCurrentCourseSession,
} from "@/composables";
import { stringifyParse } from "@/utils/utils";
import { useCircleStore } from "@/stores/circle";
import LearningSequence from "@/pages/learningPath/circlePage/LearningSequence.vue";
import { useCSRFFetch } from "@/fetchHelpers";
export interface Props {
courseSlug: string;
@ -21,6 +23,8 @@ export interface Props {
readonly?: boolean;
}
const courseSession = useCurrentCourseSession();
const route = useRoute();
const courseSessionDetailResult = useCourseSessionDetailQuery();
const circleStore = useCircleStore();
@ -67,6 +71,43 @@ const showDocumentSection = computed(() => {
return lpQueryResult.course.value?.enable_circle_documents && !props.readonly;
});
const courseConfig = computed(() => {
if (lpQueryResult.course.value?.circle_contact_type === "EXPERT") {
return {
contactDescription: "circlePage.contactExpertDescription",
contactButton: "circlePage.contactExpertButton",
showContact: true,
};
} else if (lpQueryResult.course.value?.circle_contact_type === "LEARNING_MENTOR") {
return {
contactDescription: "circlePage.contactLearningMentorDescription",
contactButton: "circlePage.contactLearningMentorButton",
showContact: true,
};
} else {
return {
contactDescription: "",
contactButton: "",
showContact: true,
};
}
});
const { data: mentors } = useCSRFFetch(
`/api/mentor/${courseSession.value.id}/mentors`
).json();
const expert = computed(() => {
if (courseConfig.value.showContact) {
if (lpQueryResult.course.value?.circle_contact_type === "EXPERT") {
return circleExperts.value[0];
} else if (lpQueryResult.course.value?.circle_contact_type === "LEARNING_MENTOR") {
return mentors.value?.[0].mentor;
}
}
return null;
});
watch(
() => circle.value,
() => {
@ -198,29 +239,28 @@ watch(
<h3 class="text-blue-dark">{{ $t("circlePage.gotQuestions") }}</h3>
<div class="mt-4 leading-relaxed">
{{
$t("circlePage.contactExpertDescription", {
$t(courseConfig.contactDescription, {
circleName: circle?.title,
})
}}
</div>
<div v-for="expert in circleExperts" :key="expert.user_id">
<div class="mb-2 mt-2 flex flex-row items-center">
<template v-if="expert">
<div class="mb-6 mt-4 flex flex-row items-center">
<img
class="mr-2 h-[45px] rounded-full"
:src="expert.avatar_url"
:src="
expert.avatar_url ||
'/static/avatars/myvbv-default-avatar.png'
"
/>
<p class="lg:leading-[45px]">
{{ expert.first_name }} {{ expert.last_name }}
</p>
</div>
</div>
<a
v-if="circleExperts.length > 0"
:href="'mailto:' + circleExperts[0].email"
class="btn-secondary mt-4 text-xl"
>
{{ $t("circlePage.contactExpertButton") }}
</a>
<a :href="'mailto:' + expert.email" class="btn-secondary text-xl">
{{ $t(courseConfig.contactButton) }}
</a>
</template>
</div>
</div>
</div>

View File

@ -1,25 +1,19 @@
<script setup lang="ts">
import DateEmbedding from "@/components/dueDates/DateEmbedding.vue";
import ItButton from "@/components/ui/ItButton.vue";
import ItCheckbox from "@/components/ui/ItCheckbox.vue";
import ItSuccessAlert from "@/components/ui/ItSuccessAlert.vue";
import {
useCourseData,
useCourseSessionDetailQuery,
useCurrentCourseSession,
} from "@/composables";
import { bustItGetCache } from "@/fetchHelpers";
import { UPSERT_ASSIGNMENT_COMPLETION_MUTATION } from "@/graphql/mutations";
import AssignmentSubmissionResponses from "@/pages/learningPath/learningContentPage/assignment/AssignmentSubmissionResponses.vue";
import { useUserStore } from "@/stores/user";
import type { Assignment, AssignmentCompletion, AssignmentTask } from "@/types";
import { useMutation } from "@urql/vue";
import log from "loglevel";
import { computed, reactive } from "vue";
import { computed } from "vue";
import { useTranslation } from "i18next-vue";
import eventBus from "@/utils/eventBus";
import dayjs from "dayjs";
import type { AssignmentAssignmentAssignmentTypeChoices } from "@/gql/graphql";
import CaseWorkSubmit from "@/components/learningPath/assignment/CaseWorkSubmit.vue";
import SimpleSubmit from "@/components/learningPath/assignment/SimpleSubmit.vue";
import PraxisAssignmentSubmit from "@/components/learningPath/assignment/PraxisAssignmentSubmit.vue";
const props = defineProps<{
assignment: Assignment;
@ -39,11 +33,6 @@ const courseData = useCourseData(courseSession.value.course.slug);
const { t } = useTranslation();
const state = reactive({
confirmInput: false,
confirmPerson: false,
});
const learningContent = computed(() => {
return courseData.findLearningContent(props.learningContentId);
});
@ -77,13 +66,6 @@ const completionTaskData = computed(() => {
return props.assignmentCompletion?.task_completion_data ?? {};
});
const cannotSubmit = computed(() => {
return (
(!state.confirmInput && !isPraxisAssignment.value) ||
(props.assignment.assignment_type === "CASEWORK" && !state.confirmPerson)
);
});
function checkAssignmentType(
assignmentType: AssignmentAssignmentAssignmentTypeChoices[]
) {
@ -91,15 +73,8 @@ function checkAssignmentType(
}
const isCasework = computed(() => checkAssignmentType(["CASEWORK"]));
const mayBeEvaluated = computed(() =>
checkAssignmentType(["CASEWORK", "PRAXIS_ASSIGNMENT"])
);
const isPraxisAssignment = computed(() => checkAssignmentType(["PRAXIS_ASSIGNMENT"]));
const upsertAssignmentCompletionMutation = useMutation(
UPSERT_ASSIGNMENT_COMPLETION_MUTATION
);
const onEditTask = (task: AssignmentTask) => {
emit("editTask", task);
};
@ -111,97 +86,43 @@ const openSolutionSample = () => {
window.open(url, "_blank");
}
};
const onSubmit = async () => {
try {
await upsertAssignmentCompletionMutation.executeMutation({
assignmentId: props.assignment.id,
courseSessionId: courseSession.value.id,
learningContentId: props.learningContentId,
completionDataString: JSON.stringify({}),
completionStatus: "SUBMITTED",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
id: props.assignmentCompletion?.id,
});
bustItGetCache(
`/api/course/completion/${courseSession.value.id}/${useUserStore().id}/`
);
// if solution sample is available, do not close the assigment automatically
if (!props.assignment.solution_sample) {
eventBus.emit("finishedLearningContent", true);
}
} catch (error) {
log.error("Could not submit assignment", error);
}
};
</script>
<template>
<div class="w-full border border-gray-400 p-8" data-cy="confirm-container">
<h3 class="heading-3 border-b border-gray-400 pb-6">
{{ $t("assignment.submitAssignment") }}
<template v-if="isPraxisAssignment">
{{ $t("a.Ergebnisse teilen") }}
</template>
<template v-else>
{{ $t("assignment.submitAssignment") }}
</template>
</h3>
<div v-if="completionStatus === 'IN_PROGRESS'">
<ItCheckbox
v-if="!isPraxisAssignment"
class="w-full border-b border-gray-400 py-10 sm:py-6"
:checkbox-item="{
label: $t('assignment.confirmSubmitResults'),
value: 'value',
checked: state.confirmInput,
}"
data-cy="confirm-submit-results"
@toggle="state.confirmInput = !state.confirmInput"
></ItCheckbox>
<div v-if="mayBeEvaluated" class="w-full border-b border-gray-400">
<ItCheckbox
v-if="mayBeEvaluated"
class="py-6"
:checkbox-item="{
label: isPraxisAssignment
? $t('a.confirmSubmitPersonPraxisAssignment')
: $t('assignment.confirmSubmitPerson'),
value: 'value',
checked: state.confirmPerson,
}"
data-cy="confirm-submit-person"
@toggle="state.confirmPerson = !state.confirmPerson"
></ItCheckbox>
<div v-if="circleExpert" class="flex flex-row items-center pb-6 pl-[49px]">
<img
alt="Notification icon"
class="mr-2 h-[45px] min-w-[45px] rounded-full"
:src="circleExpert.avatar_url"
/>
<p class="text-base font-bold">
{{ circleExpertName }}
</p>
</div>
<!-- TODO: find way to find user that will do the corrections -->
</div>
<div v-if="isCasework" class="flex flex-col space-x-2 pt-6 text-base sm:flex-row">
<p>{{ $t("assignment.assessmentDocumentDisclaimer") }}</p>
<a :href="props.assignment.evaluation_document_url" class="underline">
{{ $t("assignment.showAssessmentDocument") }}
</a>
</div>
<p v-if="mayBeEvaluated && props.submissionDeadlineStart" class="pt-6">
{{ $t("assignment.dueDateSubmission") }}
<DateEmbedding
:single-date="dayjs(props.submissionDeadlineStart)"
></DateEmbedding>
</p>
<ItButton
class="mt-6"
variant="blue"
size="large"
:disabled="cannotSubmit"
data-cy="submit-assignment"
@click="onSubmit"
>
<p>{{ $t("assignment.submitAssignment") }}</p>
</ItButton>
<CaseWorkSubmit
v-if="isCasework"
:course-session-id="courseSessionId"
:assignment="assignment"
:learning-content-id="learningContentId"
:circle-expert="circleExpert"
:evaluation-document-url="assignment.evaluation_document_url"
:submission-deadline-start="submissionDeadlineStart"
/>
<PraxisAssignmentSubmit
v-else-if="isPraxisAssignment"
:course-session-id="courseSessionId"
:assignment="assignment"
:learning-content-id="learningContentId"
:submission-deadline-start="submissionDeadlineStart"
/>
<SimpleSubmit
v-else
:course-session-id="courseSessionId"
:assignment="assignment"
:learning-content-id="learningContentId"
/>
</div>
<div v-else class="pt-6">
<ItSuccessAlert
@ -214,7 +135,7 @@ const onSubmit = async () => {
}}
</p>
<div
v-if="props.assignment.solution_sample"
v-if="assignment.solution_sample"
class="pt-2"
data-cy="show-sample-solution"
>
@ -236,7 +157,7 @@ const onSubmit = async () => {
</div>
</div>
<AssignmentSubmissionResponses
:assignment="props.assignment"
:assignment="assignment"
:assignment-completion-data="completionData"
:assignment-task-completion-data="completionTaskData"
:allow-edit="completionStatus === 'IN_PROGRESS'"

View File

@ -1,7 +1,9 @@
<script setup lang="ts">
import WizardPage from "@/components/onboarding/WizardPage.vue";
import { useUserStore } from "@/stores/user";
import { getLoginURL } from "@/router/utils";
import { getLoginURL, getSignUpURL } from "@/router/utils";
import { useRoute } from "vue-router";
import { computed } from "vue";
const props = defineProps({
courseType: {
@ -9,7 +11,27 @@ const props = defineProps({
required: true,
},
});
const user = useUserStore();
const route = useRoute();
function constructParams() {
const params: { lang: string; next?: string; course?: string } = {
lang: user.language,
};
const nextValue = route.query.next;
if (nextValue && typeof nextValue === "string") {
params.next = nextValue;
} else {
params.course = props.courseType;
}
return params;
}
const loginURL = computed(() => getLoginURL(constructParams()));
const signUpURL = computed(() => getSignUpURL(constructParams()));
</script>
<template>
@ -19,23 +41,12 @@ const user = useUserStore();
<p class="mb-4">
{{ $t("a.Damit du myVBV nutzen kannst, brauchst du ein Konto.") }}
</p>
<a
:href="`/sso/signup?course=${props.courseType}&lang=${user.language}`"
class="btn-primary"
>
<a :href="signUpURL" class="btn-primary">
{{ $t("a.Konto erstellen") }}
</a>
<p class="mb-4 mt-12">{{ $t("a.Hast du schon ein Konto?") }}</p>
<a
:href="
getLoginURL({
course: props.courseType,
lang: user.language,
})
"
class="btn-secondary"
>
<a :href="loginURL" class="btn-secondary">
{{ $t("a.Anmelden") }}
</a>
</template>

View File

@ -1,4 +1,5 @@
import { getLoginURLNext, shouldUseSSO } from "@/router/utils";
import { useCockpitStore } from "@/stores/cockpit";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useUserStore } from "@/stores/user";
import type { NavigationGuard, RouteLocationNormalized } from "vue-router";
@ -110,3 +111,41 @@ export async function handleCourseSessionAsQueryParam(to: RouteLocationNormalize
}
}
}
export async function handleCockpit(to: RouteLocationNormalized) {
const courseSessionsStore = useCourseSessionsStore();
const courseId = courseSessionsStore.currentCourseSession?.course.id || null;
const cockpitStore = useCockpitStore();
await cockpitStore.fetchCockpitType(courseId);
if (to.name === "cockpit") {
if (cockpitStore.hasExpertCockpitType) {
return { name: "expertCockpit", params: to.params };
} else if (cockpitStore.hasMentorCockpitType) {
return { name: "mentorCockpitOverview", params: to.params };
}
}
const cockpitType = to.meta?.cockpitType;
if (!cockpitType) {
return;
}
if (cockpitType !== cockpitStore.cockpitType) {
return "/";
}
}
export async function handleAcceptLearningMentorInvitation(
to: RouteLocationNormalized
) {
const user = useUserStore();
if (user.loggedIn) {
return;
}
return `/onboarding/vv-${user.language}/account/create?next=${encodeURIComponent(
to.fullPath
)}`;
}

View File

@ -4,6 +4,8 @@ import GuestStartPage from "@/pages/start/GuestStartPage.vue";
import UKStartPage from "@/pages/start/UKStartPage.vue";
import VVStartPage from "@/pages/start/VVStartPage.vue";
import {
handleAcceptLearningMentorInvitation,
handleCockpit,
handleCourseSessionAsQueryParam,
handleCurrentCourseSession,
redirectToLoginIfRequired,
@ -139,19 +141,89 @@ const router = createRouter({
props: true,
},
{
path: "/course/:courseSlug/cockpit",
path: "/course/:courseSlug/mentor",
component: () => import("@/pages/learningMentor/MentorManagementPage.vue"),
props: true,
component: () => import("@/pages/cockpit/CockpitParentPage.vue"),
name: "learningMentorManagement",
},
{
path: "/lernbegleitung/:courseId/invitation/:invitationId",
component: () => import("@/pages/learningMentor/InvitationAcceptPage.vue"),
props: true,
beforeEnter: handleAcceptLearningMentorInvitation,
meta: {
// The login redirect is handled in the beforeEnter guard
public: true,
},
},
{
path: "/course/:courseSlug/cockpit",
name: "cockpit",
children: [
{
path: "",
component: () => import("@/pages/cockpit/cockpitPage/CockpitPage.vue"),
path: "expert",
component: () => import("@/pages/cockpit/cockpitPage/CockpitExpertPage.vue"),
props: true,
name: "expertCockpit",
meta: {
cockpitType: "expert",
},
},
{
path: "mentor",
component: () => import("@/pages/cockpit/cockpitPage/CockpitMentorPage.vue"),
name: "mentorCockpit",
meta: {
cockpitType: "mentor",
},
children: [
{
path: "",
component: () =>
import("@/pages/cockpit/cockpitPage/mentor/MentorOverview.vue"),
name: "mentorCockpitOverview",
meta: {
cockpitType: "mentor",
},
},
{
path: "participants",
component: () =>
import("@/pages/cockpit/cockpitPage/mentor/MentorParticipants.vue"),
name: "mentorCockpitParticipants",
meta: {
cockpitType: "mentor",
},
},
{
path: "details",
component: () =>
import("@/pages/cockpit/cockpitPage/mentor/MentorDetailParentPage.vue"),
meta: {
cockpitType: "mentor",
},
children: [
{
path: "praxis-assignments/:praxisAssignmentId",
component: () =>
import(
"@/pages/cockpit/cockpitPage/mentor/MentorPraxisAssignment.vue"
),
name: "mentorCockpitPraxisAssignments",
meta: {
cockpitType: "mentor",
},
props: true,
},
],
},
],
},
{
path: "profile/:userId",
component: () => import("@/pages/cockpit/CockpitUserProfilePage.vue"),
props: true,
name: "cockpitUserProfile",
},
{
path: "profile/:userId/:circleSlug",
@ -312,6 +384,8 @@ router.beforeEach(redirectToLoginIfRequired);
router.beforeEach(handleCurrentCourseSession);
router.beforeEach(handleCourseSessionAsQueryParam);
router.beforeEach(handleCockpit);
router.beforeEach(addToHistory);
export default router;

View File

@ -3,15 +3,18 @@ export function shouldUseSSO() {
return appEnv.startsWith("prod") || appEnv.startsWith("stage");
}
export function getLoginURL(params = {}) {
let url = shouldUseSSO() ? "/sso/login/" : "/login-local";
function constructURL(basePath: string, params = {}) {
const queryParams = new URLSearchParams(params);
if (queryParams.toString()) {
url += `?${queryParams}`;
}
return `${basePath}${queryParams.toString() ? `?${queryParams}` : ""}`;
}
return url;
export function getLoginURL(params = {}) {
const basePath = shouldUseSSO() ? "/sso/login" : "/login-local";
return constructURL(basePath, params);
}
export function getSignUpURL(params = {}) {
return constructURL("/sso/signup", params);
}
export function getLoginURLNext() {

View File

@ -0,0 +1,97 @@
import { itGetCached } from "@/fetchHelpers";
import type { Ref } from "vue";
import { ref, watchEffect } from "vue";
export interface Participant {
id: string;
first_name: string;
last_name: string;
email: string;
username: string;
avatar_url: string;
language: string;
}
interface Circle {
id: number;
title: string;
}
enum CompletionStatus {
UNKNOWN = "UNKNOWN",
SUBMITTED = "SUBMITTED",
EVALUATED = "EVALUATED",
}
interface Completion {
status: CompletionStatus;
user_id: string;
last_name: string;
}
export interface PraxisAssignment {
id: string;
title: string;
circle_id: string;
pending_evaluations: number;
completions: Completion[];
type: string;
}
interface Summary {
participants: Participant[];
circles: Circle[];
assignments: PraxisAssignment[];
}
export const useMentorCockpit = (
courseSessionId: string | Ref<string> | (() => string)
) => {
const isLoading = ref(false);
const summary: Ref<Summary | null> = ref(null);
const error = ref(null);
const getCircleTitleById = (id: string): string => {
if (summary.value?.circles) {
const circle = summary.value.circles.find((circle) => String(circle.id) === id);
return circle ? circle.title : "";
}
return "";
};
const getPraxisAssignmentById = (id: string): PraxisAssignment | null => {
if (summary.value?.assignments) {
const found = summary.value.assignments.find(
(assignment) => assignment.id === id
);
return found ? found : null;
}
return null;
};
const fetchData = () => {
summary.value = null;
error.value = null;
itGetCached(`/api/mentor/${courseSessionId}/summary`)
.then((response) => {
summary.value = response;
})
.catch((err) => (error.value = err))
.finally(() => {
isLoading.value = false;
});
};
watchEffect(() => {
fetchData();
});
return {
isLoading,
summary,
error,
getCircleTitleById,
getPraxisAssignmentById,
};
};

View File

@ -1,76 +1,37 @@
import { useCourseData } from "@/composables";
import { useUserStore } from "@/stores/user";
import type { CircleLight, CourseSessionUser, ExpertSessionUser } from "@/types";
import log from "loglevel";
import { itGetCached } from "@/fetchHelpers";
import { defineStore } from "pinia";
import type { Ref } from "vue";
import { computed, ref } from "vue";
type CircleCockpit = CircleLight & {
name: string;
};
type CockpitType = "mentor" | "expert" | null;
export type CockpitStoreState = {
courseSessionMembers: CourseSessionUser[] | undefined;
circles: CircleCockpit[] | undefined;
currentCircle: CircleCockpit | undefined;
};
export const useCockpitStore = defineStore("cockpit", () => {
const cockpitType: Ref<CockpitType> = ref(null);
const isLoading = ref(false);
export const useCockpitStore = defineStore({
id: "cockpit",
state: () => {
return {
courseSessionMembers: undefined,
circles: [],
currentCircle: undefined,
} as CockpitStoreState;
},
actions: {
async loadCircles(
courseSlug: string,
currentCourseSessionUser: CourseSessionUser | undefined
) {
log.debug("loadCircles called", courseSlug);
const hasExpertCockpitType = computed(() => cockpitType.value === "expert");
const hasMentorCockpitType = computed(() => cockpitType.value === "mentor");
const hasNoCockpitType = computed(() => cockpitType.value === null);
this.circles = await courseCircles(courseSlug, currentCourseSessionUser);
async function fetchCockpitType(courseId: string | null) {
if (!courseId) {
cockpitType.value = null;
return;
}
if (this.circles?.length) {
await this.setCurrentCourseCircle(this.circles[0].slug);
}
},
async setCurrentCourseCircle(circleSlug: string) {
this.currentCircle = this.circles?.find((c) => c.slug === circleSlug);
},
async setCurrentCourseCircleFromEvent(event: CircleLight) {
await this.setCurrentCourseCircle(event.slug);
},
},
isLoading.value = true;
const url = `/api/course/${courseId}/cockpit/`;
const response = await itGetCached(url);
cockpitType.value = response.type;
isLoading.value = false;
}
return {
fetchCockpitType,
hasExpertCockpitType,
hasMentorCockpitType,
hasNoCockpitType,
isLoading,
cockpitType,
};
});
async function courseCircles(
courseSlug: string,
currentCourseSessionUser: CourseSessionUser | undefined
) {
if (currentCourseSessionUser && currentCourseSessionUser.role === "EXPERT") {
const expert = currentCourseSessionUser as ExpertSessionUser;
return expert.circles.map((c) => {
return { ...c, name: c.title };
});
}
const userStore = useUserStore();
// Return all circles from learning path for admin users
if (userStore.is_superuser) {
const lpQueryResult = useCourseData(courseSlug);
await lpQueryResult.resultPromise;
return (lpQueryResult.circles.value ?? []).map((c) => {
return {
id: c.id,
slug: c.slug,
title: c.title,
name: c.title,
} as const;
});
}
return [];
}

View File

@ -1,4 +1,5 @@
import { itGetCached } from "@/fetchHelpers";
import { useCockpitStore } from "@/stores/cockpit";
import type { CourseSession, DueDate } from "@/types";
import eventBus from "@/utils/eventBus";
import { useRouteLookups } from "@/utils/route";
@ -149,6 +150,9 @@ export const useCourseSessionsStore = defineStore("courseSessions", () => {
function hasCockpit(courseSession: CourseSession) {
const userStore = useUserStore();
return (
useCockpitStore().hasMentorCockpitType ||
useCockpitStore().hasExpertCockpitType ||
// for legacy reasons: don't forget course session supervisors!
userStore.course_session_experts.includes(courseSession.id) ||
userStore.is_superuser
);

View File

@ -0,0 +1,76 @@
import { useCourseData } from "@/composables";
import { useUserStore } from "@/stores/user";
import type { CircleLight, CourseSessionUser, ExpertSessionUser } from "@/types";
import log from "loglevel";
import { defineStore } from "pinia";
type CircleExpertCockpit = CircleLight & {
name: string;
};
export type ExpertCockpitStoreState = {
courseSessionMembers: CourseSessionUser[] | undefined;
circles: CircleExpertCockpit[] | undefined;
currentCircle: CircleExpertCockpit | undefined;
};
export const useExpertCockpitStore = defineStore({
id: "expertCockpit",
state: () => {
return {
courseSessionMembers: undefined,
circles: [],
currentCircle: undefined,
} as ExpertCockpitStoreState;
},
actions: {
async loadCircles(
courseSlug: string,
currentCourseSessionUser: CourseSessionUser | undefined
) {
log.debug("loadCircles called", courseSlug);
this.circles = await courseCircles(courseSlug, currentCourseSessionUser);
if (this.circles?.length) {
await this.setCurrentCourseCircle(this.circles[0].slug);
}
},
async setCurrentCourseCircle(circleSlug: string) {
this.currentCircle = this.circles?.find((c) => c.slug === circleSlug);
},
async setCurrentCourseCircleFromEvent(event: CircleLight) {
await this.setCurrentCourseCircle(event.slug);
},
},
});
async function courseCircles(
courseSlug: string,
currentCourseSessionUser: CourseSessionUser | undefined
) {
if (currentCourseSessionUser && currentCourseSessionUser.role === "EXPERT") {
const expert = currentCourseSessionUser as ExpertSessionUser;
return expert.circles.map((c) => {
return { ...c, name: c.title };
});
}
const userStore = useUserStore();
// Return all circles from learning path for admin users
if (userStore.is_superuser) {
const lpQueryResult = useCourseData(courseSlug);
await lpQueryResult.resultPromise;
return (lpQueryResult.circles.value ?? []).map((c) => {
return {
id: c.id,
slug: c.slug,
title: c.title,
name: c.title,
} as const;
});
}
return [];
}

View File

@ -5,6 +5,7 @@ import type {
AssignmentCompletionStatus as AssignmentCompletionStatusGenerated,
AssignmentObjectType,
CircleObjectType,
CourseCourseCircleContactTypeChoices,
CourseSessionObjectType,
CourseSessionUserObjectsType,
LearningContentAssignmentObjectType,
@ -195,6 +196,7 @@ export interface Course {
category_name: string;
slug: string;
enable_circle_documents: boolean;
circle_contact_type: CourseCourseCircleContactTypeChoices;
}
export interface CourseCategory {

View File

@ -22,6 +22,11 @@ export function useRouteLookups() {
return regex.test(route.path);
}
function inLearningMentor() {
const regex = new RegExp("/course/[^/]+/mentor");
return regex.test(route.path);
}
function inMediaLibrary() {
const regex = new RegExp("/course/[^/]+/media");
return regex.test(route.path);
@ -37,6 +42,7 @@ export function useRouteLookups() {
inCockpit,
inLearningPath,
inCompetenceProfile,
inLearningMentor,
inCourse,
inAppointments: inAppointments,
};

View File

@ -14,7 +14,7 @@ function createCourseUrl(courseSlug: string | undefined, specificSub: string): s
return "/";
}
if (["learn", "media", "competence", "cockpit"].includes(specificSub)) {
if (["learn", "media", "competence", "cockpit", "mentor"].includes(specificSub)) {
return `/course/${courseSlug}/${specificSub}`;
}
return `/course/${courseSlug}`;
@ -36,6 +36,10 @@ export function getCockpitUrl(courseSlug: string | undefined): string {
return createCourseUrl(courseSlug, "cockpit");
}
export function getLearningMentorManagementUrl(courseSlug: string | undefined): string {
return createCourseUrl(courseSlug, "mentor");
}
export function getAssignmentTypeTitle(assignmentType: AssignmentType): string {
const { t } = useTranslation();

View File

@ -1,29 +1,29 @@
import { TEST_STUDENT1_USER_ID } from "../../consts";
import { login } from "../helpers";
function completePraxisAssignment(selectExpert = false) {
function completePraxisAssignment() {
cy.visit("/course/test-lehrgang/learn/reisen/mein-kundenstamm");
cy.learningContentMultiLayoutNextStep();
cy.testLearningContentTitle(
"Teilaufgabe 1: Filtere nach Kundeneigenschaften"
);
cy.get('[data-cy="it-textarea-user-text-input-1"]')
cy.get("[data-cy=\"it-textarea-user-text-input-1\"]")
.clear()
.type("Hallo Teilaufgabe 1.1");
cy.wait(550);
cy.get('[data-cy="it-textarea-user-text-input-2"]')
cy.get("[data-cy=\"it-textarea-user-text-input-2\"]")
.clear()
.type("Hallo Teilaufgabe 1.2");
cy.wait(550);
cy.get('[data-cy="it-textarea-user-text-input-3"]')
cy.get("[data-cy=\"it-textarea-user-text-input-3\"]")
.clear()
.type("Hallo Teilaufgabe 1.3");
cy.wait(550);
cy.get('[data-cy="it-textarea-user-text-input-4"]')
cy.get("[data-cy=\"it-textarea-user-text-input-4\"]")
.clear()
.type("Hallo Teilaufgabe 1.4");
cy.wait(550);
cy.get('[data-cy="it-textarea-user-text-input-5"]')
cy.get("[data-cy=\"it-textarea-user-text-input-5\"]")
.clear()
.type("Hallo Teilaufgabe 1.5");
// wait because of input debounce
@ -32,19 +32,19 @@ function completePraxisAssignment(selectExpert = false) {
// step 2
cy.testLearningContentTitle("Teilaufgabe 2: Filtere nach Versicherungen");
cy.get('[data-cy="it-textarea-user-text-input-1"]')
cy.get("[data-cy=\"it-textarea-user-text-input-1\"]")
.clear()
.type("Hallo Teilaufgabe 2.1");
cy.wait(550);
cy.get('[data-cy="it-textarea-user-text-input-2"]')
cy.get("[data-cy=\"it-textarea-user-text-input-2\"]")
.clear()
.type("Hallo Teilaufgabe 2.2");
cy.wait(550);
cy.get('[data-cy="it-textarea-user-text-input-3"]')
cy.get("[data-cy=\"it-textarea-user-text-input-3\"]")
.clear()
.type("Hallo Teilaufgabe 2.3");
cy.wait(550);
cy.get('[data-cy="it-textarea-user-text-input-4"]')
cy.get("[data-cy=\"it-textarea-user-text-input-4\"]")
.clear()
.type("Hallo Teilaufgabe 2.4");
// wait because of input debounce
@ -66,11 +66,11 @@ function completePraxisAssignment(selectExpert = false) {
cy.testLearningContentTitle(
"Teilaufgabe 3: Filtere nach besonderen Ereignissen"
);
cy.get('[data-cy="it-textarea-user-text-input-1"]')
cy.get("[data-cy=\"it-textarea-user-text-input-1\"]")
.clear()
.type("Hallo Teilaufgabe 3.1");
cy.wait(550);
cy.get('[data-cy="it-textarea-user-text-input-2"]')
cy.get("[data-cy=\"it-textarea-user-text-input-2\"]")
.clear()
.type("Hallo Teilaufgabe 3.2");
// wait because of input debounce
@ -79,7 +79,7 @@ function completePraxisAssignment(selectExpert = false) {
// step 4
cy.testLearningContentTitle("Teilaufgabe 4: Kundentelefonate");
cy.get('[data-cy="it-textarea-user-text-input-0"]')
cy.get("[data-cy=\"it-textarea-user-text-input-0\"]")
.clear()
.type("Hallo Teilaufgabe 4.1");
// wait because of input debounce
@ -88,23 +88,24 @@ function completePraxisAssignment(selectExpert = false) {
// step 5
cy.testLearningContentTitle("Teilaufgabe 5: Kundentelefonate2");
cy.get('[data-cy="it-textarea-user-text-input-0"]')
cy.get("[data-cy=\"it-textarea-user-text-input-0\"]")
.clear()
.type("Hallo Teilaufgabe 5.1");
// wait because of input debounce
cy.wait(550);
cy.learningContentMultiLayoutNextStep();
cy.get('[data-cy="confirm-submit-results"]').should("not.exist");
cy.get('[data-cy="confirm-submit-person"]').should(
cy.get("[data-cy=\"confirm-submit-results\"]").should("not.exist");
cy.get("[data-cy=\"confirm-submit-person\"]").should(
"contain",
"Folgende Person soll mir Feedback zu meinen Ergebnissen geben."
);
if (selectExpert) {
cy.get('[data-cy="confirm-submit-person"]').click();
}
cy.get('[data-cy="submit-assignment"]').click();
cy.get('[data-cy="success-text"]').should("exist");
cy.get("[data-cy=\"confirm-submit-person\"]").click();
cy.get("[data-cy='select-learning-mentor']").should("be.visible").select(0);
cy.get("[data-cy=\"submit-assignment\"]").click();
cy.get("[data-cy=\"success-text\"]").should("exist");
// app goes back to circle view -> check if assignment is marked as completed
cy.url().should((url) => {
@ -112,13 +113,13 @@ function completePraxisAssignment(selectExpert = false) {
});
cy.reload();
cy.get(
'[data-cy="test-lehrgang-lp-circle-reisen-lc-mein-kundenstamm-checkbox"]'
"[data-cy=\"test-lehrgang-lp-circle-reisen-lc-mein-kundenstamm-checkbox\"]"
).should("have.class", "cy-checked");
}
describe("assignmentStudent.cy.js", () => {
beforeEach(() => {
cy.manageCommand("cypress_reset");
cy.manageCommand("cypress_reset --create-learning-mentor");
login("test-student1@example.com", "test");
cy.visit(
"/course/test-lehrgang/learn/fahrzeug/überprüfen-einer-motorfahrzeug-versicherungspolice"
@ -162,10 +163,10 @@ describe("assignmentStudent.cy.js", () => {
"Teilaufgabe 1: Beispiel einer Versicherungspolice finden"
);
// Click confirmation
cy.get('[data-cy="it-checkbox-confirmation-1"]').click();
cy.get("[data-cy=\"it-checkbox-confirmation-1\"]").click();
cy.reload();
cy.get('[data-cy="it-checkbox-confirmation-1"]').should(
cy.get("[data-cy=\"it-checkbox-confirmation-1\"]").should(
"have.class",
"cy-checked"
);
@ -179,14 +180,14 @@ describe("assignmentStudent.cy.js", () => {
"Teilaufgabe 2: Kundensituation und Ausgangslage"
);
// Enter text
cy.get('[data-cy="it-textarea-user-text-input-1"]')
cy.get("[data-cy=\"it-textarea-user-text-input-1\"]")
.clear()
.type("Hallovelo");
// wait because of input debounce
cy.wait(550);
cy.reload();
cy.get('[data-cy="it-textarea-user-text-input-1"]').should(
cy.get("[data-cy=\"it-textarea-user-text-input-1\"]").should(
"have.value",
"Hallovelo"
);
@ -200,7 +201,7 @@ describe("assignmentStudent.cy.js", () => {
});
it("can visit sub step by clicking navigation bar", () => {
cy.get('[data-cy="nav-progress-step-4"]').click();
cy.get("[data-cy=\"nav-progress-step-4\"]").click();
cy.testLearningContentTitle("Teilaufgabe 4: Deine Empfehlungen");
});
@ -208,10 +209,10 @@ describe("assignmentStudent.cy.js", () => {
cy.visit(
"/course/test-lehrgang/learn/fahrzeug/überprüfen-einer-motorfahrzeug-versicherungspolice?step=7"
);
cy.get('[data-cy="confirm-submit-results"] label').click();
cy.get('[data-cy="confirm-submit-person"]').click();
cy.get('[data-cy="submit-assignment"]').click();
cy.get('[data-cy="success-text"]').should("exist");
cy.get("[data-cy=\"confirm-submit-results\"] label").click();
cy.get("[data-cy=\"confirm-submit-person\"]").click();
cy.get("[data-cy=\"submit-assignment\"]").click();
cy.get("[data-cy=\"success-text\"]").should("exist");
// Check if trainer received notification
cy.clearLocalStorage();
@ -220,8 +221,8 @@ describe("assignmentStudent.cy.js", () => {
login("test-trainer1@example.com", "test");
cy.visit("/notifications");
cy.get(`[data-cy=notification-idx-0]`).within(() => {
cy.get('[data-cy="unread"]').should("exist");
cy.get('[data-cy="notification-target-idx-0-verb"]').contains(
cy.get("[data-cy=\"unread\"]").should("exist");
cy.get("[data-cy=\"notification-target-idx-0-verb\"]").contains(
"Test Student1 hat die geleitete Fallarbeit «Überprüfen einer Motorfahrzeugs-Versicherungspolice» abgegeben."
);
});
@ -233,14 +234,14 @@ describe("assignmentStudent.cy.js", () => {
"Teilaufgabe 1: Beispiel einer Versicherungspolice finden"
);
// Click confirmation
cy.get('[data-cy="it-checkbox-confirmation-1"]').click();
cy.get("[data-cy=\"it-checkbox-confirmation-1\"]").click();
cy.learningContentMultiLayoutNextStep();
// step 2
cy.testLearningContentTitle(
"Teilaufgabe 2: Kundensituation und Ausgangslage"
);
cy.get('[data-cy="it-textarea-user-text-input-1"]')
cy.get("[data-cy=\"it-textarea-user-text-input-1\"]")
.clear()
.type("Hallo Teilaufgabe 2");
// wait because of input debounce
@ -261,7 +262,7 @@ describe("assignmentStudent.cy.js", () => {
// step 3
cy.testLearningContentTitle("Teilaufgabe 3: Aktuelle Versicherung");
cy.get('[data-cy="it-textarea-user-text-input-1"]')
cy.get("[data-cy=\"it-textarea-user-text-input-1\"]")
.clear()
.type("Hallo Teilaufgabe 3");
// wait because of input debounce
@ -270,15 +271,15 @@ describe("assignmentStudent.cy.js", () => {
// step 4
cy.testLearningContentTitle("Teilaufgabe 4: Deine Empfehlungen");
cy.get('[data-cy="it-textarea-user-text-input-1"]')
cy.get("[data-cy=\"it-textarea-user-text-input-1\"]")
.clear()
.type("Hallo Teilaufgabe 4.1");
cy.wait(550);
cy.get('[data-cy="it-textarea-user-text-input-2"]')
cy.get("[data-cy=\"it-textarea-user-text-input-2\"]")
.clear()
.type("Hallo Teilaufgabe 4.2");
cy.wait(550);
cy.get('[data-cy="it-textarea-user-text-input-3"]')
cy.get("[data-cy=\"it-textarea-user-text-input-3\"]")
.clear()
.type("Hallo Teilaufgabe 4.3");
// wait because of input debounce
@ -287,15 +288,15 @@ describe("assignmentStudent.cy.js", () => {
// step 5
cy.testLearningContentTitle("Teilaufgabe 5: Reflexion");
cy.get('[data-cy="it-textarea-user-text-input-1"]')
cy.get("[data-cy=\"it-textarea-user-text-input-1\"]")
.clear()
.type("Hallo Teilaufgabe 5.1");
cy.wait(550);
cy.get('[data-cy="it-textarea-user-text-input-2"]')
cy.get("[data-cy=\"it-textarea-user-text-input-2\"]")
.clear()
.type("Hallo Teilaufgabe 5.2");
cy.wait(550);
cy.get('[data-cy="it-textarea-user-text-input-3"]')
cy.get("[data-cy=\"it-textarea-user-text-input-3\"]")
.clear()
.type("Hallo Teilaufgabe 5.3");
// wait because of input debounce
@ -304,40 +305,40 @@ describe("assignmentStudent.cy.js", () => {
// step 6
cy.testLearningContentTitle("Teilaufgabe 6: Learnings");
cy.get('[data-cy="it-textarea-user-text-input-1"]')
cy.get("[data-cy=\"it-textarea-user-text-input-1\"]")
.clear()
.type("Hallo Teilaufgabe 6.1");
cy.wait(550);
cy.get('[data-cy="it-textarea-user-text-input-2"]')
cy.get("[data-cy=\"it-textarea-user-text-input-2\"]")
.clear()
.type("Hallo Teilaufgabe 6.2");
// wait because of input debounce
cy.wait(550);
cy.learningContentMultiLayoutNextStep();
cy.get('[data-cy="confirm-submit-person"]').should(
cy.get("[data-cy=\"confirm-submit-person\"]").should(
"contain",
"Ja, die folgende Person soll meine Ergebnisse bewerten."
);
cy.get('[data-cy="confirm-submit-results"] label').click();
cy.get('[data-cy="confirm-submit-person"]').click();
cy.get('[data-cy="submit-assignment"]').click();
cy.get('[data-cy="success-text"]').should("exist");
cy.get("[data-cy=\"confirm-submit-results\"] label").click();
cy.get("[data-cy=\"confirm-submit-person\"]").click();
cy.get("[data-cy=\"submit-assignment\"]").click();
cy.get("[data-cy=\"success-text\"]").should("exist");
cy.get('[data-cy="confirm-container"]')
.find('[data-cy="show-sample-solution"]')
cy.get("[data-cy=\"confirm-container\"]")
.find("[data-cy=\"show-sample-solution\"]")
.then(($elements) => {
if ($elements.length > 0) {
// Ist die Musterlösung da?
cy.get('[data-cy="show-sample-solution"]').should("exist");
cy.get('[data-cy="show-sample-solution-button"]').should("exist");
cy.get("[data-cy=\"show-sample-solution\"]").should("exist");
cy.get("[data-cy=\"show-sample-solution-button\"]").should("exist");
}
});
cy.visit("/course/test-lehrgang/learn/fahrzeug/");
cy.get(
'[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-überprüfen-einer-motorfahrzeug-versicherungspolice-checkbox"]'
"[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-überprüfen-einer-motorfahrzeug-versicherungspolice-checkbox\"]"
).should("have.class", "cy-checked");
//reopening page should get directly to last step
@ -370,10 +371,7 @@ describe("assignmentStudent.cy.js", () => {
});
describe("Praxis Assignment", () => {
it("can make complete assignment without expert", () =>
it("can make complete assignment with mentor", () =>
completePraxisAssignment());
it("can make complete assignment with expert", () =>
completePraxisAssignment(true));
});
});

View File

@ -18,13 +18,7 @@ from vbv_lernwelt.notify.email.email_services import (
)
def main():
print("start")
if __name__ == "__main__":
main()
def send_attendance_course_reminder():
csac = CourseSessionAttendanceCourse.objects.get(pk=1)
print(csac)
print(csac.trainer)
@ -38,3 +32,28 @@ if __name__ == "__main__":
fail_silently=False,
)
print(result)
def send_learning_mentor_invitation():
result = send_email(
recipient_email="daniel.egger+sendgrid@gmail.com",
template=EmailTemplate.LEARNING_MENTOR_INVITATION,
template_data={
"inviter_name": f"Daniel Egger",
"inviter_email": "daniel.egger@example.com",
"target_url": f"https://stage.vbv-afa.ch/foobar",
},
template_language="de",
fail_silently=True,
)
print(result)
def main():
print("start")
# send_attendance_course_reminder()
send_learning_mentor_invitation()
if __name__ == "__main__":
main()

View File

@ -132,6 +132,7 @@ LOCAL_APPS = [
"vbv_lernwelt.edoniq_test",
"vbv_lernwelt.course_session_group",
"vbv_lernwelt.shop",
"vbv_lernwelt.learning_mentor",
]
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS

View File

@ -3,6 +3,10 @@ import os
os.environ["IT_APP_ENVIRONMENT"] = "local"
os.environ["AWS_S3_SECRET_ACCESS_KEY"] = os.environ.get(
"AWS_S3_SECRET_ACCESS_KEY",
"!!!default_for_quieting_tests_within_pycharm!!!",
)
from .base import * # noqa

View File

@ -12,7 +12,7 @@ from django_ratelimit.exceptions import Ratelimited
from graphene_django.views import GraphQLView
from vbv_lernwelt.api.directory import list_entities
from vbv_lernwelt.api.user import me_user_view
from vbv_lernwelt.api.user import get_cockpit_type, me_user_view
from vbv_lernwelt.assignment.views import request_assignment_completion_status
from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt
from vbv_lernwelt.core.schema import schema
@ -127,6 +127,11 @@ urlpatterns = [
path(r"api/course/completion/<signed_int:course_session_id>/<uuid:user_id>/",
request_course_completion_for_user,
name="request_course_completion_for_user"),
path(r"api/course/<signed_int:course_id>/cockpit/",
get_cockpit_type,
name="get_cockpit_type"),
path("api/mentor/<signed_int:course_session_id>/", include("vbv_lernwelt.learning_mentor.urls")),
# assignment
path(

View File

@ -0,0 +1,80 @@
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from vbv_lernwelt.course.creators.test_utils import (
create_course,
create_course_session,
create_user,
)
from vbv_lernwelt.course.models import CourseSessionUser
from vbv_lernwelt.course_session_group.models import CourseSessionGroup
from vbv_lernwelt.learning_mentor.models import LearningMentor
class MeUserViewTest(APITestCase):
def setUp(self) -> None:
self.course, _ = create_course("Test Course")
self.user = create_user("tester")
self.url = reverse("get_cockpit_type", kwargs={"course_id": self.course.id})
def test_no_cockpit(self) -> None:
# GIVEN
self.client.force_login(self.user)
# WHEN
response = self.client.get(self.url)
# THEN
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEquals(response.data["type"], None)
def test_mentor_cockpit(self) -> None:
# GIVEN
self.client.force_login(self.user)
LearningMentor.objects.create(mentor=self.user, course=self.course)
# WHEN
response = self.client.get(self.url)
# THEN
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEquals(response.data["type"], "mentor")
def test_trainer_cockpit(self) -> None:
# GIVEN
self.client.force_login(self.user)
course_session = create_course_session(course=self.course, title="Test Session")
CourseSessionUser.objects.create(
user=self.user,
course_session=course_session,
role=CourseSessionUser.Role.EXPERT,
)
# WHEN
response = self.client.get(self.url)
# THEN
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEquals(response.data["type"], "expert")
def test_supervisor_cockpit(self):
# GIVEN
self.client.force_login(self.user)
course_session = create_course_session(course=self.course, title="Test Session")
csg = CourseSessionGroup.objects.create(
name="Test Group",
course=course_session.course,
)
csg.course_session.add(course_session)
csg.supervisor.add(self.user)
# WHEN
response = self.client.get(self.url)
# THEN
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEquals(response.data["type"], "expert")

View File

@ -1,8 +1,15 @@
from rest_framework.decorators import api_view
from django.shortcuts import get_object_or_404
from rest_framework.decorators import api_view, permission_classes
from rest_framework.generics import get_object_or_404
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from vbv_lernwelt.core.serializers import UserSerializer
from vbv_lernwelt.course.models import Course, CourseSessionUser
from vbv_lernwelt.course_session_group.models import CourseSessionGroup
from vbv_lernwelt.learning_mentor.models import LearningMentor
@api_view(["GET", "PUT"])
def me_user_view(request):
@ -23,3 +30,32 @@ def me_user_view(request):
return Response(UserSerializer(request.user).data)
return Response(status=400)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def get_cockpit_type(request, course_id: int):
course = get_object_or_404(Course, id=course_id)
is_mentor = LearningMentor.objects.filter(
mentor=request.user, course=course
).exists()
is_expert = CourseSessionUser.objects.filter(
user=request.user,
course_session__course=course,
role=CourseSessionUser.Role.EXPERT,
).exists()
is_supervisor = CourseSessionGroup.objects.filter(
course_session__course=course, supervisor=request.user
).exists()
if is_mentor:
cockpit_type = "mentor"
elif is_expert or is_supervisor:
cockpit_type = "expert"
else:
cockpit_type = None
return Response({"type": cockpit_type})

View File

@ -10,7 +10,7 @@ from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletionStatu
from vbv_lernwelt.assignment.services import update_assignment_completion
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseSession, CourseSessionUser
from vbv_lernwelt.iam.permissions import has_course_access, is_course_session_expert
from vbv_lernwelt.iam.permissions import can_evaluate_assignments, has_course_access
logger = structlog.get_logger(__name__)
@ -31,6 +31,7 @@ class AssignmentCompletionMutation(graphene.Mutation):
evaluation_points = graphene.Float()
evaluation_passed = graphene.Boolean()
evaluation_user_id = graphene.ID(required=False)
initialize_completion = graphene.Boolean(required=False)
@classmethod
@ -46,6 +47,7 @@ class AssignmentCompletionMutation(graphene.Mutation):
completion_data_string="{}",
evaluation_points=None,
evaluation_passed=None,
evaluation_user_id=None,
initialize_completion=False,
):
if assignment_user_id is None:
@ -78,13 +80,18 @@ class AssignmentCompletionMutation(graphene.Mutation):
}
if completion_status == AssignmentCompletionStatus.SUBMITTED:
# TODO: determine proper way to assign evaluation user
experts = CourseSessionUser.objects.filter(
course_session=course_session, role="EXPERT"
)
if not experts:
raise PermissionDenied()
assignment_data["evaluation_user"] = experts[0].user
if evaluation_user_id:
assignment_data["evaluation_user"] = User.objects.get(
id=evaluation_user_id
)
else:
# TODO: determine proper way to assign evaluation user
experts = CourseSessionUser.objects.filter(
course_session=course_session, role="EXPERT"
)
if not experts:
raise PermissionDenied()
assignment_data["evaluation_user"] = experts[0].user
evaluation_data = {}
@ -92,7 +99,7 @@ class AssignmentCompletionMutation(graphene.Mutation):
AssignmentCompletionStatus.EVALUATION_SUBMITTED,
AssignmentCompletionStatus.EVALUATION_IN_PROGRESS,
):
if not is_course_session_expert(info.context.user, course_session_id):
if not can_evaluate_assignments(info.context.user, course_session_id):
raise PermissionDenied()
evaluation_data = {

View File

@ -7,7 +7,7 @@ from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletion
from vbv_lernwelt.core.graphql.types import JSONStreamField
from vbv_lernwelt.course.graphql.interfaces import CoursePageInterface
from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.iam.permissions import has_course_access, is_course_session_expert
from vbv_lernwelt.iam.permissions import can_evaluate_assignments, has_course_access
from vbv_lernwelt.learnpath.graphql.types import LearningContentInterface
from vbv_lernwelt.media_files.graphql.types import ContentDocumentObjectType
@ -108,7 +108,7 @@ def resolve_assignment_completion(
if assignment_user_id is None:
assignment_user_id = info.context.user.id
if str(assignment_user_id) == str(info.context.user.id) or is_course_session_expert(
if str(assignment_user_id) == str(info.context.user.id) or can_evaluate_assignments(
info.context.user, course_session_id
):
course_id = CourseSession.objects.get(id=course_session_id).course_id

View File

@ -154,6 +154,7 @@ def update_assignment_completion(
if completion_status == AssignmentCompletionStatus.SUBMITTED:
ac.submitted_at = timezone.now()
if evaluation_user:
ac.evaluation_user = evaluation_user
NotificationService.send_assignment_submitted_notification(
recipient=evaluation_user,
sender=ac.assignment_user,

View File

@ -144,7 +144,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
user_text_input0 = subtasks[0]
user_text_input1 = subtasks[1]
ac = AssignmentCompletion.objects.create(
AssignmentCompletion.objects.create(
assignment_user=self.user,
assignment=self.assignment,
course_session=self.course_session,
@ -163,6 +163,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
assignment=self.assignment,
course_session=self.course_session,
completion_status=AssignmentCompletionStatus.SUBMITTED,
evaluation_user=self.trainer,
)
ac = AssignmentCompletion.objects.get(
@ -173,6 +174,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
self.assertEqual(ac.completion_status, "SUBMITTED")
self.assertEqual(ac.submitted_at.date(), date.today())
self.assertEqual(ac.evaluation_user, self.trainer)
# will create AssignmentCompletionAuditLog entry
acl = AssignmentCompletionAuditLog.objects.get(

View File

@ -4,7 +4,7 @@ from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from vbv_lernwelt.assignment.models import AssignmentCompletion
from vbv_lernwelt.iam.permissions import is_course_session_expert
from vbv_lernwelt.iam.permissions import can_evaluate_assignments
logger = structlog.get_logger(__name__)
@ -12,7 +12,7 @@ logger = structlog.get_logger(__name__)
@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):
if can_evaluate_assignments(request.user, course_session_id):
qs = AssignmentCompletion.objects.filter(
course_session_id=course_session_id,
assignment_id=assignment_id,

View File

@ -24,6 +24,7 @@ TEST_TRAINER2_USER_ID = "299941ae-1e4b-4f45-8180-876c3ad340b4"
TEST_STUDENT1_USER_ID = "65c73ad0-6d53-43a9-a4a4-64143f27b03a"
TEST_STUDENT2_USER_ID = "19c40d94-15cc-4198-aaad-ef707c4b0900"
TEST_STUDENT3_USER_ID = "bcf94dba-53bc-474b-a22d-e4af39aa042b"
TEST_MENTOR1_USER_ID = "d1f5f5a9-5b0a-4e1a-9e1a-9e9b5b5e1b1b"
TEST_COURSE_SESSION_BERN_ID = -1
TEST_COURSE_SESSION_ZURICH_ID = -2

View File

@ -7,6 +7,7 @@ env.read_env()
from vbv_lernwelt.core.constants import (
ADMIN_USER_ID,
TEST_MENTOR1_USER_ID,
TEST_STUDENT1_USER_ID,
TEST_STUDENT2_USER_ID,
TEST_STUDENT3_USER_ID,
@ -342,6 +343,15 @@ def create_default_users(default_password="test"):
language="de",
avatar_url="",
)
_create_user(
_id=TEST_MENTOR1_USER_ID,
email="test-mentor1@example.com",
first_name="[Mentor]",
last_name="Mentor",
password=default_password,
language="de",
avatar_url="",
)
def _get_or_create_user(user_model, *args, **kwargs):

View File

@ -7,6 +7,7 @@ from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletion
from vbv_lernwelt.competence.models import PerformanceCriteria
from vbv_lernwelt.core.constants import (
TEST_COURSE_SESSION_BERN_ID,
TEST_MENTOR1_USER_ID,
TEST_STUDENT1_USER_ID,
TEST_STUDENT2_USER_ID,
TEST_STUDENT3_USER_ID,
@ -25,11 +26,13 @@ from vbv_lernwelt.course.models import (
CourseCompletion,
CourseCompletionStatus,
CourseSession,
CourseSessionUser,
)
from vbv_lernwelt.course.services import mark_course_completion
from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse
from vbv_lernwelt.course_session.services.attendance import AttendanceUserStatus
from vbv_lernwelt.feedback.models import FeedbackResponse
from vbv_lernwelt.learning_mentor.models import LearningMentor
from vbv_lernwelt.learnpath.models import (
LearningContentAttendanceCourse,
LearningContentFeedbackUK,
@ -81,6 +84,11 @@ from vbv_lernwelt.notify.models import Notification
default=True,
help="will enable circle documents for test course",
)
@click.option(
"--create-learning-mentor/--no--create-learning-mentor",
default=False,
help="Will create a learning mentor for test user",
)
def command(
create_assignment_completion,
create_assignment_evaluation,
@ -90,6 +98,7 @@ def command(
create_course_completion_performance_criteria,
create_attendance_days,
enable_circle_documents,
create_learning_mentor,
):
print("cypress reset data")
CourseCompletion.objects.all().delete()
@ -98,6 +107,7 @@ def command(
FeedbackResponse.objects.all().delete()
CourseSessionAttendanceCourse.objects.all().update(attendance_user_list=[])
LearningMentor.objects.all().delete()
User.objects.all().update(language="de")
User.objects.all().update(additional_json_data={})
@ -320,6 +330,18 @@ def command(
)
attendance_course.save()
if create_learning_mentor:
print("Create learning mentor")
mentor = LearningMentor.objects.create(
course=Course.objects.get(id=COURSE_TEST_ID),
mentor=User.objects.get(id=TEST_MENTOR1_USER_ID),
)
course_session = CourseSession.objects.get(id=TEST_COURSE_SESSION_BERN_ID)
csu = CourseSessionUser.objects.get(
user__id=TEST_STUDENT1_USER_ID, course_session=course_session
)
mentor.participants.add(csu)
course = Course.objects.get(id=COURSE_TEST_ID)
course.enable_circle_documents = enable_circle_documents
course.save()

View File

@ -40,6 +40,7 @@ from vbv_lernwelt.course_session.models import (
)
from vbv_lernwelt.course_session_group.models import CourseSessionGroup
from vbv_lernwelt.duedate.models import DueDate
from vbv_lernwelt.learning_mentor.models import LearningMentor
from vbv_lernwelt.learnpath.models import (
Circle,
LearningContentAssignment,
@ -94,6 +95,14 @@ def create_course_session(
)
def add_learning_mentor(
course: Course, mentor: User, mentee: CourseSessionUser
) -> LearningMentor:
learning_mentor = LearningMentor.objects.create(course=course, mentor=mentor)
learning_mentor.participants.add(mentee)
return learning_mentor
def add_course_session_user(
course_session: CourseSession, user: User, role: CourseSessionUser.Role
) -> CourseSessionUser:

View File

@ -94,7 +94,14 @@ class CourseObjectType(DjangoObjectType):
class Meta:
model = Course
fields = ("id", "title", "category_name", "slug", "enable_circle_documents")
fields = (
"id",
"title",
"category_name",
"slug",
"enable_circle_documents",
"circle_contact_type",
)
@staticmethod
def resolve_learning_path(root: Course, info):

View File

@ -45,6 +45,7 @@ from vbv_lernwelt.competence.create_vv_new_competence_profile import (
create_vv_new_competence_profile,
)
from vbv_lernwelt.competence.models import PerformanceCriteria
from vbv_lernwelt.core.constants import TEST_MENTOR1_USER_ID
from vbv_lernwelt.core.create_default_users import default_users
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.consts import (
@ -91,6 +92,7 @@ from vbv_lernwelt.importer.services import (
import_students_from_excel,
import_trainers_from_excel_for_training,
)
from vbv_lernwelt.learning_mentor.models import LearningMentor
from vbv_lernwelt.learnpath.create_vv_new_learning_path import (
create_vv_new_learning_path,
)
@ -213,6 +215,13 @@ def create_versicherungsvermittlerin_course(
cs = CourseSession.objects.create(course_id=course_id, title=names[language])
for assignment in Assignment.objects.all():
if assignment.get_course().id == course_id:
CourseSessionAssignment.objects.get_or_create(
course_session=cs,
learning_content=assignment.find_attached_learning_content(),
)
if language == "de":
for user_data in default_users:
CourseSessionUser.objects.create(
@ -242,6 +251,13 @@ def create_versicherungsvermittlerin_course(
role=CourseSessionUser.Role.EXPERT,
)
lemme = LearningMentor.objects.create(
mentor=User.objects.get(id=TEST_MENTOR1_USER_ID),
course=cs.course,
)
lemme.participants.add(csu)
experts = [expert1, expert2, expert3]
circles = Circle.objects.filter(

View File

@ -0,0 +1,30 @@
# Generated by Django 3.2.20 on 2023-12-21 13:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("course", "0005_course_enable_circle_documents"),
]
operations = [
migrations.AddField(
model_name="course",
name="circle_contact_type",
field=models.CharField(
choices=[("EXPERT", "EXPERT"), ("LEARNING_MENTOR", "LEARNING_MENTOR")],
default="EXPERT",
max_length=50,
),
),
migrations.AlterField(
model_name="coursesessionuser",
name="role",
field=models.CharField(
choices=[("MEMBER", "Teilnehmer"), ("EXPERT", "Experte/Trainer")],
default="MEMBER",
max_length=255,
),
),
]

View File

@ -1,5 +1,5 @@
import enum
import uuid
from enum import Enum
from django.db import models
from django.db.models import UniqueConstraint, Value
@ -15,6 +15,11 @@ from vbv_lernwelt.course.serializer_helpers import get_course_serializer_class
from vbv_lernwelt.files.models import UploadFile
class CircleContactType(Enum):
EXPERT = "EXPERT"
LEARNING_MENTOR = "LEARNING_MENTOR"
class Course(models.Model):
title = models.CharField(_("Titel"), max_length=255)
category_name = models.CharField(
@ -26,6 +31,11 @@ class Course(models.Model):
enable_circle_documents = models.BooleanField(
_("Trainer Dokumente in Circles"), default=True
)
circle_contact_type = models.CharField(
max_length=50,
choices=[(cct.value, cct.value) for cct in CircleContactType],
default=CircleContactType.EXPERT.value,
)
def get_course_url(self):
return f"/course/{self.slug}"
@ -184,7 +194,7 @@ class CoursePage(CourseBasePage):
return f"{self.title}"
class CourseCompletionStatus(enum.Enum):
class CourseCompletionStatus(Enum):
SUCCESS = "SUCCESS"
FAIL = "FAIL"
UNKNOWN = "UNKNOWN"
@ -269,7 +279,6 @@ class CourseSessionUser(models.Model):
class Role(models.TextChoices):
MEMBER = "MEMBER", _("Teilnehmer")
EXPERT = "EXPERT", _("Experte/Trainer")
TUTOR = "TUTOR", _("Lernbegleitung")
role = models.CharField(choices=Role.choices, max_length=255, default=Role.MEMBER)
@ -289,6 +298,9 @@ class CourseSessionUser(models.Model):
]
ordering = ["user__last_name", "user__first_name", "user__email"]
def __str__(self):
return f"{self.user} ({self.course_session.title})"
class CircleDocument(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)

View File

@ -24,6 +24,7 @@ from vbv_lernwelt.iam.permissions import (
is_circle_expert,
is_course_session_expert,
)
from vbv_lernwelt.learning_mentor.models import LearningMentor
logger = structlog.get_logger(__name__)
@ -142,8 +143,17 @@ def get_course_sessions(request):
).values_list("course_session", flat=True)
).prefetch_related("course")
# enrich with mentor course sessions
mentor_course_sessions = CourseSession.objects.filter(
course__in=LearningMentor.objects.filter(mentor=request.user).values_list(
"course", flat=True
)
).prefetch_related("course")
all_to_serialize = (
regular_course_sessions | supervisor_course_sessions
regular_course_sessions
| supervisor_course_sessions
| mentor_course_sessions
).distinct()
return Response(

View File

@ -23,6 +23,7 @@ from vbv_lernwelt.iam.permissions import (
can_view_course_session_group_statistics,
can_view_course_session_progress,
)
from vbv_lernwelt.learning_mentor.models import LearningMentor
class DashboardQuery(graphene.ObjectType):
@ -86,7 +87,13 @@ class DashboardQuery(graphene.ObjectType):
user=user, exclude_course_ids=statistics_dashboard_course_ids
)
return statistic_dashboards + course_session_dashboards
learning_mentor_dashboards = get_learning_mentor_dashboards(user=user)
return (
statistic_dashboards
+ course_session_dashboards
+ learning_mentor_dashboards
)
def resolve_course_progress(root, info, course_id: str): # noqa
"""
@ -174,6 +181,24 @@ def get_user_statistics_dashboards(user: User) -> Tuple[List[Dict[str, str]], Se
return dashboards, course_index
def get_learning_mentor_dashboards(user: User) -> List[Dict[str, str]]:
learning_mentor = LearningMentor.objects.filter(mentor=user)
dashboards = []
for mentor in learning_mentor:
course = mentor.course
dashboards.append(
{
"id": str(course.id),
"name": course.title,
"slug": course.slug,
"dashboard_type": DashboardType.SIMPLE_DASHBOARD,
}
)
return dashboards
def get_user_course_session_dashboards(
user: User, exclude_course_ids: Set[int]
) -> List[Dict[str, str]]:

View File

@ -4,6 +4,7 @@ from vbv_lernwelt.assignment.models import AssignmentType
from vbv_lernwelt.course.creators.test_utils import (
add_course_session_group_supervisor,
add_course_session_user,
add_learning_mentor,
create_assignment,
create_assignment_completion,
create_circle,
@ -213,6 +214,44 @@ class DashboardTestCase(GraphQLTestCase):
self.assertEqual(course_3_config["slug"], course_3.slug)
self.assertEqual(course_3_config["dashboard_type"], "SIMPLE_DASHBOARD")
def test_dashboard_config_mentor(self):
# GIVEN
course_1, _ = create_course("Test Course 1")
cs_1 = create_course_session(course=course_1, title="Test Course 1 Session")
mentor = create_user("learning mentor")
csu = add_course_session_user(
course_session=cs_1,
user=create_user("csu"),
role=CourseSessionUser.Role.MEMBER,
)
add_learning_mentor(course=course_1, mentor=mentor, mentee=csu)
self.client.force_login(mentor)
# WHEN
query = """query {
dashboard_config {
id
name
slug
dashboard_type
}
}
"""
response = self.query(query)
# THEN
self.assertResponseNoErrors(response)
self.assertEqual(len(response.json()["data"]["dashboard_config"]), 1)
self.assertEqual(
response.json()["data"]["dashboard_config"][0]["dashboard_type"],
"SIMPLE_DASHBOARD",
)
def test_course_statistics_deny_not_allowed_user(self):
# GIVEN
disallowed_user = create_user("1337_hacker_schorsch")

View File

@ -1,6 +1,7 @@
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseSession, CourseSessionUser
from vbv_lernwelt.course.models import Course, CourseSession, CourseSessionUser
from vbv_lernwelt.course_session_group.models import CourseSessionGroup
from vbv_lernwelt.learning_mentor.models import LearningMentor
from vbv_lernwelt.learnpath.models import LearningSequence
@ -48,6 +49,34 @@ def is_course_session_expert(user, course_session_id: int):
return is_supervisor or is_expert
def is_course_session_member(user, course_session_id: int | None = None):
if course_session_id is None:
return False
return CourseSessionUser.objects.filter(
course_session_id=course_session_id,
user=user,
role=CourseSessionUser.Role.MEMBER,
).exists()
def can_evaluate_assignments(user, course_session_id: int):
if user.is_superuser:
return True
is_supervisor = CourseSessionGroup.objects.filter(
supervisor=user, course_session__id=course_session_id
).exists()
is_expert = CourseSessionUser.objects.filter(
course_session_id=course_session_id,
user=user,
role=CourseSessionUser.Role.EXPERT,
).exists()
return is_supervisor or is_expert
def course_sessions_for_user_qs(user):
if user.is_superuser:
return CourseSession.objects.all()
@ -106,3 +135,18 @@ def can_view_course_session(user: User, course_session: CourseSession) -> bool:
course_session=course_session,
user=user,
).exists()
def has_role_in_course(user: User, course: Course) -> bool:
if CourseSessionUser.objects.filter(
course_session__course=course, user=user
).exists():
return True
if LearningMentor.objects.filter(course=course, mentor=user).exists():
return True
if CourseSessionGroup.objects.filter(course=course, supervisor=user).exists():
return True
return False

View File

@ -0,0 +1,78 @@
from django.test import TestCase
from vbv_lernwelt.course.creators.test_utils import (
add_course_session_user,
create_course,
create_course_session,
create_user,
)
from vbv_lernwelt.course.models import CourseSessionUser
from vbv_lernwelt.course_session_group.models import CourseSessionGroup
from vbv_lernwelt.iam.permissions import has_role_in_course
from vbv_lernwelt.learning_mentor.models import LearningMentor
class RoleTestCase(TestCase):
def setUp(self):
self.course, _ = create_course("Test Course")
self.course_session = create_course_session(
course=self.course, title="Test Session"
)
self.user = create_user("user")
def test_has_role_regional(self):
# GIVEN
csg = CourseSessionGroup.objects.create(name="Test Group", course=self.course)
csg.supervisor.add(self.user)
# WHEN
has_role = has_role_in_course(user=self.user, course=self.course)
# THEN
self.assertTrue(has_role)
def test_has_role_course_session(self):
# GIVEN
add_course_session_user(
self.course_session,
self.user,
role=CourseSessionUser.Role.MEMBER,
)
# WHEN
has_role = has_role_in_course(user=self.user, course=self.course)
# THEN
self.assertTrue(has_role)
def test_has_role_mentor(self):
# GIVEN
LearningMentor.objects.create(
mentor=self.user,
course=self.course,
)
# WHEN
has_role = has_role_in_course(user=self.user, course=self.course)
# THEN
self.assertTrue(has_role)
def test_no_role(self):
# GIVEN
other_course, _ = create_course("Other Test Course")
other_course_session = create_course_session(
course=other_course, title="Other Test Session"
)
add_course_session_user(
other_course_session,
self.user,
role=CourseSessionUser.Role.MEMBER,
)
# WHEN
has_role = has_role_in_course(user=self.user, course=self.course)
# THEN
self.assertFalse(has_role)

View File

@ -0,0 +1,19 @@
from django.contrib import admin
from vbv_lernwelt.learning_mentor.models import LearningMentor, MentorInvitation
@admin.register(LearningMentor)
class LearningMentorAdmin(admin.ModelAdmin):
def participant_count(self, obj):
return obj.participants.count()
participant_count.short_description = "Participants"
list_display = ["mentor", "course", "participant_count"]
@admin.register(MentorInvitation)
class MentorInvitationAdmin(admin.ModelAdmin):
list_display = ["id", "email", "participant", "created"]
readonly_fields = ["id", "created"]

View File

@ -0,0 +1,9 @@
from django.apps import AppConfig
class LearningMentorConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "vbv_lernwelt.learning_mentor"
def ready(self):
import vbv_lernwelt.learning_mentor.signals # noqa F401

View File

@ -0,0 +1,125 @@
from typing import List, Set, Tuple
from vbv_lernwelt.assignment.models import (
Assignment,
AssignmentCompletion,
AssignmentCompletionStatus,
AssignmentType,
)
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.course_session.models import CourseSessionAssignment
from vbv_lernwelt.learning_mentor.entities import (
CompletionStatus,
PraxisAssignmentCompletion,
PraxisAssignmentStatus,
)
def get_assignment_completions(
course_session: CourseSession,
assignment: Assignment,
participants: List[User],
evaluation_user: User,
) -> List[PraxisAssignmentCompletion]:
evaluation_results = AssignmentCompletion.objects.filter(
assignment_user__in=participants,
course_session=course_session,
assignment=assignment,
evaluation_user=evaluation_user,
).values("completion_status", "assignment_user__last_name", "assignment_user")
user_status_map = {}
for result in evaluation_results:
completion_status = result["completion_status"]
if completion_status == AssignmentCompletionStatus.EVALUATION_SUBMITTED.value:
status = CompletionStatus.EVALUATED
elif completion_status in [
AssignmentCompletionStatus.SUBMITTED.value,
AssignmentCompletionStatus.EVALUATION_IN_PROGRESS.value,
]:
status = CompletionStatus.SUBMITTED
else:
status = CompletionStatus.UNKNOWN
user_status_map[result["assignment_user"]] = (
status,
result["assignment_user__last_name"],
)
status_priority = {
CompletionStatus.SUBMITTED: 1,
CompletionStatus.EVALUATED: 2,
CompletionStatus.UNKNOWN: 3,
}
sorted_participants = sorted(
participants,
key=lambda u: (
status_priority.get(
user_status_map.get(u.id, (CompletionStatus.UNKNOWN, ""))[0]
),
user_status_map.get(u.id, ("", u.last_name))[1],
),
)
return [
PraxisAssignmentCompletion(
status=user_status_map.get(
user.id, (CompletionStatus.UNKNOWN, user.last_name)
)[0],
user_id=user.id,
last_name=user.last_name,
)
for user in sorted_participants
]
def get_praxis_assignments(
course_session: CourseSession, participants: List[User], evaluation_user: User
) -> Tuple[List[PraxisAssignmentStatus], Set[int]]:
records = []
circle_ids = set()
if not participants:
return records, circle_ids
for course_session_assignment in CourseSessionAssignment.objects.filter(
course_session=course_session,
learning_content__assignment_type__in=[
AssignmentType.PRAXIS_ASSIGNMENT.value,
],
):
learning_content = course_session_assignment.learning_content
completions = get_assignment_completions(
course_session=course_session,
assignment=learning_content.content_assignment,
participants=participants,
evaluation_user=evaluation_user,
)
submitted_count = len(
[
completion
for completion in completions
if completion.status == CompletionStatus.SUBMITTED
]
)
circle_id = learning_content.get_circle().id
records.append(
PraxisAssignmentStatus(
id=course_session_assignment.id,
title=learning_content.content_assignment.title,
circle_id=circle_id,
pending_evaluations=submitted_count,
completions=completions,
)
)
circle_ids.add(circle_id)
return records, circle_ids

View File

@ -0,0 +1,25 @@
from dataclasses import dataclass
from enum import Enum
from typing import List
class CompletionStatus(str, Enum):
UNKNOWN = "UNKNOWN"
SUBMITTED = "SUBMITTED"
EVALUATED = "EVALUATED"
@dataclass
class PraxisAssignmentCompletion:
status: CompletionStatus
user_id: str
last_name: str
@dataclass
class PraxisAssignmentStatus:
id: str
title: str
circle_id: str
pending_evaluations: int
completions: List[PraxisAssignmentCompletion]

View File

@ -0,0 +1,55 @@
# Generated by Django 3.2.20 on 2023-12-07 08:39
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("course", "0005_course_enable_circle_documents"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="LearningMentor",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"course",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="course.course"
),
),
(
"mentor",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
(
"participants",
models.ManyToManyField(
blank=True,
related_name="participants",
to="course.CourseSessionUser",
),
),
],
options={
"unique_together": {("mentor", "course")},
},
),
]

View File

@ -0,0 +1,55 @@
# Generated by Django 3.2.20 on 2023-12-07 13:46
import uuid
import django.db.models.deletion
import django_extensions.db.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("course", "0005_course_enable_circle_documents"),
("learning_mentor", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="MentorInvitation",
fields=[
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("email", models.EmailField(max_length=254)),
(
"participant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="course.coursesessionuser",
),
),
],
options={
"verbose_name": "Mentor Invitation",
"verbose_name_plural": "Mentor Invitations",
},
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 3.2.20 on 2023-12-07 13:48
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("learning_mentor", "0002_mentorinvitation"),
]
operations = [
migrations.AlterModelOptions(
name="learningmentor",
options={
"verbose_name": "Lernbegleiter",
"verbose_name_plural": "Lernbegleiter",
},
),
migrations.AlterModelOptions(
name="mentorinvitation",
options={
"verbose_name": "Lernbegleiter Einladung",
"verbose_name_plural": "Lernbegleiter Einladungen",
},
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.20 on 2023-12-11 09:00
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("course", "0005_course_enable_circle_documents"),
("learning_mentor", "0003_auto_20231207_1448"),
]
operations = [
migrations.AlterUniqueTogether(
name="mentorinvitation",
unique_together={("email", "participant")},
),
]

View File

@ -0,0 +1,44 @@
import uuid
from django.db import models
from django_extensions.db.models import TimeStampedModel
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseSessionUser
class LearningMentor(models.Model):
mentor = models.OneToOneField(User, on_delete=models.CASCADE)
course = models.ForeignKey("course.Course", on_delete=models.CASCADE)
participants = models.ManyToManyField(
CourseSessionUser,
related_name="participants",
blank=True,
)
class Meta:
unique_together = [["mentor", "course"]]
verbose_name = "Lernbegleiter"
verbose_name_plural = "Lernbegleiter"
def __str__(self):
return f"{self.mentor} ({self.course.title})"
@property
def course_sessions(self):
return self.participants.values_list("course_session", flat=True).distinct()
class MentorInvitation(TimeStampedModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
email = models.EmailField()
participant = models.ForeignKey(CourseSessionUser, on_delete=models.CASCADE)
def __str__(self):
return f"{self.email} ({self.participant})"
class Meta:
verbose_name = "Lernbegleiter Einladung"
verbose_name_plural = "Lernbegleiter Einladungen"
unique_together = [["email", "participant"]]

View File

@ -0,0 +1,46 @@
from rest_framework import serializers
from vbv_lernwelt.core.serializers import UserSerializer
from vbv_lernwelt.learning_mentor.models import LearningMentor, MentorInvitation
class PraxisAssignmentCompletionSerializer(serializers.Serializer):
status = serializers.SerializerMethodField()
user_id = serializers.CharField()
last_name = serializers.CharField()
@staticmethod
def get_status(obj):
return obj.status.value
class PraxisAssignmentStatusSerializer(serializers.Serializer):
id = serializers.CharField()
title = serializers.CharField()
circle_id = serializers.CharField()
pending_evaluations = serializers.IntegerField()
completions = PraxisAssignmentCompletionSerializer(many=True)
type = serializers.ReadOnlyField(default="praxis_assignment")
class InvitationSerializer(serializers.ModelSerializer):
class Meta:
model = MentorInvitation
fields = ["id", "email"]
read_only_fields = ["id"]
def create(self, validated_data):
participant = self.context["course_session_user"]
invitation, _ = MentorInvitation.objects.get_or_create(
email=validated_data["email"], participant=participant
)
return invitation
class MentorSerializer(serializers.ModelSerializer):
mentor = UserSerializer(read_only=True)
class Meta:
model = LearningMentor
fields = ["id", "mentor"]
read_only_fields = ["id"]

View File

@ -0,0 +1,16 @@
from django.core.exceptions import ValidationError
from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from .models import LearningMentor
@receiver(m2m_changed, sender=LearningMentor.participants.through)
def validate_student(sender, instance, action, reverse, model, pk_set, **kwargs):
if action == "pre_add":
participants = model.objects.filter(pk__in=pk_set)
for participant in participants:
if participant.course_session.course != instance.course:
raise ValidationError(
"Participant (CourseSessionUser) does not match the course for this mentor."
)

View File

@ -0,0 +1,108 @@
from django.test import TestCase
from vbv_lernwelt.assignment.models import (
AssignmentCompletion,
AssignmentCompletionStatus,
AssignmentType,
)
from vbv_lernwelt.course.creators.test_utils import (
create_assignment,
create_assignment_learning_content,
create_circle,
create_course,
create_course_session,
create_course_session_assignment,
create_user,
)
from vbv_lernwelt.learning_mentor.content.praxis_assignment import (
get_assignment_completions,
get_praxis_assignments,
)
from vbv_lernwelt.learning_mentor.entities import CompletionStatus
class AttendanceServicesTestCase(TestCase):
def setUp(self):
self.mentor = create_user("Mentor")
self.user1 = create_user("Alpha")
self.user2 = create_user("Beta")
self.user3 = create_user("Kappa")
self.user4 = create_user("Gamma")
self.course, self.course_page = create_course("Test Course")
self.course_session = create_course_session(course=self.course, title=":)")
self.circle, _ = create_circle(title="Circle", course_page=self.course_page)
self.assignment = create_assignment(
course=self.course, assignment_type=AssignmentType.PRAXIS_ASSIGNMENT
)
AssignmentCompletion.objects.create(
assignment_user=self.user1,
course_session=self.course_session,
assignment=self.assignment,
completion_status=AssignmentCompletionStatus.EVALUATION_SUBMITTED.value,
evaluation_user=self.mentor,
)
AssignmentCompletion.objects.create(
assignment_user=self.user2,
course_session=self.course_session,
assignment=self.assignment,
completion_status=AssignmentCompletionStatus.SUBMITTED.value,
evaluation_user=self.mentor,
)
AssignmentCompletion.objects.create(
assignment_user=self.user3,
course_session=self.course_session,
assignment=self.assignment,
completion_status=AssignmentCompletionStatus.IN_PROGRESS.value,
evaluation_user=self.mentor,
)
def test_assignment_completions(self):
# GIVEN
participants = [self.user1, self.user2, self.user3, self.user4]
# WHEN
results = get_assignment_completions(
course_session=self.course_session,
assignment=self.assignment,
participants=participants,
evaluation_user=self.mentor,
)
# THEN
expected_order = ["Beta", "Alpha", "Gamma", "Kappa"]
expected_statuses = {
"Alpha": CompletionStatus.EVALUATED, # user1
"Beta": CompletionStatus.SUBMITTED, # user2
"Gamma": CompletionStatus.UNKNOWN, # user4 (no AssignmentCompletion)
"Kappa": CompletionStatus.UNKNOWN, # user3 (IN_PROGRESS should be PENDING)
}
self.assertEqual(len(results), len(participants))
for i, result in enumerate(results):
self.assertEqual(result.last_name, expected_order[i])
self.assertEqual(result.status, expected_statuses[result.last_name])
def test_praxis_assignment_status(self):
# GIVEN
lca = create_assignment_learning_content(self.circle, self.assignment)
create_course_session_assignment(
course_session=self.course_session, learning_content_assignment=lca
)
participants = [self.user1, self.user2, self.user3, self.user4]
# WHEN
assignments, circle_ids = get_praxis_assignments(
course_session=self.course_session,
participants=participants,
evaluation_user=self.mentor,
)
# THEN
assignment = assignments[0]
self.assertEqual(assignment.pending_evaluations, 1)
self.assertEqual(assignment.title, "Dummy Assignment (PRAXIS_ASSIGNMENT)")
self.assertEqual(assignment.circle_id, self.circle.id)
self.assertEqual(list(circle_ids)[0], self.circle.id)

View File

@ -0,0 +1,239 @@
from unittest.mock import patch
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from vbv_lernwelt.course.creators.test_utils import (
add_course_session_user,
create_course,
create_course_session,
create_user,
)
from vbv_lernwelt.course.models import CourseSessionUser
from vbv_lernwelt.learning_mentor.models import LearningMentor, MentorInvitation
from vbv_lernwelt.notify.email.email_services import EmailTemplate
class LearningMentorInvitationTest(APITestCase):
def setUp(self) -> None:
self.course, self.course_page = create_course("Test Course")
self.course_session = create_course_session(course=self.course, title="Test VV")
self.participant = create_user("participant")
def test_create_invitation_not_member(self) -> None:
# GIVEN
self.client.force_login(self.participant)
invite_url = reverse(
"create_invitation", kwargs={"course_session_id": self.course_session.id}
)
# WHEN
response = self.client.post(invite_url)
# THEN
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
@patch("vbv_lernwelt.learning_mentor.views.send_email")
def test_create_invitation(self, mock_send_mail) -> None:
# GIVEN
self.client.force_login(self.participant)
add_course_session_user(
self.course_session,
self.participant,
role=CourseSessionUser.Role.MEMBER,
)
invite_url = reverse(
"create_invitation", kwargs={"course_session_id": self.course_session.id}
)
email = "test@example.com"
# WHEN
response = self.client.post(invite_url, data={"email": email})
# THEN
invitation_id = response.data["id"]
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(
MentorInvitation.objects.filter(
id=invitation_id,
).exists()
)
mock_send_mail.assert_called_once_with(
recipient_email=email,
template=EmailTemplate.LEARNING_MENTOR_INVITATION,
template_data={
"inviter_name": f"{self.participant.first_name} {self.participant.last_name}",
"inviter_email": self.participant.email,
"target_url": f"https://my.vbv-afa.ch/lernbegleitung/{self.course_session.id}/invitation/{invitation_id}",
},
template_language=self.participant.language,
fail_silently=True,
)
def test_list_invitations(self) -> None:
# GIVEN
self.client.force_login(self.participant)
participant_cs_user = add_course_session_user(
self.course_session,
self.participant,
role=CourseSessionUser.Role.MEMBER,
)
email = "test@example.com"
MentorInvitation.objects.create(participant=participant_cs_user, email=email)
list_url = reverse(
"list_invitations", kwargs={"course_session_id": self.course_session.id}
)
# WHEN
response = self.client.get(list_url)
# THEN
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
response.data,
[{"id": str(MentorInvitation.objects.get(email=email).id), "email": email}],
)
def test_delete_invitation(self) -> None:
# GIVEN
self.client.force_login(self.participant)
participant_cs_user = add_course_session_user(
self.course_session,
self.participant,
role=CourseSessionUser.Role.MEMBER,
)
email = "test@example.com"
invitation = MentorInvitation.objects.create(
participant=participant_cs_user, email=email
)
delete_url = reverse(
"delete_invitation",
kwargs={
"course_session_id": self.course_session.id,
"invitation_id": invitation.id,
},
)
# WHEN
response = self.client.delete(delete_url)
# THEN
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertFalse(MentorInvitation.objects.filter(id=invitation.id).exists())
def test_accept_invitation_invalid_course(self) -> None:
# GIVEN
other_course, _ = create_course("Other Test Course")
other_course_session = create_course_session(
course=other_course, title="Other Test Session"
)
participant_cs_user = add_course_session_user(
self.course_session,
self.participant,
role=CourseSessionUser.Role.MEMBER,
)
invitee = create_user("invitee")
invitation = MentorInvitation.objects.create(
participant=participant_cs_user, email=invitee.email
)
self.client.force_login(invitee)
accept_url = reverse(
"accept_invitation", kwargs={"course_session_id": other_course_session.id}
)
# WHEN
response = self.client.post(accept_url, data={"invitation_id": invitation.id})
# THEN
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(
response.data,
{
"message": "Invalid invitation",
"code": "invalidInvitation",
},
)
def test_accept_invitation_role_collision(self) -> None:
# GIVEN
participant_cs_user = add_course_session_user(
self.course_session,
self.participant,
role=CourseSessionUser.Role.MEMBER,
)
invitee = create_user("invitee")
invitation = MentorInvitation.objects.create(
participant=participant_cs_user, email=invitee.email
)
# Make invitee a trainer
add_course_session_user(
self.course_session,
invitee,
role=CourseSessionUser.Role.EXPERT,
)
self.client.force_login(invitee)
accept_url = reverse(
"accept_invitation", kwargs={"course_session_id": self.course_session.id}
)
# WHEN
response = self.client.post(accept_url, data={"invitation_id": invitation.id})
# THEN
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(
response.data,
{
"message": "User already has a role in this course",
"code": "existingRole",
},
)
def test_accept_invitation(self) -> None:
# GIVEN
participant_cs_user = add_course_session_user(
self.course_session,
self.participant,
role=CourseSessionUser.Role.MEMBER,
)
invitee = create_user("invitee")
invitation = MentorInvitation.objects.create(
participant=participant_cs_user, email=invitee.email
)
self.client.force_login(invitee)
accept_url = reverse(
"accept_invitation", kwargs={"course_session_id": self.course_session.id}
)
# WHEN
response = self.client.post(accept_url, data={"invitation_id": invitation.id})
# THEN
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertFalse(MentorInvitation.objects.filter(id=invitation.id).exists())
self.assertTrue(
LearningMentor.objects.filter(
mentor=invitee, course=self.course, participants=participant_cs_user
).exists()
)
user = response.data["user"]
self.assertEqual(user["id"], str(self.participant.id))
self.assertEqual(response.data["course_slug"], str(self.course.slug))

View File

@ -0,0 +1,234 @@
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from vbv_lernwelt.assignment.models import (
AssignmentCompletion,
AssignmentCompletionStatus,
AssignmentType,
)
from vbv_lernwelt.course.creators.test_utils import (
add_course_session_user,
create_assignment,
create_assignment_learning_content,
create_circle,
create_course,
create_course_session,
create_course_session_assignment,
create_user,
)
from vbv_lernwelt.course.models import CourseSessionUser
from vbv_lernwelt.learning_mentor.models import LearningMentor
class LearningMentorAPITest(APITestCase):
def setUp(self) -> None:
self.course, self.course_page = create_course("Test Course")
self.course_session = create_course_session(course=self.course, title="Test VV")
self.circle, _ = create_circle(title="Circle", course_page=self.course_page)
self.assignment = create_assignment(
course=self.course, assignment_type=AssignmentType.PRAXIS_ASSIGNMENT
)
lca = create_assignment_learning_content(self.circle, self.assignment)
create_course_session_assignment(
course_session=self.course_session, learning_content_assignment=lca
)
self.mentor = create_user("mentor")
self.participant_1 = add_course_session_user(
self.course_session,
create_user("participant_1"),
role=CourseSessionUser.Role.MEMBER,
)
self.participant_2 = add_course_session_user(
self.course_session,
create_user("participant_2"),
role=CourseSessionUser.Role.MEMBER,
)
self.participant_3 = add_course_session_user(
self.course_session,
create_user("participant_3"),
role=CourseSessionUser.Role.MEMBER,
)
self.url = reverse(
"mentor_summary", kwargs={"course_session_id": self.course_session.id}
)
def test_api_no_mentor(self) -> None:
# GIVEN
self.client.force_login(self.mentor)
# WHEN
response = self.client.get(self.url)
# THEN
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_api_no_participants(self) -> None:
# GIVEN
self.client.force_login(self.mentor)
LearningMentor.objects.create(
mentor=self.mentor, course=self.course_session.course
)
# WHEN
response = self.client.get(self.url)
# THEN
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["participants"], [])
self.assertEqual(response.data["assignments"], [])
def test_api_participants(self) -> None:
# GIVEN
participants = [self.participant_1, self.participant_2, self.participant_3]
self.client.force_login(self.mentor)
mentor = LearningMentor.objects.create(
mentor=self.mentor,
course=self.course_session.course,
)
mentor.participants.set(participants)
# WHEN
response = self.client.get(self.url)
# THEN
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data["participants"]), len(participants))
participant_1 = [
p
for p in response.data["participants"]
if p["id"] == str(self.participant_1.user.id)
][0]
self.assertEqual(participant_1["email"], "participant_1@example.com")
self.assertEqual(participant_1["first_name"], "Test")
self.assertEqual(participant_1["last_name"], "Participant_1")
def test_api_praxis_assignments(self) -> None:
# GIVEN
participants = [self.participant_1, self.participant_2, self.participant_3]
self.client.force_login(self.mentor)
mentor = LearningMentor.objects.create(
mentor=self.mentor,
course=self.course_session.course,
)
mentor.participants.set(participants)
AssignmentCompletion.objects.create(
assignment_user=self.participant_1.user,
course_session=self.course_session,
assignment=self.assignment,
completion_status=AssignmentCompletionStatus.EVALUATION_SUBMITTED.value,
evaluation_user=self.mentor,
)
AssignmentCompletion.objects.create(
assignment_user=self.participant_3.user,
course_session=self.course_session,
assignment=self.assignment,
completion_status=AssignmentCompletionStatus.SUBMITTED.value,
evaluation_user=self.mentor,
)
# WHEN
response = self.client.get(self.url)
# THEN
assignments = response.data["assignments"]
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(assignments), 1)
self.assertEqual(
response.data["circles"],
[{"id": self.circle.id, "title": self.circle.title}],
)
assignment = assignments[0]
self.assertEqual(assignment["type"], "praxis_assignment")
self.assertEqual(assignment["pending_evaluations"], 1)
self.assertEqual(
assignment["completions"][0]["last_name"], self.participant_3.user.last_name
)
self.assertEqual(
assignment["completions"][1]["last_name"], self.participant_1.user.last_name
)
self.assertEqual(
assignment["completions"][2]["last_name"], self.participant_2.user.last_name
)
def test_list_user_mentors(self) -> None:
# GIVEN
participant = create_user("participant")
self.client.force_login(participant)
participant_cs_user = add_course_session_user(
self.course_session,
participant,
role=CourseSessionUser.Role.MEMBER,
)
learning_mentor = LearningMentor.objects.create(
mentor=self.mentor,
course=self.course_session.course,
)
learning_mentor.participants.add(participant_cs_user)
list_url = reverse(
"list_user_mentors", kwargs={"course_session_id": self.course_session.id}
)
# WHEN
response = self.client.get(list_url)
# THEN
self.assertEqual(response.status_code, status.HTTP_200_OK)
mentor = response.data[0]
self.assertEqual(mentor["id"], learning_mentor.id)
mentor_user = mentor["mentor"]
self.assertEqual(mentor_user["email"], self.mentor.email)
self.assertEqual(mentor_user["id"], str(self.mentor.id))
def test_remove_user_mentor(self) -> None:
# GIVEN
participant = create_user("participant")
self.client.force_login(participant)
participant_cs_user = add_course_session_user(
self.course_session,
participant,
role=CourseSessionUser.Role.MEMBER,
)
learning_mentor = LearningMentor.objects.create(
mentor=self.mentor,
course=self.course_session.course,
)
learning_mentor.participants.add(participant_cs_user)
remove_self_url = reverse(
"remove_self_from_mentor",
kwargs={
"course_session_id": self.course_session.id,
"mentor_id": learning_mentor.id,
},
)
# WHEN
response = self.client.delete(remove_self_url)
# THEN
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertFalse(
LearningMentor.objects.filter(participants=participant_cs_user).exists()
)

View File

@ -0,0 +1,21 @@
from django.urls import path
from . import views
urlpatterns = [
path("summary", views.mentor_summary, name="mentor_summary"),
path("mentors", views.list_user_mentors, name="list_user_mentors"),
path(
"mentors/<int:mentor_id>/leave",
views.remove_self_from_mentor,
name="remove_self_from_mentor",
),
path("invitations", views.list_invitations, name="list_invitations"),
path("invitations/create", views.create_invitation, name="create_invitation"),
path(
"invitations/<uuid:invitation_id>/delete",
views.delete_invitation,
name="delete_invitation",
),
path("invitations/accept", views.accept_invitation, name="accept_invitation"),
]

View File

@ -0,0 +1,205 @@
from uuid import UUID
from rest_framework import permissions, status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.generics import get_object_or_404
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from vbv_lernwelt.core.serializers import UserSerializer
from vbv_lernwelt.course.models import CourseSession, CourseSessionUser
from vbv_lernwelt.iam.permissions import has_role_in_course, is_course_session_member
from vbv_lernwelt.learning_mentor.content.praxis_assignment import (
get_praxis_assignments,
)
from vbv_lernwelt.learning_mentor.models import LearningMentor, MentorInvitation
from vbv_lernwelt.learning_mentor.serializers import (
InvitationSerializer,
MentorSerializer,
PraxisAssignmentStatusSerializer,
)
from vbv_lernwelt.learnpath.models import Circle
from vbv_lernwelt.notify.email.email_services import EmailTemplate, send_email
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def mentor_summary(request, course_session_id: int):
course_session = CourseSession.objects.get(id=course_session_id)
mentor = get_object_or_404(
LearningMentor, mentor=request.user, course=course_session.course
)
participants = mentor.participants.filter(course_session=course_session)
users = [p.user for p in participants]
assignments = []
circle_ids = set()
praxis_assignments, _circle_ids = get_praxis_assignments(
course_session=course_session, participants=users, evaluation_user=request.user
)
assignments.extend(
PraxisAssignmentStatusSerializer(praxis_assignments, many=True).data
)
circle_ids.update(_circle_ids)
circles = Circle.objects.filter(id__in=circle_ids).values("id", "title")
assignments.sort(
key=lambda x: (-x.get("pending_evaluations", 0), x.get("title", "").lower())
)
return Response(
{
"participants": [UserSerializer(user).data for user in users],
"circles": list(circles),
"assignments": assignments,
}
)
class CourseSessionMember(permissions.BasePermission):
def has_permission(self, request, view):
return is_course_session_member(
request.user, view.kwargs.get("course_session_id")
)
@api_view(["GET"])
@permission_classes([IsAuthenticated, CourseSessionMember])
def list_invitations(request, course_session_id: int):
course_session = get_object_or_404(CourseSession, id=course_session_id)
course_session_user = get_object_or_404(
CourseSessionUser, user=request.user, course_session=course_session
)
invitations = MentorInvitation.objects.filter(participant=course_session_user)
serializer = InvitationSerializer(invitations, many=True)
return Response(serializer.data)
@api_view(["DELETE"])
@permission_classes([IsAuthenticated, CourseSessionMember])
def delete_invitation(request, course_session_id: int, invitation_id: UUID):
course_session = get_object_or_404(CourseSession, id=course_session_id)
course_session_user = get_object_or_404(
CourseSessionUser, user=request.user, course_session=course_session
)
get_object_or_404(
MentorInvitation, id=invitation_id, participant=course_session_user
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(["POST"])
@permission_classes([IsAuthenticated, CourseSessionMember])
def create_invitation(request, course_session_id: int):
user = request.user
course_session = get_object_or_404(CourseSession, id=course_session_id)
course_session_user = get_object_or_404(
CourseSessionUser, user=user, course_session=course_session
)
serializer = InvitationSerializer(
data=request.data, context={"course_session_user": course_session_user}
)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
invitation = serializer.save()
target_url = f"/lernbegleitung/{course_session_id}/invitation/{invitation.id}"
send_email(
recipient_email=invitation.email,
template=EmailTemplate.LEARNING_MENTOR_INVITATION,
template_data={
"inviter_name": f"{user.first_name} {user.last_name}",
"inviter_email": user.email,
"target_url": f"https://my.vbv-afa.ch{target_url}",
},
template_language=request.user.language,
fail_silently=True,
)
return Response(serializer.data)
@api_view(["GET"])
@permission_classes([IsAuthenticated, CourseSessionMember])
def list_user_mentors(request, course_session_id: int):
course_session = get_object_or_404(CourseSession, id=course_session_id)
course_session_user = get_object_or_404(
CourseSessionUser, user=request.user, course_session=course_session
)
mentors = LearningMentor.objects.filter(
course=course_session.course, participants=course_session_user
)
return Response(MentorSerializer(mentors, many=True).data)
@api_view(["DELETE"])
@permission_classes([IsAuthenticated, CourseSessionMember])
def remove_self_from_mentor(request, course_session_id: int, mentor_id: int):
course_session = get_object_or_404(CourseSession, id=course_session_id)
course_session_user = get_object_or_404(
CourseSessionUser, user=request.user, course_session=course_session
)
mentor = get_object_or_404(LearningMentor, id=mentor_id)
mentor.participants.remove(course_session_user)
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def accept_invitation(request, course_session_id: int):
course_session = get_object_or_404(CourseSession, id=course_session_id)
invitation = get_object_or_404(
MentorInvitation, id=request.data.get("invitation_id")
)
if invitation.participant.course_session != course_session:
return Response(
data={"message": "Invalid invitation", "code": "invalidInvitation"},
status=status.HTTP_400_BAD_REQUEST,
)
if LearningMentor.objects.filter(
mentor=request.user, course=course_session.course
).exists():
mentor = LearningMentor.objects.get(
mentor=request.user, course=course_session.course
)
else:
if has_role_in_course(request.user, course_session.course):
return Response(
data={
"message": "User already has a role in this course",
"code": "existingRole",
},
status=status.HTTP_400_BAD_REQUEST,
)
mentor = LearningMentor.objects.create(
mentor=request.user, course=course_session.course
)
mentor.participants.add(invitation.participant)
invitation.delete()
return Response(
{
"course_slug": course_session.course.slug,
"user": UserSerializer(invitation.participant.user).data,
}
)

View File

@ -1,4 +1,6 @@
import datetime
import os
from unittest import skipIf
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
@ -11,6 +13,10 @@ from vbv_lernwelt.media_files.models import ContentDocument
TITLE = "Musterlösung Fahrzeug"
@skipIf(
os.environ.get("ENABLE_S3_STORAGE_UNIT_TESTS") is None,
"Only enable tests by setting ENABLE_S3_STORAGE_UNIT_TESTS=1",
)
class TestContentDocumentServing(TestCase):
def setUp(self):
create_default_users()

View File

@ -1,4 +1,5 @@
import datetime
import os
from unittest import skipIf
from django.conf import settings
@ -11,6 +12,10 @@ from vbv_lernwelt.media_files.models import ContentDocument
TITLE = "Musterlösung Fahrzeug"
@skipIf(
os.environ.get("ENABLE_S3_STORAGE_UNIT_TESTS") is None,
"Only enable tests by setting ENABLE_S3_STORAGE_UNIT_TESTS=1",
)
class TestContentDocumentStorage(TestCase):
@override_settings(FILE_UPLOAD_STORAGE="s3")
def setUp(self):

View File

@ -69,6 +69,13 @@ class EmailTemplate(Enum):
"it": "d-0882ec9c92f64312b9f358481a943c9a",
}
# VBV - Lernbegleitung Einladung
LEARNING_MENTOR_INVITATION = {
"de": "d-8c862afde62748b6b8410887eeee89d8",
"fr": "d-7451e3c858954c15a9f410fa9d92dc06",
"it": "d-30c6aa9accda4973a940dd25703cb4a9",
}
def send_email(
recipient_email: str,